From fa9ca35053bf9463747f3ab722fefd0fbd8d1d95 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Wed, 15 Nov 2023 21:22:17 +0800 Subject: [PATCH 001/259] refactor(dev): improve eslint and jsconfig --- eslint.config.js | 18 +++++++++++++++++- scripts/build-app.js | 1 + scripts/build-ext-v4.js | 1 + scripts/build-ext-v5.js | 1 + scripts/jsconfig.json | 26 ++++++++++++++++++++++++++ scripts/preview-app.js | 1 + src/ext/background/main.js | 2 +- 7 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 scripts/jsconfig.json diff --git a/eslint.config.js b/eslint.config.js index e2bd75e7..5b5fa18f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -49,7 +49,23 @@ export default [ // https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options { - files: ["src/**/*.{js,svelte}"], + files: ["scripts/**/*.js"], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { + files: ["src/{app,dev}/**/*.{js,svelte}"], + languageOptions: { + globals: { + ...globals.browser, + }, + }, + }, + { + files: ["src/ext/**/*.{js,svelte}"], languageOptions: { globals: { ...globals.browser, diff --git a/scripts/build-app.js b/scripts/build-app.js index 2fedc7ce..f4713f70 100644 --- a/scripts/build-app.js +++ b/scripts/build-app.js @@ -14,6 +14,7 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; * Disable auto resolving {@link vite.config.js} * @see {@link https://vitejs.dev/config/ Config} * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig configFile} + * @type {import("vite").InlineConfig} */ const defineConfig = { base: "./", diff --git a/scripts/build-ext-v4.js b/scripts/build-ext-v4.js index 03b01c3a..4a091260 100644 --- a/scripts/build-ext-v4.js +++ b/scripts/build-ext-v4.js @@ -22,6 +22,7 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; * Disable auto resolving {@link vite.config.js} * @see {@link https://vitejs.dev/config/ Config} * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig configFile} + * @type {import("vite").InlineConfig} */ const defineConfig = { base: "./", diff --git a/scripts/build-ext-v5.js b/scripts/build-ext-v5.js index e810c972..605317b0 100644 --- a/scripts/build-ext-v5.js +++ b/scripts/build-ext-v5.js @@ -21,6 +21,7 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; * Disable auto resolving {@link vite.config.js} * @see {@link https://vitejs.dev/config/ Config} * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig configFile} + * @type {import("vite").InlineConfig} */ const defineConfig = { base: "./", diff --git a/scripts/jsconfig.json b/scripts/jsconfig.json new file mode 100644 index 00000000..f0633242 --- /dev/null +++ b/scripts/jsconfig.json @@ -0,0 +1,26 @@ +// https://code.visualstudio.com/docs/languages/jsconfig +// https://www.typescriptlang.org/docs/handbook/tsconfig-json.html +// https://www.typescriptlang.org/tsconfig + +// https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html +// https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html + +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "nodenext", + + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["**/*.d.ts", "**/*.js"] +} diff --git a/scripts/preview-app.js b/scripts/preview-app.js index 691f601c..d9060d8d 100644 --- a/scripts/preview-app.js +++ b/scripts/preview-app.js @@ -10,6 +10,7 @@ import { preview } from "vite"; * Disable auto resolving {@link vite.config.js} * @see {@link https://vitejs.dev/config/ Config} * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig configFile} + * @type {import("vite").InlineConfig} */ const defineConfig = { base: "./", diff --git a/src/ext/background/main.js b/src/ext/background/main.js index 5d481b2d..e0530cae 100644 --- a/src/ext/background/main.js +++ b/src/ext/background/main.js @@ -321,7 +321,7 @@ async function handleMessage(request, sender, sendResponse) { } case "API_ADD_STYLE": { const tabId = sender.tab.id; - /** @type {{code: string, cssOrigin: "user"|"author"}} */ + /** @type {import("webextension-polyfill").ExtensionTypes.InjectDetails} */ const details = { code: request.css, cssOrigin: "user" }; return browser.tabs.insertCSS(tabId, details); } From 99c5f00dd77adb8aa4a84de93afb49c58ab5a859 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Tue, 28 Nov 2023 03:36:30 +0800 Subject: [PATCH 002/259] refactor(dev): adjust directory structure --- .gitignore | 9 ++- .prettierignore | 2 +- entry-ext-extension-page.html | 2 +- .../ext/safari-15}/images/icon-128.png | Bin .../ext/safari-15}/images/icon-256.png | Bin .../ext/safari-15}/images/icon-48.png | Bin .../ext/safari-15}/images/icon-512.png | Bin .../ext/safari-15}/images/icon-96.png | Bin .../ext/safari-15}/images/toolbar-icon-16.png | Bin .../ext/safari-15}/images/toolbar-icon-32.png | Bin public/ext/safari-15/manifest.json | 63 ++++++++++++++++++ public/ext/safari-16.4/images/128.svg | 1 + public/ext/safari-16.4/images/action.svg | 1 + public/ext/safari-16.4/manifest.json | 48 +++++++++++++ public/ext/shared/_locales/en/messages.json | 10 +++ .../vendor}/jshint.min.js | 0 src/ext/content-scripts/entry-dot-user-js.js | 0 src/ext/content-scripts/entry-greasyfork.js | 0 .../{main.js => entry-userscripts.js} | 24 ++++--- src/ext/shared/utils.js | 4 +- vite.config.js | 38 ++++------- .../Resources/_locales/en/messages.json | 10 --- .../Ext-Safari/Resources/ext-v4-manifest.json | 46 ------------- .../Ext-Safari/Resources/ext-v5-manifest.json | 45 ------------- 24 files changed, 159 insertions(+), 144 deletions(-) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/icon-128.png (100%) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/icon-256.png (100%) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/icon-48.png (100%) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/icon-512.png (100%) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/icon-96.png (100%) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/toolbar-icon-16.png (100%) rename {xcode/Ext-Safari/Resources => public/ext/safari-15}/images/toolbar-icon-32.png (100%) create mode 100644 public/ext/safari-15/manifest.json create mode 100644 public/ext/safari-16.4/images/128.svg create mode 100644 public/ext/safari-16.4/images/action.svg create mode 100644 public/ext/safari-16.4/manifest.json create mode 100644 public/ext/shared/_locales/en/messages.json rename public/{extension-page => ext/vendor}/jshint.min.js (100%) create mode 100644 src/ext/content-scripts/entry-dot-user-js.js create mode 100644 src/ext/content-scripts/entry-greasyfork.js rename src/ext/content-scripts/{main.js => entry-userscripts.js} (92%) delete mode 100644 xcode/Ext-Safari/Resources/_locales/en/messages.json delete mode 100644 xcode/Ext-Safari/Resources/ext-v4-manifest.json delete mode 100644 xcode/Ext-Safari/Resources/ext-v5-manifest.json diff --git a/.gitignore b/.gitignore index 0a6c392f..ded7b2f9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ dist dist-ssr build built -*.local # Logs logs @@ -47,5 +46,9 @@ xcuserdata/ *.xcuserstate *.dev.xcconfig XCBuildData -xcode/**/build/ -xcode/**/dist/ \ No newline at end of file +xcode/Ext-Safari/Resources/ + +# directories and files +local +*.local +/scripts/*.local.js \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index be1335e6..5c1a0da1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,7 +6,7 @@ # root /package -/public +/public/**/vendor /etc # dir diff --git a/entry-ext-extension-page.html b/entry-ext-extension-page.html index 16e4944d..b112106c 100644 --- a/entry-ext-extension-page.html +++ b/entry-ext-extension-page.html @@ -10,6 +10,6 @@
- + diff --git a/xcode/Ext-Safari/Resources/images/icon-128.png b/public/ext/safari-15/images/icon-128.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/icon-128.png rename to public/ext/safari-15/images/icon-128.png diff --git a/xcode/Ext-Safari/Resources/images/icon-256.png b/public/ext/safari-15/images/icon-256.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/icon-256.png rename to public/ext/safari-15/images/icon-256.png diff --git a/xcode/Ext-Safari/Resources/images/icon-48.png b/public/ext/safari-15/images/icon-48.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/icon-48.png rename to public/ext/safari-15/images/icon-48.png diff --git a/xcode/Ext-Safari/Resources/images/icon-512.png b/public/ext/safari-15/images/icon-512.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/icon-512.png rename to public/ext/safari-15/images/icon-512.png diff --git a/xcode/Ext-Safari/Resources/images/icon-96.png b/public/ext/safari-15/images/icon-96.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/icon-96.png rename to public/ext/safari-15/images/icon-96.png diff --git a/xcode/Ext-Safari/Resources/images/toolbar-icon-16.png b/public/ext/safari-15/images/toolbar-icon-16.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/toolbar-icon-16.png rename to public/ext/safari-15/images/toolbar-icon-16.png diff --git a/xcode/Ext-Safari/Resources/images/toolbar-icon-32.png b/public/ext/safari-15/images/toolbar-icon-32.png similarity index 100% rename from xcode/Ext-Safari/Resources/images/toolbar-icon-32.png rename to public/ext/safari-15/images/toolbar-icon-32.png diff --git a/public/ext/safari-15/manifest.json b/public/ext/safari-15/manifest.json new file mode 100644 index 00000000..99c503ee --- /dev/null +++ b/public/ext/safari-15/manifest.json @@ -0,0 +1,63 @@ +{ + "manifest_version": 2, + "default_locale": "en", + "name": "__MSG_extension_name__", + "description": "__MSG_extension_description__", + "version": "_VERSION_", + "icons": { + "48": "images/icon-48.png", + "96": "images/icon-96.png", + "128": "images/icon-128.png", + "256": "images/icon-256.png", + "512": "images/icon-512.png" + }, + "background": { + "scripts": ["dist/background.js"], + "persistent": false + }, + "browser_action": { + "default_popup": "dist/entry-ext-action-popup.html", + "default_icon": { + "16": "images/toolbar-icon-16.png", + "32": "images/toolbar-icon-32.png" + } + }, + "content_scripts": [ + { + "js": ["dist/content-scripts/userscripts.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true + }, + { + "js": ["dist/content-scripts/dot-user-js.js"], + "matches": [ + "*://*/*.user.js", + "*://*/*.user.js?*", + "*://*/*.user.css", + "*://*/*.user.css?*" + ], + "run_at": "document_start", + "all_frames": false + }, + { + "js": ["dist/content-scripts/greasyfork.js"], + "matches": ["*://*.greasyfork.org/*"], + "run_at": "document_start", + "all_frames": false + } + ], + "permissions": [ + "", + "clipboardWrite", + "contextMenus", + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess", + "menus", + "nativeMessaging", + "storage", + "tabs", + "unlimitedStorage", + "webNavigation" + ] +} diff --git a/public/ext/safari-16.4/images/128.svg b/public/ext/safari-16.4/images/128.svg new file mode 100644 index 00000000..c6ec62ad --- /dev/null +++ b/public/ext/safari-16.4/images/128.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/ext/safari-16.4/images/action.svg b/public/ext/safari-16.4/images/action.svg new file mode 100644 index 00000000..ad16f09d --- /dev/null +++ b/public/ext/safari-16.4/images/action.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/ext/safari-16.4/manifest.json b/public/ext/safari-16.4/manifest.json new file mode 100644 index 00000000..7d2c00da --- /dev/null +++ b/public/ext/safari-16.4/manifest.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 2, + "default_locale": "en", + "name": "__MSG_extension_name__", + "description": "__MSG_extension_description__", + "version": "_VERSION_", + "icons": { + "48": "images/128.svg", + "96": "images/128.svg", + "128": "images/128.svg", + "256": "images/128.svg", + "512": "images/128.svg" + }, + "background": { + "scripts": ["dist/background.js"], + "persistent": false + }, + "browser_action": { + "default_popup": "dist/entry-ext-action-popup.html", + "default_icon": "images/action.svg" + }, + "content_scripts": [ + { + "js": ["dist/content-scripts/userscripts.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true + } + ], + "permissions": [ + "", + "clipboardWrite", + "contextMenus", + "declarativeNetRequestWithHostAccess", + "menus", + "nativeMessaging", + "scripting", + "storage", + "tabs", + "unlimitedStorage", + "webNavigation" + ], + "browser_specific_settings": { + "safari": { + "strict_min_version": "16.4" + } + } +} diff --git a/public/ext/shared/_locales/en/messages.json b/public/ext/shared/_locales/en/messages.json new file mode 100644 index 00000000..e840a3a2 --- /dev/null +++ b/public/ext/shared/_locales/en/messages.json @@ -0,0 +1,10 @@ +{ + "extension_name": { + "message": "_NAME_", + "description": "The display name for the extension." + }, + "extension_description": { + "message": "Save and run javascript for the web pages you visit", + "description": "Description of what the extension does." + } +} diff --git a/public/extension-page/jshint.min.js b/public/ext/vendor/jshint.min.js similarity index 100% rename from public/extension-page/jshint.min.js rename to public/ext/vendor/jshint.min.js diff --git a/src/ext/content-scripts/entry-dot-user-js.js b/src/ext/content-scripts/entry-dot-user-js.js new file mode 100644 index 00000000..e69de29b diff --git a/src/ext/content-scripts/entry-greasyfork.js b/src/ext/content-scripts/entry-greasyfork.js new file mode 100644 index 00000000..e69de29b diff --git a/src/ext/content-scripts/main.js b/src/ext/content-scripts/entry-userscripts.js similarity index 92% rename from src/ext/content-scripts/main.js rename to src/ext/content-scripts/entry-userscripts.js index ce307fd8..ab620622 100644 --- a/src/ext/content-scripts/main.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -1,4 +1,4 @@ -import apis from "./api.js"; +import USAPI from "./api.js"; // code received from background page will be stored in this variable // code referenced again when strict CSPs block initial injection attempt @@ -52,24 +52,26 @@ function injectJS(userscript) { 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"); + } + const world = injectInto === "content" ? "content" : "page"; + if (window.self === window.top) { + console.info(`Injecting: ${name} %c(js/${world})`, "color: #fff600"); } else { console.info( - `Injecting ${name} %c(js)%c - %cframe(${label})(${window.location})`, + `Injecting: ${name} %c(js/${world})%c - %cframe(${label})(${window.location})`, "color: #fff600", "color: inherit", "color: #006fff", ); } - if (injectInto !== "content") { + if (world === "page") { 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); + (document.body ?? document.head ?? document.documentElement).append(div); } else { try { // eslint-disable-next-line no-new-func @@ -186,8 +188,8 @@ async function injection() { 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; + // ensure API method exists in USAPI object + if (!Object.keys(USAPI).includes(method)) continue; // add granted methods switch (method) { case "info": @@ -197,15 +199,15 @@ async function injection() { case "setValue": case "deleteValue": case "listValues": - userscript.apis.GM[method] = apis[method].bind({ + userscript.apis.GM[method] = USAPI[method].bind({ US_filename: filename, }); break; case "GM_xmlhttpRequest": - userscript.apis[method] = apis[method]; + userscript.apis[method] = USAPI[method]; break; default: - userscript.apis.GM[method] = apis[method]; + userscript.apis.GM[method] = USAPI[method]; } } // triage userjs item for injection diff --git a/src/ext/shared/utils.js b/src/ext/shared/utils.js index c8c1355e..fae3e29e 100644 --- a/src/ext/shared/utils.js +++ b/src/ext/shared/utils.js @@ -186,8 +186,8 @@ export const validMetaKeys = new Set([ ]); export const extensionPaths = { - popup: "/dist/s/entry-ext-action-popup.html", - page: "/dist/s/entry-ext-extension-page.html", + popup: "/dist/entry-ext-action-popup.html", + page: "/dist/entry-ext-extension-page.html", }; export async function openExtensionPage() { diff --git a/vite.config.js b/vite.config.js index 81d75297..3d4378f9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,34 +1,22 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -// [inline-svg] -// TODO: remove this once vite resolved issue -// https://github.com/vitejs/vite/issues/1204 -// import fs from "fs"; -// import svgpkg from "svg-inline-loader"; -// export function svgInline() { // custom plugin -// return { -// name: "transform-file", -// transform(code, id) { -// if (id.endsWith(".svg")) { -// const svg = fs.readFileSync(id, "utf8"); -// const ret = svgpkg.getExtractedSVG(svg, {}); -// return `export default '${ret}'`; -// } -// } -// }; -// } -// [inline-svg] -// NOW: use `?raw` suffix import svg assets as inline -// https://vitejs.dev/guide/assets.html#importing-asset-as-string +/** + * About `inline-svg` + * use `?raw` suffix import svg assets as inline + * @see {@link https://vitejs.dev/guide/assets.html#importing-asset-as-string} + */ -// [autoprefixer] -// https://vitejs.dev/guide/features.html#postcss -// have config with `.postcssrc.json` file -// about `missing peer postcss` error, ignore it +/** + * About `autoprefixer` + * have config with `.postcssrc.json` file + * @see {@link https://vitejs.dev/guide/features.html#postcss} + * about `missing peer postcss` error, ignore it + */ -// https://vitejs.dev/config/ +/** @see {@link https://vitejs.dev/config/} */ export default defineConfig({ + publicDir: "public/ext/vendor/", plugins: [svelte()], base: "./", }); diff --git a/xcode/Ext-Safari/Resources/_locales/en/messages.json b/xcode/Ext-Safari/Resources/_locales/en/messages.json deleted file mode 100644 index 8db4438d..00000000 --- a/xcode/Ext-Safari/Resources/_locales/en/messages.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extension_name": { - "message": "_NAME_", - "description": "The display name for the extension." - }, - "extension_description": { - "message": "Save and run javascript for the web pages you visit", - "description": "Description of what the extension does." - } -} diff --git a/xcode/Ext-Safari/Resources/ext-v4-manifest.json b/xcode/Ext-Safari/Resources/ext-v4-manifest.json deleted file mode 100644 index dde68d2a..00000000 --- a/xcode/Ext-Safari/Resources/ext-v4-manifest.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "manifest_version": 2, - "default_locale": "en", - "name": "__MSG_extension_name__", - "description": "__MSG_extension_description__", - "version": "_VERSION_", - "icons": { - "48": "images/icon-48.png", - "96": "images/icon-96.png", - "128": "images/icon-128.png", - "256": "images/icon-256.png", - "512": "images/icon-512.png" - }, - "background": { - "scripts": ["dist/background.js"], - "persistent": false - }, - "browser_action": { - "default_popup": "dist/s/entry-ext-action-popup.html", - "default_icon": { - "16": "images/toolbar-icon-16.png", - "32": "images/toolbar-icon-32.png" - } - }, - "content_scripts": [ - { - "js": ["dist/content.js"], - "matches": [""], - "run_at": "document_start", - "all_frames": true - } - ], - "permissions": [ - "", - "clipboardWrite", - "contextMenus", - "declarativeNetRequest", - "declarativeNetRequestWithHostAccess", - "menus", - "nativeMessaging", - "storage", - "tabs", - "unlimitedStorage", - "webNavigation" - ] -} diff --git a/xcode/Ext-Safari/Resources/ext-v5-manifest.json b/xcode/Ext-Safari/Resources/ext-v5-manifest.json deleted file mode 100644 index ead6d695..00000000 --- a/xcode/Ext-Safari/Resources/ext-v5-manifest.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "manifest_version": 2, - "default_locale": "en", - "name": "__MSG_extension_name__", - "description": "__MSG_extension_description__", - "version": "_VERSION_", - "icons": { - "48": "images/icon-48.png", - "96": "images/icon-96.png", - "128": "images/icon-128.png", - "256": "images/icon-256.png", - "512": "images/icon-512.png" - }, - "background": { - "scripts": ["dist/background.js"], - "persistent": false - }, - "browser_action": { - "default_popup": "dist/s/entry-ext-action-popup.html", - "default_icon": { - "16": "images/toolbar-icon-16.png", - "32": "images/toolbar-icon-32.png" - } - }, - "content_scripts": [ - { - "js": ["dist/content.js"], - "matches": [""], - "run_at": "document_start", - "all_frames": true - } - ], - "permissions": [ - "", - "clipboardWrite", - "contextMenus", - "declarativeNetRequestWithHostAccess", - "menus", - "nativeMessaging", - "storage", - "tabs", - "unlimitedStorage", - "webNavigation" - ] -} From 6a3faded6327186518b05c07d5c4e16b80ed74fc Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Tue, 28 Nov 2023 05:25:10 +0800 Subject: [PATCH 003/259] refactor(dev): create remote real-time dev build and server With this new way of development build and server, modify and save the extension code in the src to instantly see the reaction in the safari extension environment. The extension pages (popup, background etc.) are automatically refreshed, and the content scripts will instantly updated the next time the page is reload. --- entry-ext-background.html | 9 + package.json | 10 +- public/ext/safari-dev/manifest-ios.json | 52 +++++ public/ext/safari-dev/manifest-mac.json | 52 +++++ public/ext/shared-dev/images/128.svg | 1 + public/ext/shared-dev/images/action.svg | 1 + ...build-ext-v4.js => build-ext-safari-15.js} | 74 ++++-- ...ild-ext-v5.js => build-ext-safari-16.4.js} | 89 ++++--- scripts/dev-ext-safari.js | 220 ++++++++++++++++++ scripts/utils.js | 92 ++++++++ src/ext/action-popup/main.js | 14 +- src/ext/extension-page/main.js | 6 +- src/ext/shared/dev.js | 6 +- 13 files changed, 558 insertions(+), 68 deletions(-) create mode 100644 entry-ext-background.html create mode 100644 public/ext/safari-dev/manifest-ios.json create mode 100644 public/ext/safari-dev/manifest-mac.json create mode 100644 public/ext/shared-dev/images/128.svg create mode 100644 public/ext/shared-dev/images/action.svg rename scripts/{build-ext-v4.js => build-ext-safari-15.js} (55%) rename scripts/{build-ext-v5.js => build-ext-safari-16.4.js} (50%) create mode 100644 scripts/dev-ext-safari.js create mode 100644 scripts/utils.js diff --git a/entry-ext-background.html b/entry-ext-background.html new file mode 100644 index 00000000..e3ee5d4c --- /dev/null +++ b/entry-ext-background.html @@ -0,0 +1,9 @@ + + + + Userscripts background + + + + + diff --git a/package.json b/package.json index c2802776..e478e39d 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,15 @@ "type": "module", "scripts": { "dev": "vite", - "build": "npm run build:app && npm run build:ext", + "dev:ext-safari-mac": "SAFARI_PLATFORM=mac node scripts/dev-ext-safari.js", + "dev:ext-safari-ios": "SAFARI_PLATFORM=ios node scripts/dev-ext-safari.js", + "build:mac": "npm run build:app && npm run build:ext-safari-mac", + "build:ios": "npm run build:app && npm run build:ext-safari-ios", "build:app": "node scripts/build-app.js", - "build:ext": "node scripts/build-ext-v4.js", + "build:ext-safari-mac": "SAFARI_PLATFORM=mac node scripts/build-ext-safari-15.js", + "build:ext-safari-ios": "SAFARI_PLATFORM=ios node scripts/build-ext-safari-15.js", + "build:ext-safari-mac-16.4": "SAFARI_PLATFORM=mac node scripts/build-ext-safari-16.4.js", + "build:ext-safari-ios-16.4": "SAFARI_PLATFORM=ios node scripts/build-ext-safari-16.4.js", "lint:js": "eslint . && prettier --check . --plugin prettier-plugin-svelte", "lint:css": "stylelint '**/*.{css,html,svelte}'", "prettier": "prettier --write . --plugin prettier-plugin-svelte" diff --git a/public/ext/safari-dev/manifest-ios.json b/public/ext/safari-dev/manifest-ios.json new file mode 100644 index 00000000..2cb0308a --- /dev/null +++ b/public/ext/safari-dev/manifest-ios.json @@ -0,0 +1,52 @@ +{ + "manifest_version": 2, + "default_locale": "en", + "name": "__MSG_extension_name__", + "description": "__MSG_extension_description__", + "version": "_VERSION_", + "icons": { + "48": "images/128.svg", + "96": "images/128.svg", + "128": "images/128.svg", + "256": "images/128.svg", + "512": "images/128.svg" + }, + "background": { + "page": "dist/entry-ext-background.html", + "persistent": false + }, + "browser_action": { + "default_popup": "dist/entry-ext-action-popup.html", + "default_icon": "images/action.svg" + }, + "options_ui": { + "page": "dist/entry-ext-extension-page.html#settings" + }, + "content_scripts": [ + { + "js": ["dist/content-scripts/userscripts.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true + } + ], + "permissions": [ + "", + "clipboardWrite", + "contextMenus", + "declarativeNetRequestWithHostAccess", + "menus", + "nativeMessaging", + "scripting", + "storage", + "tabs", + "unlimitedStorage", + "webNavigation" + ], + "browser_specific_settings": { + "safari": { + "strict_min_version": "16.4" + } + }, + "content_security_policy": "script-src 'self' https://localhost:55173 https://userscripts.test:55173" +} diff --git a/public/ext/safari-dev/manifest-mac.json b/public/ext/safari-dev/manifest-mac.json new file mode 100644 index 00000000..046f8239 --- /dev/null +++ b/public/ext/safari-dev/manifest-mac.json @@ -0,0 +1,52 @@ +{ + "manifest_version": 2, + "default_locale": "en", + "name": "__MSG_extension_name__", + "description": "__MSG_extension_description__", + "version": "_VERSION_", + "icons": { + "48": "images/128.svg", + "96": "images/128.svg", + "128": "images/128.svg", + "256": "images/128.svg", + "512": "images/128.svg" + }, + "background": { + "page": "dist/entry-ext-background.html", + "persistent": true + }, + "browser_action": { + "default_popup": "dist/entry-ext-action-popup.html", + "default_icon": "images/action.svg" + }, + "options_ui": { + "page": "dist/entry-ext-extension-page.html#settings" + }, + "content_scripts": [ + { + "js": ["dist/content-scripts/userscripts.js"], + "matches": [""], + "run_at": "document_start", + "all_frames": true + } + ], + "permissions": [ + "", + "clipboardWrite", + "contextMenus", + "declarativeNetRequestWithHostAccess", + "menus", + "nativeMessaging", + "scripting", + "storage", + "tabs", + "unlimitedStorage", + "webNavigation" + ], + "browser_specific_settings": { + "safari": { + "strict_min_version": "16.4" + } + }, + "content_security_policy": "script-src 'self' https://localhost:55173 https://userscripts.test:55173" +} diff --git a/public/ext/shared-dev/images/128.svg b/public/ext/shared-dev/images/128.svg new file mode 100644 index 00000000..76189438 --- /dev/null +++ b/public/ext/shared-dev/images/128.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/ext/shared-dev/images/action.svg b/public/ext/shared-dev/images/action.svg new file mode 100644 index 00000000..3789dd89 --- /dev/null +++ b/public/ext/shared-dev/images/action.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/build-ext-v4.js b/scripts/build-ext-safari-15.js similarity index 55% rename from scripts/build-ext-v4.js rename to scripts/build-ext-safari-15.js index 4a091260..3242f700 100644 --- a/scripts/build-ext-v4.js +++ b/scripts/build-ext-safari-15.js @@ -16,17 +16,20 @@ import { build } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { cp, emptyBuildDir, rootDir, SAFARI_EXT_RESOURCES } from "./utils.js"; /** * Define default vite config options * Disable auto resolving {@link vite.config.js} * @see {@link https://vitejs.dev/config/ Config} - * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig configFile} + * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig InlineConfig} * @type {import("vite").InlineConfig} */ const defineConfig = { - base: "./", configFile: false, + envFile: false, + root: await rootDir(), + base: "./", define: { "import.meta.env.BROWSER": JSON.stringify("safari"), "import.meta.env.NATIVE_APP": JSON.stringify("app"), @@ -34,41 +37,62 @@ const defineConfig = { }; /** - * Build shared modules for safari - * These multiple entry files will share the same modules - * Using a subdirectory avoid emptying other built files + * Empty resources directory + * Copy public static assets */ -build({ - ...defineConfig, - plugins: [svelte()], - build: { - outDir: "xcode/Ext-Safari/Resources/dist/s/", - rollupOptions: { - input: ["entry-ext-action-popup.html", "entry-ext-extension-page.html"], - }, - }, -}); +await emptyBuildDir(SAFARI_EXT_RESOURCES); +cp("public/ext/shared", SAFARI_EXT_RESOURCES); +cp("public/ext/safari-15", SAFARI_EXT_RESOURCES); -/** - * Build independent scripts for safari - * Each entry in the array will generate a separate script - */ +/** Build content scripts */ [ - { background: "src/ext/background/main.js" }, - { content: "src/ext/content-scripts/main.js" }, + { userscripts: "src/ext/content-scripts/entry-userscripts.js" }, + { "dot-user-js": "src/ext/content-scripts/entry-dot-user-js.js" }, + { greasyfork: "src/ext/content-scripts/entry-greasyfork.js" }, ].forEach((input) => { build({ ...defineConfig, build: { - outDir: "xcode/Ext-Safari/Resources/dist/", + outDir: `${SAFARI_EXT_RESOURCES}/dist/content-scripts/`, emptyOutDir: false, copyPublicDir: false, rollupOptions: { input, - output: { - entryFileNames: "[name].js", - }, + output: { entryFileNames: "[name].js" }, }, }, }); }); + +/** Build background scripts */ +build({ + ...defineConfig, + build: { + outDir: `${SAFARI_EXT_RESOURCES}/dist/`, + emptyOutDir: false, + copyPublicDir: false, + rollupOptions: { + input: { background: "src/ext/background/main.js" }, + output: { entryFileNames: "[name].js" }, + }, + }, +}); + +/** Build shared modules */ +/** @type {import("rollup").InputOption} */ +let input = ["entry-ext-action-popup.html", "entry-ext-extension-page.html"]; +if (process.env.SAFARI_PLATFORM === "ios") { + input = ["entry-ext-action-popup.html"]; +} +build({ + ...defineConfig, + plugins: [svelte()], + publicDir: "public/ext/vendor/", + build: { + outDir: `${SAFARI_EXT_RESOURCES}/dist/`, + emptyOutDir: false, + rollupOptions: { + input, + }, + }, +}); diff --git a/scripts/build-ext-v5.js b/scripts/build-ext-safari-16.4.js similarity index 50% rename from scripts/build-ext-v5.js rename to scripts/build-ext-safari-16.4.js index 605317b0..12a67481 100644 --- a/scripts/build-ext-v5.js +++ b/scripts/build-ext-safari-16.4.js @@ -15,6 +15,7 @@ import { build } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { cp, emptyBuildDir, rootDir, SAFARI_EXT_RESOURCES } from "./utils.js"; /** * Define default vite config options @@ -24,8 +25,10 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; * @type {import("vite").InlineConfig} */ const defineConfig = { - base: "./", configFile: false, + envFile: false, + root: await rootDir(), + base: "./", define: { "import.meta.env.BROWSER": JSON.stringify("safari"), "import.meta.env.NATIVE_APP": JSON.stringify("app"), @@ -33,45 +36,71 @@ const defineConfig = { }; /** - * Build shared modules for safari - * These multiple entry files will share the same modules - * Using a subdirectory avoid emptying other built files + * Empty resources directory + * Copy public static assets */ -build({ - ...defineConfig, - plugins: [svelte()], - build: { - outDir: "xcode/Ext-Safari/Resources/dist/s/", - rollupOptions: { - input: { - background: "src/ext/background/main.js", - "action-popup": "entry-ext-action-popup.html", - "extension-page": "entry-ext-extension-page.html", - }, - output: { - entryFileNames: "[name].js", - }, - }, - }, -}); +await emptyBuildDir(SAFARI_EXT_RESOURCES); +cp("public/ext/shared", SAFARI_EXT_RESOURCES); +cp("public/ext/safari-16.4", SAFARI_EXT_RESOURCES); -/** - * Build independent scripts for safari - * Each entry in the array will generate a separate script - */ -[{ content: "src/ext/content-scripts/main.js" }].forEach((input) => { +/** Build content scripts */ +[ + { userscripts: "src/ext/content-scripts/entry-userscripts.js" }, + { "dot-user-js": "src/ext/content-scripts/entry-dot-user-js.js" }, + { greasyfork: "src/ext/content-scripts/entry-greasyfork.js" }, +].forEach((input) => { build({ ...defineConfig, build: { - outDir: "xcode/Ext-Safari/Resources/dist/", + outDir: `${SAFARI_EXT_RESOURCES}/dist/content-scripts/`, emptyOutDir: false, copyPublicDir: false, rollupOptions: { input, - output: { - entryFileNames: "[name].js", - }, + output: { entryFileNames: "[name].js" }, }, }, }); }); + +/** + * Build background scripts + * Modular background may not load correctly on Safari startup + * Currently build classic script separately to avoid this error + */ +build({ + ...defineConfig, + build: { + outDir: `${SAFARI_EXT_RESOURCES}/dist/`, + emptyOutDir: false, + copyPublicDir: false, + rollupOptions: { + input: { background: "src/ext/background/main.js" }, + output: { entryFileNames: "[name].js" }, + }, + }, +}); + +/** Build shared modules */ +/** @type {import("rollup").InputOption} */ +let input = { + // background: "src/ext/background/main.js", + "action-popup": "entry-ext-action-popup.html", + "extension-page": "entry-ext-extension-page.html", +}; +if (process.env.SAFARI_PLATFORM === "ios") { + delete input["extension-page"]; +} +build({ + ...defineConfig, + plugins: [svelte()], + publicDir: "public/ext/vendor/", + build: { + outDir: `${SAFARI_EXT_RESOURCES}/dist/`, + emptyOutDir: false, + rollupOptions: { + input, + output: { entryFileNames: "[name].js" }, + }, + }, +}); diff --git a/scripts/dev-ext-safari.js b/scripts/dev-ext-safari.js new file mode 100644 index 00000000..2b4e7c25 --- /dev/null +++ b/scripts/dev-ext-safari.js @@ -0,0 +1,220 @@ +/** + * @file Develop Safari extension resources using the Vite JavaScript API + * @see {@link https://vitejs.dev/guide/api-javascript.html JavaScript API} + * This development build and server requires a valid https certificate to support remote real-time development + * Typically using a self-signed certificate, it needs to be installed and trusted by the device or simulator + * @see {@link https://developer.apple.com/library/archive/qa/qa1948/} + * @see {@link https://developer.apple.com/library/archive/technotes/tn2326/} + */ + +import { build, createServer } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { + cp, + emptyBuildDir, + sharedServerOptions, + rootDir, + SAFARI_EXT_RESOURCES, +} from "./utils.js"; +import https from "node:https"; + +/** + * Define default vite config options + * Disable auto resolving {@link vite.config.js} + * @see {@link https://vitejs.dev/config/ Config} + * @see {@link https://vitejs.dev/guide/api-javascript.html#inlineconfig configFile} + * @type {import("vite").InlineConfig} + */ +const defineConfig = { + configFile: false, + envFile: false, + root: await rootDir(), + base: "./", + define: { + "import.meta.env.BROWSER": JSON.stringify("safari"), + "import.meta.env.NATIVE_APP": JSON.stringify("app"), + }, +}; + +/** + * Build resources for remote real-time development + * @param {import("vite").ViteDevServer} server + * @param {string} origin + */ +async function buildResources(server, origin) { + /** + * empty resources directory + * copy public static assets + */ + await emptyBuildDir("dist"); + await emptyBuildDir(SAFARI_EXT_RESOURCES); + cp("public/ext/shared", SAFARI_EXT_RESOURCES); + cp("public/ext/shared-dev", SAFARI_EXT_RESOURCES); + if (process.env.SAFARI_PLATFORM === "ios") { + cp( + "public/ext/safari-dev/manifest-ios.json", + `${SAFARI_EXT_RESOURCES}/manifest.json`, + ); + } else { + cp( + "public/ext/safari-dev/manifest-mac.json", + `${SAFARI_EXT_RESOURCES}/manifest.json`, + ); + } + + /** build content scripts */ + [ + { userscripts: "src/ext/content-scripts/entry-userscripts.js" }, + { "dot-user-js": "src/ext/content-scripts/entry-dot-user-js.js" }, + { greasyfork: "src/ext/content-scripts/entry-greasyfork.js" }, + ].forEach((input) => { + /** build proxy content scripts replace actual code */ + build({ + ...defineConfig, + plugins: [ + { + name: "generate-content-proxy", + load(id) { + const name = id.replace(/.+entry-/, ""); + const url = `${origin}/dist/content-scripts/${name}`; + return `// proxy content + const xhr = new XMLHttpRequest(); + xhr.open("GET", "${url}", false); + xhr.send(); + const code = xhr.responseText; + try { + Function(code + "//# sourceURL=proxy-${name}")(); + } catch (error) { + console.error(error); + }`; + }, + }, + ], + build: { + outDir: `${SAFARI_EXT_RESOURCES}/dist/content-scripts/`, + emptyOutDir: false, + copyPublicDir: false, + rollupOptions: { + input, + output: { entryFileNames: "[name].js" }, + }, + }, + }); + /** build content scripts for dev server and watch changes */ + build({ + ...defineConfig, + build: { + outDir: `dist/content-scripts/`, + emptyOutDir: false, + copyPublicDir: false, + rollupOptions: { + input, + output: { entryFileNames: "[name].js" }, + }, + watch: {}, + minify: false, + }, + }); + }); + + /** generate entrance dist */ + build({ + ...defineConfig, + publicDir: "public/ext/vendor/", + plugins: [ + /** + * @see {@link https://github.com/vitejs/vite/issues/14263} + * Dev only requires entrances, so order `pre` without transform modules + */ + { + name: "generate-dev-entrance", + transformIndexHtml: { + order: "pre", + async handler(html, ctx) { + const str = await server.transformIndexHtml(ctx.path, html); + return str.replaceAll(`src="/`, `src="${origin}/`); + }, + }, + }, + ], + build: { + outDir: `${SAFARI_EXT_RESOURCES}/dist/`, + emptyOutDir: false, + rollupOptions: { + input: { + background: "entry-ext-background.html", + "action-popup": "entry-ext-action-popup.html", + "extension-page": "entry-ext-extension-page.html", + }, + }, + }, + }); +} + +/** + * Define shared constants + * Developing in native machine and simulator can just use `https://localhost:port/` as the origin + * But using a fixed domain name on non-native devices such as real iOS devices is a better choice + * The local domain name needs to be resolved to the LAN IP address on LAN DNS or the target device + * When the origin specified here is unreachable, localhost will be automatically used as the alternate + * The origin requires as an exemption to be added to the `manifest.json` due to default CSP limitations + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_Security_Policy} + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_security_policy} + */ +const serverOptions = await sharedServerOptions(); +const SERVER_PORT = 55173; +const SERVER_ORIGIN = `https://userscripts.test:${SERVER_PORT}`; + +/** + * Check if the server is reachable with self-signed certificate + * @param {string | URL} url + * @returns {Promise} + */ +async function serverCheck(url) { + const options = { + method: "HEAD", + ca: serverOptions.ca, + headers: { Accept: "*/*" }, + }; + return new Promise((resolve) => { + const req = https.request(url, options, (res) => { + if (res.headers.server === SERVER_ORIGIN) { + resolve(true); + } + resolve(false); + }); + req.on("error", (e) => { + console.error(`${req.host}: ${e.message}`); + resolve(false); + }); + req.end(); + }); +} + +/** main process */ +(async () => { + /** run development server */ + const server = await createServer({ + ...defineConfig, + plugins: [svelte()], + server: { + host: true, + port: SERVER_PORT, + strictPort: true, + https: { key: serverOptions.key, cert: serverOptions.cert }, + headers: { server: SERVER_ORIGIN }, + }, + }); + await server.listen(); + server.printUrls(); + + // Check available origins and build resources + for (let url of [SERVER_ORIGIN, ...server.resolvedUrls.local]) { + if (await serverCheck(url)) { + const origin = url.at(-1) === "/" ? url.slice(0, -1) : url; + console.info(`build with origin: ${origin}`); + buildResources(server, origin); + break; + } + } +})(); diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 00000000..c80fb51b --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,92 @@ +import { chdir, cwd } from "node:process"; +import { fileURLToPath } from "node:url"; +import { + copyFile, + mkdir, + readdir, + readFile, + realpath, + rm, + stat, +} from "node:fs/promises"; + +/** Define shared constants */ +export const SAFARI_APP_RESOURCES = "xcode/App-Shared/Resources"; +export const SAFARI_EXT_RESOURCES = "xcode/Ext-Safari/Resources"; + +/** + * If not then cd to root dir and returns the path + * @returns {Promise} project root directory + */ +export async function rootDir() { + const root = fileURLToPath(new URL("..", import.meta.url)); + if (cwd() !== (await realpath(root))) { + chdir(root); + console.info("cd:", root); + } + return root; +} + +/** + * Empty the build directory safely + * @param {string} dir + * @returns {Promise} + */ +export async function emptyBuildDir(dir) { + const buildPaths = ["dist", SAFARI_APP_RESOURCES, SAFARI_EXT_RESOURCES]; + if (!buildPaths.includes(dir)) { + console.error("Non-build path, cannot be empty."); + return false; + } + const root = await rootDir(); + try { + for (const sub of await readdir(dir)) { + const path = `${root}/${dir}/${sub}`; + // console.log("rm:", path); // DEBUG + await rm(path, { force: true, recursive: true }); + } + return true; + } catch (error) { + console.error("emptyBuildDir:", error); + return false; + } +} + +/** + * Copy a file or folder recursively + * @param {string} src Source file or directory + * @param {string} dest Destination file or directory + */ +export async function cp(src, dest) { + // console.log("cp:", src, "->", dest); // DEBUG + await rootDir(); + const srcStat = await stat(src); + if (srcStat.isFile()) return copyFile(src, dest); + if (!srcStat.isDirectory()) return; + await mkdir(dest, { recursive: true }); + for (const sub of await readdir(src)) { + if ([".DS_Store"].includes(sub)) continue; + await cp(`${src}/${sub}`, `${dest}/${sub}`); + } +} + +/** + * Read file and return text + * @param {string} file file path + * @returns {Promise} file content + */ +export async function read(file) { + const path = fileURLToPath(new URL(file, import.meta.url)); + return readFile(path, "utf8"); +} + +/** + * @see {@link https://vitejs.dev/config/server-options.html#server-https} + * @see {@link https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener} + * @returns {Promise} https.ServerOptions + */ +export const sharedServerOptions = async () => ({ + ca: await read("./local/ca.pem"), + key: await read("./local/key.pem"), + cert: await read("./local/cert.pem"), +}); diff --git a/src/ext/action-popup/main.js b/src/ext/action-popup/main.js index ecf31c3f..2829a2c0 100644 --- a/src/ext/action-popup/main.js +++ b/src/ext/action-popup/main.js @@ -8,11 +8,12 @@ if (import.meta.env.MODE === "development") { const modules = import.meta.glob("../shared/dev.js", { eager: true }); const browser = modules["../shared/dev.js"]["browser"]; console.debug("DEV-ENV", import.meta.env, modules, browser); - // assign to window simulation WebExtension APIs - window.browser = browser; - // macos popup simulation - const style = document.createElement("style"); - style.textContent = ` + if (!window?.browser?.extension) { + // assign to window simulation WebExtension APIs + window.browser = browser; + // macos popup simulation + const style = document.createElement("style"); + style.textContent = ` body { top: 20px; left: 20px; @@ -32,7 +33,8 @@ body:before { border-radius: 9px; } `; - browser.platform === "macos" && document.head.append(style); + browser.platform === "macos" && document.head.append(style); + } } const app = new App({ diff --git a/src/ext/extension-page/main.js b/src/ext/extension-page/main.js index 9e90a4c7..ec2580e9 100644 --- a/src/ext/extension-page/main.js +++ b/src/ext/extension-page/main.js @@ -8,8 +8,10 @@ if (import.meta.env.MODE === "development") { const modules = import.meta.glob("../shared/dev.js", { eager: true }); const browser = modules["../shared/dev.js"]["browser"]; console.debug("DEV-ENV", import.meta.env, modules, browser); - // assign to window simulation WebExtension APIs - window.browser = browser; + if (!window?.browser?.extension) { + // assign to window simulation WebExtension APIs + window.browser = browser; + } } const app = new App({ diff --git a/src/ext/shared/dev.js b/src/ext/shared/dev.js index cfde2634..6d9a754c 100644 --- a/src/ext/shared/dev.js +++ b/src/ext/shared/dev.js @@ -470,9 +470,9 @@ const _browser = { new Promise((resolve) => { setTimeout(() => resolve(), _browser.delay); }), - onChanged: { - addListener: () => undefined, - }, + }, + onChanged: { + addListener: () => undefined, }, }, }; From 6e80a846706059a3d3b2fc3b88262d219f5b42f0 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Tue, 28 Nov 2023 05:44:44 +0800 Subject: [PATCH 004/259] refactor(dev): add xcode config and organize schemes Create a new configuration to correspond to the new remote real-time development build. --- xcode/App-Shared/ViewController.swift | 1 + xcode/Userscripts.xcodeproj/project.pbxproj | 71 ++++++++++++++--- ...tension.xcscheme => Mac-Vite-Dev.xcscheme} | 76 +++++------------- .../{Userscripts.xcscheme => Mac.xcscheme} | 22 ++---- .../xcschemes/iOS-Vite-Dev.xcscheme | 77 +++++++++++++++++++ .../xcshareddata/xcschemes/iOS.xcscheme | 77 +++++++++++++++++++ xcode/xcconfig/Mac-App.xcconfig | 2 + xcode/xcconfig/Mac-Safari-Extension.xcconfig | 7 +- xcode/xcconfig/Safari-Extension.xcconfig | 5 ++ xcode/xcconfig/Userscripts-Base.xcconfig | 1 + xcode/xcconfig/Userscripts-Release.xcconfig | 2 + xcode/xcconfig/Userscripts-Vite.xcconfig | 24 ++++++ xcode/xcconfig/iOS-App.xcconfig | 1 + xcode/xcconfig/iOS-Safari-Extension.xcconfig | 7 +- 14 files changed, 282 insertions(+), 91 deletions(-) rename xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/{Userscripts Extension.xcscheme => Mac-Vite-Dev.xcscheme} (53%) rename xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/{Userscripts.xcscheme => Mac.xcscheme} (83%) create mode 100644 xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS-Vite-Dev.xcscheme create mode 100644 xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme create mode 100644 xcode/xcconfig/Safari-Extension.xcconfig create mode 100644 xcode/xcconfig/Userscripts-Vite.xcconfig diff --git a/xcode/App-Shared/ViewController.swift b/xcode/App-Shared/ViewController.swift index df9befb9..a4dc6e4c 100644 --- a/xcode/App-Shared/ViewController.swift +++ b/xcode/App-Shared/ViewController.swift @@ -24,6 +24,7 @@ class ViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHan // https://developer.apple.com/documentation/webkit/wkwebview/4111163-inspectable/ self.webView.isInspectable = true } + logger?.debug("\(#function, privacy: .public) - DEBUG mode: isInspectable = true") #endif view = webView self.webView.scrollView.isScrollEnabled = false diff --git a/xcode/Userscripts.xcodeproj/project.pbxproj b/xcode/Userscripts.xcodeproj/project.pbxproj index aca26fad..5b6feb25 100644 --- a/xcode/Userscripts.xcodeproj/project.pbxproj +++ b/xcode/Userscripts.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -106,7 +106,7 @@ 033661A429510B7900CFE179 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/View.storyboard; sourceTree = ""; }; 039F59522AD9591C002E9977 /* dist */ = {isa = PBXFileReference; lastKnownFileType = folder; path = dist; sourceTree = ""; }; 039F59552ADA2FE2002E9977 /* USchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = USchemeHandler.swift; sourceTree = ""; }; - 039F595A2ADFB312002E9977 /* ext-v5-manifest.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "ext-v5-manifest.json"; sourceTree = ""; }; + 039F59662B03AE5A002E9977 /* Userscripts-Vite.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Userscripts-Vite.xcconfig"; sourceTree = ""; }; 03C24F962ABD2CBB00F130F9 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 03E20D05291E170000B4F692 /* dist */ = {isa = PBXFileReference; lastKnownFileType = folder; path = dist; sourceTree = ""; }; 4A143AA9279DE6FF0029BFD0 /* Mac-Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Mac-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -134,7 +134,7 @@ 4A57BA07227235CE008A9763 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; }; 4A57BA0E227235CE008A9763 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4A57BA1D227235E6008A9763 /* Mac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mac.entitlements; sourceTree = ""; }; - 4AD66D2026826D8100B004E8 /* ext-v4-manifest.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "ext-v4-manifest.json"; sourceTree = ""; }; + 4AD66D2026826D8100B004E8 /* manifest.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = manifest.json; sourceTree = ""; }; 4AED6491268CDB58001794BF /* Functions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Functions.swift; sourceTree = ""; }; 78391D4629A4CB3C00979E34 /* Userscripts-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Userscripts-Debug.xcconfig"; sourceTree = ""; }; 78391D4729A4CB3C00979E34 /* Userscripts-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Userscripts-Release.xcconfig"; sourceTree = ""; }; @@ -288,8 +288,7 @@ 4A36A617268266B30018536B /* _locales */, 4A36A616268266B30018536B /* images */, 03E20D05291E170000B4F692 /* dist */, - 4AD66D2026826D8100B004E8 /* ext-v4-manifest.json */, - 039F595A2ADFB312002E9977 /* ext-v5-manifest.json */, + 4AD66D2026826D8100B004E8 /* manifest.json */, ); path = Resources; sourceTree = ""; @@ -297,6 +296,7 @@ 7878ED2E299BBEBF00E36A24 /* xcconfig */ = { isa = PBXGroup; children = ( + 039F59662B03AE5A002E9977 /* Userscripts-Vite.xcconfig */, 78391D4629A4CB3C00979E34 /* Userscripts-Debug.xcconfig */, 78391D4729A4CB3C00979E34 /* Userscripts-Release.xcconfig */, 7878ED38299BBEBF00E36A24 /* Mac-App.xcconfig */, @@ -419,6 +419,7 @@ 4A57B9E4227235CD008A9763 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1320; LastUpgradeCheck = 1420; ORGANIZATIONNAME = "Justin Wasack"; @@ -705,6 +706,48 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 039F59602B03A48A002E9977 /* Vite */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 039F59662B03AE5A002E9977 /* Userscripts-Vite.xcconfig */; + buildSettings = { + }; + name = Vite; + }; + 039F59612B03A48A002E9977 /* Vite */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7878ED38299BBEBF00E36A24 /* Mac-App.xcconfig */; + buildSettings = { + }; + name = Vite; + }; + 039F59622B03A48A002E9977 /* Vite */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7878ED32299BBEBF00E36A24 /* Mac-Safari-Extension.xcconfig */; + buildSettings = { + }; + name = Vite; + }; + 039F59632B03A48A002E9977 /* Vite */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7878ED34299BBEBF00E36A24 /* iOS-App.xcconfig */; + buildSettings = { + }; + name = Vite; + }; + 039F59642B03A48A002E9977 /* Vite */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7878ED2F299BBEBF00E36A24 /* iOS-Safari-Extension.xcconfig */; + buildSettings = { + }; + name = Vite; + }; + 039F59652B03A48A002E9977 /* Vite */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7878ED31299BBEBF00E36A24 /* Mac-Tests.xcconfig */; + buildSettings = { + }; + name = Vite; + }; 4A143AAF279DE6FF0029BFD0 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7878ED31299BBEBF00E36A24 /* Mac-Tests.xcconfig */; @@ -795,56 +838,62 @@ 4A143AB1279DE6FF0029BFD0 /* Build configuration list for PBXNativeTarget "Mac-Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( + 039F59652B03A48A002E9977 /* Vite */, 4A143AAF279DE6FF0029BFD0 /* Debug */, 4A143AB0279DE6FF0029BFD0 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 4A4CF716270A38BF00111584 /* Build configuration list for PBXNativeTarget "iOS-Safari-Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( + 039F59642B03A48A002E9977 /* Vite */, 4A4CF714270A38BF00111584 /* Debug */, 4A4CF715270A38BF00111584 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 4A4CF717270A38BF00111584 /* Build configuration list for PBXNativeTarget "iOS-App" */ = { isa = XCConfigurationList; buildConfigurations = ( + 039F59632B03A48A002E9977 /* Vite */, 4A4CF711270A38BF00111584 /* Debug */, 4A4CF712270A38BF00111584 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 4A57B9E7227235CD008A9763 /* Build configuration list for PBXProject "Userscripts" */ = { isa = XCConfigurationList; buildConfigurations = ( + 039F59602B03A48A002E9977 /* Vite */, 4A57BA14227235CE008A9763 /* Debug */, 4A57BA15227235CE008A9763 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 4A57BA16227235CE008A9763 /* Build configuration list for PBXNativeTarget "Mac-Safari-Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( + 039F59622B03A48A002E9977 /* Vite */, 4A57BA17227235CE008A9763 /* Debug */, 4A57BA18227235CE008A9763 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 4A57BA1A227235CE008A9763 /* Build configuration list for PBXNativeTarget "Mac-App" */ = { isa = XCConfigurationList; buildConfigurations = ( + 039F59612B03A48A002E9977 /* Vite */, 4A57BA1B227235CE008A9763 /* Debug */, 4A57BA1C227235CE008A9763 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ }; diff --git a/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Userscripts Extension.xcscheme b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Mac-Vite-Dev.xcscheme similarity index 53% rename from xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Userscripts Extension.xcscheme rename to xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Mac-Vite-Dev.xcscheme index bf2b16e1..0a3c056b 100644 --- a/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Userscripts Extension.xcscheme +++ b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Mac-Vite-Dev.xcscheme @@ -1,26 +1,11 @@ + LastUpgradeVersion = "1500" + version = "1.7"> - - - - @@ -38,19 +23,11 @@ - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> @@ -65,63 +42,48 @@ - - - + allowLocationSimulation = "YES"> + - - - - - + + debugDocumentVersioning = "YES"> + buildConfiguration = "Vite"> diff --git a/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Userscripts.xcscheme b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Mac.xcscheme similarity index 83% rename from xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Userscripts.xcscheme rename to xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Mac.xcscheme index e3d1c38e..d78f492e 100644 --- a/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Userscripts.xcscheme +++ b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/Mac.xcscheme @@ -1,7 +1,7 @@ + LastUpgradeVersion = "1500" + version = "1.7"> @@ -15,7 +15,7 @@ @@ -26,16 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> @@ -64,7 +56,7 @@ @@ -81,7 +73,7 @@ diff --git a/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS-Vite-Dev.xcscheme b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS-Vite-Dev.xcscheme new file mode 100644 index 00000000..c87e739c --- /dev/null +++ b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS-Vite-Dev.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme new file mode 100644 index 00000000..9c0ceed5 --- /dev/null +++ b/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/iOS.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xcode/xcconfig/Mac-App.xcconfig b/xcode/xcconfig/Mac-App.xcconfig index d477275f..7734d6a3 100644 --- a/xcode/xcconfig/Mac-App.xcconfig +++ b/xcode/xcconfig/Mac-App.xcconfig @@ -9,11 +9,13 @@ GENERATE_INFOPLIST_FILE = YES INFOPLIST_FILE = $(SRC_DIR)/Info.plist INFOPLIST_KEY_CFBundleDisplayName = $(PROJECT_NAME)-$(TARGET_NAME) INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = $(PROJECT_NAME)-$(TARGET_NAME)-Debug +INFOPLIST_KEY_CFBundleDisplayName[config=Vite] = $(PROJECT_NAME)-$(TARGET_NAME)-Vite LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks MARKETING_VERSION = $(MAC_APP_VERSION) PRODUCT_BUNDLE_IDENTIFIER = $(APP_IDENTIFIER) PRODUCT_NAME = $(PROJECT_NAME) PRODUCT_NAME[config=Debug] = $(PROJECT_NAME)-Debug +PRODUCT_NAME[config=Vite] = $(PROJECT_NAME)-Vite // Override this file #include? "Mac-App.dev.xcconfig" diff --git a/xcode/xcconfig/Mac-Safari-Extension.xcconfig b/xcode/xcconfig/Mac-Safari-Extension.xcconfig index 6e392d8a..74251cf9 100644 --- a/xcode/xcconfig/Mac-Safari-Extension.xcconfig +++ b/xcode/xcconfig/Mac-Safari-Extension.xcconfig @@ -1,4 +1,6 @@ -SRC_DIR = Ext-Safari +// Shared config file +#include? "Safari-Extension.xcconfig" + CODE_SIGN_ENTITLEMENTS = $(SRC_DIR)/Mac.entitlements CURRENT_PROJECT_VERSION = $(MAC_APP_BUILD) ENABLE_HARDENED_RUNTIME = YES @@ -11,9 +13,6 @@ 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" diff --git a/xcode/xcconfig/Safari-Extension.xcconfig b/xcode/xcconfig/Safari-Extension.xcconfig new file mode 100644 index 00000000..d2724321 --- /dev/null +++ b/xcode/xcconfig/Safari-Extension.xcconfig @@ -0,0 +1,5 @@ +SRC_DIR = Ext-Safari +MANIFEST_FILE = $(SRC_DIR)/Resources/manifest.json +MANIFEST_NAME = $(PROJECT_NAME) +MANIFEST_NAME[config=Debug] = $(PROJECT_NAME)-Debug +MANIFEST_NAME[config=Vite] = $(PROJECT_NAME)-Vite \ No newline at end of file diff --git a/xcode/xcconfig/Userscripts-Base.xcconfig b/xcode/xcconfig/Userscripts-Base.xcconfig index 4113b7fa..6ad773d0 100644 --- a/xcode/xcconfig/Userscripts-Base.xcconfig +++ b/xcode/xcconfig/Userscripts-Base.xcconfig @@ -55,6 +55,7 @@ MTL_FAST_MATH = YES ONLY_ACTIVE_ARCH[config=Debug] = YES SDKROOT = macosx SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = DEBUG +SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Vite] = DEBUG SWIFT_COMPILATION_MODE[config=Release] = wholemodule SWIFT_OPTIMIZATION_LEVEL[config=Debug] = -Onone SWIFT_OPTIMIZATION_LEVEL[config=Release] = -O diff --git a/xcode/xcconfig/Userscripts-Release.xcconfig b/xcode/xcconfig/Userscripts-Release.xcconfig index 5bfbafca..94cb3265 100644 --- a/xcode/xcconfig/Userscripts-Release.xcconfig +++ b/xcode/xcconfig/Userscripts-Release.xcconfig @@ -6,6 +6,8 @@ IOS_APP_VERSION = 1.5.0 MAC_APP_BUILD = 83 MAC_APP_VERSION = 4.5.0 +// Development +CODE_SIGN_IDENTITY = Apple Development // Developer Team ID DEVELOPMENT_TEAM = J74Q8V8V8N // Organization Identifier diff --git a/xcode/xcconfig/Userscripts-Vite.xcconfig b/xcode/xcconfig/Userscripts-Vite.xcconfig new file mode 100644 index 00000000..1635bab4 --- /dev/null +++ b/xcode/xcconfig/Userscripts-Vite.xcconfig @@ -0,0 +1,24 @@ +#include "Userscripts-Base.xcconfig" + +INFOPLIST_KEY_NSHumanReadableCopyright = Copyright © Userscripts +IOS_APP_BUILD = 2 +IOS_APP_VERSION = 2.0.0 +MAC_APP_BUILD = 5 +MAC_APP_VERSION = 5.0.0 + +// Sign to Run Locally +CODE_SIGN_IDENTITY = - +// Organization Identifier +ORG_IDENTIFIER = dev.vite +// Application Identifier +APP_IDENTIFIER = $(ORG_IDENTIFIER).userscripts +// Safari Extension Identifier +EXT_IDENTIFIER = $(APP_IDENTIFIER).ext.safari +// App Groups and Group Containers +IOS_SHARED_GID = group.$(ORG_IDENTIFIER).userscripts +MAC_SHARED_GID = 00000.$(ORG_IDENTIFIER).userscripts +// App URL Scheme +APP_URL_SCHEME = userscripts-vite + +// Override this file +#include? "Userscripts-Vite.dev.xcconfig" diff --git a/xcode/xcconfig/iOS-App.xcconfig b/xcode/xcconfig/iOS-App.xcconfig index 005ffcaf..b1e738bb 100644 --- a/xcode/xcconfig/iOS-App.xcconfig +++ b/xcode/xcconfig/iOS-App.xcconfig @@ -8,6 +8,7 @@ GENERATE_INFOPLIST_FILE = YES INFOPLIST_FILE = $(SRC_DIR)/Info.plist INFOPLIST_KEY_CFBundleDisplayName = $(PROJECT_NAME) INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = $(PROJECT_NAME)-D +INFOPLIST_KEY_CFBundleDisplayName[config=Vite] = $(PROJECT_NAME)-V INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen INFOPLIST_KEY_UIMainStoryboardFile = Main diff --git a/xcode/xcconfig/iOS-Safari-Extension.xcconfig b/xcode/xcconfig/iOS-Safari-Extension.xcconfig index 7df9eae4..f45ffdb6 100644 --- a/xcode/xcconfig/iOS-Safari-Extension.xcconfig +++ b/xcode/xcconfig/iOS-Safari-Extension.xcconfig @@ -1,4 +1,6 @@ -SRC_DIR = Ext-Safari +// Shared config file +#include? "Safari-Extension.xcconfig" + CODE_SIGN_ENTITLEMENTS = $(SRC_DIR)/iOS.entitlements CURRENT_PROJECT_VERSION = $(IOS_APP_BUILD) GENERATE_INFOPLIST_FILE = YES @@ -15,9 +17,6 @@ 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" From c963dc17824658b27cfe65bf16560e916e59c225 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:40:07 +0800 Subject: [PATCH 005/259] refactor(docs): update docs and tasks --- .vscode/tasks.json | 69 ++++++++++++++++++++++++++++++++++++++++------ docs/dev.md | 11 ++++---- eslint.config.js | 26 ++++++++++------- 3 files changed, 83 insertions(+), 23 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9d6aec0f..c967bcab 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -23,33 +23,86 @@ } }, { - "label": "npm: build", + "label": "npm: dev ext-safari-mac", + "detail": "Run dev server and build dev resource to xcode dist", + "type": "shell", + "command": "npm run dev:ext-safari-mac", + "group": "build", + "options": { + "cwd": "./" + } + }, + { + "label": "npm: dev ext-safari-ios", + "detail": "Run dev server and build dev resource to xcode dist", + "type": "shell", + "command": "npm run dev:ext-safari-ios", + "group": "build", + "options": { + "cwd": "./" + } + }, + { + "label": "npm: build mac", "detail": "Build to xcode dist path", "type": "shell", - "command": "npm run build", + "command": "npm run build:mac", "group": "build", "options": { "cwd": "./" } }, { - "label": "xcode: build", - "detail": "xcodebuild", + "label": "npm: build ios", + "detail": "Build to xcode dist path", "type": "shell", - "command": "xcodebuild -scheme Userscripts build", + "command": "npm run build:ios", + "group": "build", + "options": { + "cwd": "./" + } + }, + { + "label": "xcodebuild: mac-debug", + "detail": "xcodebuild mac debug", + "type": "shell", + "command": "xcodebuild -scheme Mac -configuration Debug", + "group": "build", + "options": { + "cwd": "./xcode/" + } + }, + { + "label": "xcodebuild: mac-vite", + "detail": "xcodebuild mac vite", + "type": "shell", + "command": "xcodebuild -scheme Mac-Vite-Dev -configuration Vite", "group": "build", "options": { "cwd": "./xcode/" } }, { - "label": "build all", - "detail": "npm build + xcodebuild", + "label": "dev: ext-safari-mac", + "detail": "npm: dev ext-safari-mac + xcodebuild: mac-vite", + "presentation": { + "reveal": "silent" + }, + "dependsOrder": "sequence", + "dependsOn": ["npm: dev ext-safari-mac", "xcodebuild: mac-vite"], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "build: mac debug", + "detail": "npm: build mac + xcodebuild: mac-debug", "presentation": { "reveal": "silent" }, "dependsOrder": "sequence", - "dependsOn": ["npm: build", "xcode: build"], + "dependsOn": ["npm: build mac", "xcodebuild: mac-debug"], "group": { "kind": "build", "isDefault": true diff --git a/docs/dev.md b/docs/dev.md index d1e7a7da..38ba3a6e 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -25,17 +25,18 @@ Reviewing the template will help you understand the composition of the project. # Dev -- `npm install`[^1] -- `npm run dev`[^1] +- `npm install` [^1] +- `npm run dev` [^1] # Build -- `npm run build`[^1] +- `npm run build:mac` [^1][^2] - `cd ./xcode` -- `xcodebuild`[^1][^2] or build with `Xcode` App +- `xcodebuild -scheme Mac` [^1][^2][^3] or build with `Xcode` App [^1]: These commands can also be executed directly through the vscode tasks. 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) +[^2]: Select the corresponding target and platform to build. Please refer to: [/package.json](../package.json) and [xcode-schemes](/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/) +[^3]: 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 diff --git a/eslint.config.js b/eslint.config.js index 5b5fa18f..8bf9c246 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,13 +14,17 @@ import prettier from "eslint-config-prettier"; import globals from "globals"; export default [ - // https://eslint.org/docs/latest/use/configure/configuration-files-new#using-predefined-configurations - // https://github.com/eslint/eslint/tree/main/packages/js + /** + * @see {@link https://eslint.org/docs/latest/use/configure/configuration-files-new#using-predefined-configurations} + * @see {@link https://github.com/eslint/eslint/tree/main/packages/js} + */ js.configs.recommended, - // https://github.com/sveltejs/eslint-plugin-svelte - // currently no official svelte/recommended flat config - // flatten and call it through a custom config object + /** + * @see {@link https://github.com/sveltejs/eslint-plugin-svelte} + * currently no official svelte/recommended flat config + * flatten and call it through a custom config object + */ { files: ["**/*.svelte"], languageOptions: { @@ -36,18 +40,20 @@ export default [ }, }, - // https://github.com/prettier/eslint-config-prettier - // turns off the conflict rules, put it last + /** + * @see {@link https://github.com/prettier/eslint-config-prettier} + * turns off the conflict rules, put it last + */ prettier, - // custom config objects + /** custom config objects */ - // https://eslint.org/docs/latest/use/configure/configuration-files-new#globally-ignoring-files-with-ignores + /** @see {@link https://eslint.org/docs/latest/use/configure/configuration-files-new#globally-ignoring-files-with-ignores} */ { ignores: ["**/dist/", "**/build/", "etc/", "xcode/", "public/"], }, - // https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options + /** @see {@link https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options} */ { files: ["scripts/**/*.js"], languageOptions: { From 97da992d0c6fdff79f343efd5ca2e968a2f0ff8f Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Wed, 29 Nov 2023 09:49:53 +0800 Subject: [PATCH 006/259] chore: add `sideEffects` key to help tree-shaking --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e478e39d..36940a00 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "userscripts", "private": true, "type": "module", + "sideEffects": false, "scripts": { "dev": "vite", "dev:ext-safari-mac": "SAFARI_PLATFORM=mac node scripts/dev-ext-safari.js", From debe1a29b69ca3d7adc6afabd0ae83eb4df0ca93 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Wed, 29 Nov 2023 14:17:05 +0800 Subject: [PATCH 007/259] chore: improve and correct document content --- docs/dev.md | 2 +- vite.config.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/dev.md b/docs/dev.md index 38ba3a6e..85633998 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -35,7 +35,7 @@ Reviewing the template will help you understand the composition of the project. - `xcodebuild -scheme Mac` [^1][^2][^3] or build with `Xcode` App [^1]: These commands can also be executed directly through the vscode tasks. Please refer to: [/.vscode/tasks.json](../.vscode/tasks.json) -[^2]: Select the corresponding target and platform to build. Please refer to: [/package.json](../package.json) and [xcode-schemes](/xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/) +[^2]: Select the corresponding target and platform to build. Please refer to: [/package.json](../package.json) and [xcode-schemes](../xcode/Userscripts.xcodeproj/xcshareddata/xcschemes/) [^3]: 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 diff --git a/vite.config.js b/vite.config.js index 3d4378f9..f7cf2b45 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,9 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; * About `inline-svg` * use `?raw` suffix import svg assets as inline * @see {@link https://vitejs.dev/guide/assets.html#importing-asset-as-string} + * Ignore lint errors `{@html}`, since these are deterministic local svg resources, + * there is no risk of Cross-Site Scripting (XSS) attacks and can be safely disabled. + * @see {@link https://sveltejs.github.io/eslint-plugin-svelte/rules/no-at-html-tags/} */ /** From 1b8213328c23952649b5e43afdaba14be839b38f Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Thu, 30 Nov 2023 22:23:27 +0800 Subject: [PATCH 008/259] fix: correctly handle url containing hash but without search --- xcode/Ext-Safari/Functions.swift | 74 ++------ xcode/Shared/UrlPolyfill.swift | 96 +++++++++++ xcode/Tests-Mac/UrlCodecTests.swift | 182 ++++++++++++++++++++ xcode/Tests-Mac/UserscriptsTests.swift | 22 ++- xcode/Userscripts.xcodeproj/project.pbxproj | 12 ++ 5 files changed, 311 insertions(+), 75 deletions(-) create mode 100644 xcode/Shared/UrlPolyfill.swift create mode 100644 xcode/Tests-Mac/UrlCodecTests.swift diff --git a/xcode/Ext-Safari/Functions.swift b/xcode/Ext-Safari/Functions.swift index ae4a931e..738fe244 100644 --- a/xcode/Ext-Safari/Functions.swift +++ b/xcode/Ext-Safari/Functions.swift @@ -77,29 +77,12 @@ func openSaveLocation() -> Bool { } func validateUrl(_ urlString: String) -> Bool { - var urlChecked = urlString - // if the url is already encoded, decode it - if isEncoded(urlChecked) { - if let decodedUrl = urlChecked.removingPercentEncoding { - urlChecked = decodedUrl - } else { - logger?.error("\(#function, privacy: .public) - failed at (1), couldn't decode url, \(urlString, privacy: .public)") - return false - } - } - // encode all urls strings - if let encodedUrl = urlChecked.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - urlChecked = encodedUrl - } else { - logger?.error("\(#function, privacy: .public) - failed at (2), couldn't percent encode url, \(urlString, privacy: .public)") - return false - } guard - let parts = getUrlProps(urlChecked), + let parts = jsLikeURL(urlString), let ptcl = parts["protocol"], let path = parts["pathname"] else { - logger?.error("\(#function, privacy: .public) - failed at (3) for \(urlString, privacy: .public)") + logger?.error("\(#function, privacy: .public) - Invalid URL: \(urlString, privacy: .public)") return false } if @@ -129,7 +112,10 @@ func isVersionNewer(_ oldVersion: String, _ newVersion: String) -> Bool { } func isEncoded(_ str: String) -> Bool { - return str.removingPercentEncoding != str + if let decoded = str.removingPercentEncoding { + return decoded != str + } + return false } // parser @@ -888,25 +874,9 @@ func getRemoteFileContents(_ url: String) -> String? { urlChecked = urlChecked.replacingOccurrences(of: "http:", with: "https:") logger?.info("\(#function, privacy: .public) - \(url, privacy: .public) is using insecure http, attempt to fetch remote content with https") } - // if the url is already encoded, decode it - if isEncoded(urlChecked) { - if let decodedUrl = urlChecked.removingPercentEncoding { - urlChecked = decodedUrl - } else { - logger?.error("\(#function, privacy: .public) - failed at (1), couldn't decode url, \(url, privacy: .public)") - return nil - } - } - // encode all urls strings - if let encodedUrl = urlChecked.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - urlChecked = encodedUrl - } else { - logger?.error("\(#function, privacy: .public) - failed at (2), couldn't percent encode url, \(url, privacy: .public)") - return nil - } // convert url string to url - guard let solidURL = URL(string: urlChecked) else { - logger?.error("\(#function, privacy: .public) - failed at (3), couldn't convert string to url, \(url, privacy: .public)") + guard let solidURL = fixedURL(string: urlChecked) else { + logger?.error("\(#function, privacy: .public) - failed at (1), invalid URL: \(url, privacy: .public)") return nil } var contents = "" @@ -929,7 +899,7 @@ func getRemoteFileContents(_ url: String) -> String? { // if made it to this point and contents still an empty string, something went wrong with the request if contents.isEmpty { - logger?.error("\(#function, privacy: .public) - failed at (4), contents empty, \(url, privacy: .public)") + logger?.error("\(#function, privacy: .public) - failed at (2), contents empty: \(url, privacy: .public)") return nil } logger?.info("\(#function, privacy: .public) - completed for \(url, privacy: .public)") @@ -1032,28 +1002,6 @@ func checkDefaultDirectories() -> Bool { } // matching -func getUrlProps(_ url: String) -> [String: String]? { - guard - let parts = URLComponents(string: url), - let ptcl = parts.scheme, - let host = parts.host - else { - logger?.error("\(#function, privacy: .public) - failed to parse url") - return nil - } - var search = "" - if let query = parts.query { - search = "?" + query - } - return [ - "protocol": "\(ptcl):", - "host": host, - "pathname": parts.path, - "search": search, - "href": url - ] -} - func stringToRegex(_ stringPattern: String) -> NSRegularExpression? { let pattern = #"[\.|\?|\^|\$|\+|\{|\}|\[|\]|\||\\(|\)|\/]"# var patternReplace = "^\(stringPattern.replacingOccurrences(of: pattern, with: #"\\$0"#, options: .regularExpression))$" @@ -1066,9 +1014,9 @@ func stringToRegex(_ stringPattern: String) -> NSRegularExpression? { func match(_ url: String, _ matchPattern: String) -> Bool { guard - let parts = getUrlProps(url), + let parts = jsLikeURL(url), let ptcl = parts["protocol"], - let host = parts["host"], + let host = parts["hostname"], var path = parts["pathname"] else { logger?.error("\(#function, privacy: .public) - invalid url \(url, privacy: .public)") diff --git a/xcode/Shared/UrlPolyfill.swift b/xcode/Shared/UrlPolyfill.swift new file mode 100644 index 00000000..f0110d67 --- /dev/null +++ b/xcode/Shared/UrlPolyfill.swift @@ -0,0 +1,96 @@ +import Foundation + +extension CharacterSet { + // https://developer.apple.com/documentation/foundation/characterset#2902136 + public static let urlAllowed_ = CharacterSet(charactersIn: "#") + .union(.urlFragmentAllowed) + .union(.urlHostAllowed) + .union(.urlPasswordAllowed) + .union(.urlPathAllowed) + .union(.urlQueryAllowed) + .union(.urlUserAllowed) +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI +func encodeURI(_ uri: String) -> String { + // https://developer.apple.com/documentation/foundation/characterset#2902136 +// var urlAllowed = CharacterSet(charactersIn: "#") +// urlAllowed.formUnion(.urlFragmentAllowed) +// urlAllowed.formUnion(.urlHostAllowed) +// urlAllowed.formUnion(.urlPasswordAllowed) +// urlAllowed.formUnion(.urlPathAllowed) +// urlAllowed.formUnion(.urlQueryAllowed) +// urlAllowed.formUnion(.urlUserAllowed) + return uri.addingPercentEncoding(withAllowedCharacters: .urlAllowed_) ?? uri +} + +func fixedURL(string urlString: String) -> URL? { + let rawUrlString = urlString.removingPercentEncoding ?? urlString + var url: URL? + if #available(macOS 14.0, iOS 17.0, *) { + url = URL(string: rawUrlString, encodingInvalidCharacters: true) + } else { + url = URL(string: encodeURI(rawUrlString)) + } + return url +} + +// https://developer.mozilla.org/en-US/docs/Web/API/URL +func jsLikeURL(_ urlString: String, baseString: String? = nil) -> [String: String]? { + var _url: URL? + if let baseString = baseString { + guard let baseUrl = fixedURL(string: baseString) else { + return nil + } + _url = URL(string: urlString, relativeTo: baseUrl) + } else { + _url = fixedURL(string: urlString) + } + guard let url = _url else { + return nil + } + + guard let scheme = url.scheme else { + return nil + } + var port = (url.port == nil) ? "" : String(url.port!) + if (scheme == "http" && port == "80") { port = "" } + if (scheme == "https" && port == "443") { port = "" } + if #available(macOS 13.0, iOS 16.0, *) { + let hostname = url.host(percentEncoded: true) ?? "" + let host = (port == "") ? hostname : "\(hostname):\(port)" + let query = url.query(percentEncoded: true) ?? "" + let fragment = url.fragment(percentEncoded: true) ?? "" + return [ + "hash": fragment == "" ? "" : "#\(fragment)", + "host": host, + "hostname": hostname, +// "href": url.absoluteString, + "origin": "\(scheme)://\(host)", + "password": url.password(percentEncoded: true) ?? "", + "pathname": url.path(percentEncoded: true), + "port": port, + "protocol": "\(scheme):", + "search": query == "" ? "" : "?\(query)", + "username": url.user(percentEncoded: true) ?? "" + ] + } else { + let hostname = url.host ?? "" + let host = (port == "") ? hostname : "\(hostname):\(port)" + let query = url.query ?? "" + let fragment = url.fragment ?? "" + return [ + "hash": fragment == "" ? "" : "#\(fragment)", + "host": host, + "hostname": hostname, +// "href": url.absoluteString, + "origin": "\(scheme)://\(host)", + "password": url.password ?? "", + "pathname": url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? url.path, + "port": port, + "protocol": "\(scheme):", + "search": query == "" ? "" : "?\(query)", + "username": url.user?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? "" + ] + } +} diff --git a/xcode/Tests-Mac/UrlCodecTests.swift b/xcode/Tests-Mac/UrlCodecTests.swift new file mode 100644 index 00000000..52a08bb3 --- /dev/null +++ b/xcode/Tests-Mac/UrlCodecTests.swift @@ -0,0 +1,182 @@ +import XCTest + +final class UrlCodecTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + + func testEncodeURI() throws { + + func check(_ urlString: String, _ res: String) -> Bool { + let url = encodeURI(urlString) + if (url != res) { + print(urlString, url) + return false + } + return true + } + + XCTAssert(check("http://user:password@host:80/path?q=1#id", + "http://user:password@host:80/path?q=1#id")) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中文/?a=中文#中文", + "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD%E6%96%87#%E4%B8%AD%E6%96%87")) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", + "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%25E6%2596%2587/?a=%E4%B8%AD&b=%25E6%2596%2587&c#%E4%B8%AD%E6%96%87")) + + } + + func testJsLikeURL() throws { + + func diffPrint(_ dA: Dictionary?, _ dB: Dictionary) { + guard let dA = dA else { + return print("dA is nil") + } + for k in dA.keys { + if dA[k] != dB[k] { + print(k, dA[k] ?? "nil", dB[k] ?? "nil") + } + } + } + + func check(_ urlString: String, _ res: Dictionary) -> Bool { + let url = jsLikeURL(urlString) + if (url != res) { + print(urlString) + diffPrint(url, res) + return false + } + return true + } + + /** javascript get res + url = new URL("http://user:password@host:80/path?q=1#id") + res = {} + for(let k of ["hash","host","hostname","href","origin","password","pathname","port","protocol","search","username"]){ + res[k] = url[k]; + } + JSON.stringify(res) + */ + + XCTAssert(check("http://user:password@host:80/path?q=1#id", [ + "hash": "#id", + "host": "host", + "hostname": "host", +// "href": "http://user:password@host/path?q=1#id", + "origin": "http://host", + "password": "password", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "?q=1", + "username": "user" + ])) + + XCTAssert(check("http://host.test:8080/path?#id", [ + "hash": "#id", + "host": "host.test:8080", + "hostname": "host.test", +// "href": "http://host.test:8080/path?#id", + "origin": "http://host.test:8080", + "password": "", + "pathname": "/path", + "port": "8080", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test:8080/path?", [ + "hash": "", + "host": "host.test:8080", + "hostname": "host.test", +// "href": "http://host.test:8080/path?", + "origin": "http://host.test:8080", + "password": "", + "pathname": "/path", + "port": "8080", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/path#id", [ + "hash": "#id", + "host": "host.test", + "hostname": "host.test", +// "href": "http://host.test/path#id", + "origin": "http://host.test", + "password": "", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/path", [ + "hash": "", + "host": "host.test", + "hostname": "host.test", +// "href": "http://host.test/path", + "origin": "http://host.test", + "password": "", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/", [ + "hash": "", + "host": "host.test", + "hostname": "host.test", +// "href": "http://host.test/", + "origin": "http://host.test", + "password": "", + "pathname": "/", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + "hash": "#%E4%B8%AD%E6%96%87", + "host": "xn--fiq.xn--7dv:80", + "hostname": "xn--fiq.xn--7dv", +// "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://xn--fiq.xn--7dv:80", + "password": "%E5%AF%86%E7%A0%81", + "pathname": "/path/%E4%B8%AD%E6%96%87/", + "port": "80", + "protocol": "https:", + "search": "?a=%E4%B8%AD&b=%E6%96%87&c", + "username": "%E7%94%A8%E6%88%B7%E5%90%8D" + ])) + + } + +} diff --git a/xcode/Tests-Mac/UserscriptsTests.swift b/xcode/Tests-Mac/UserscriptsTests.swift index 12c91bd0..c8911bb1 100644 --- a/xcode/Tests-Mac/UserscriptsTests.swift +++ b/xcode/Tests-Mac/UserscriptsTests.swift @@ -60,6 +60,7 @@ class UserscriptsTests: XCTestCase { "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", "https://cdn.frankerfacez.com/static/ffz_injector.user.js", "http://www.k21p.com/example.user.js", // add http protocol + "https://example.test/%%%test.user.js", // removingPercentEncoding -> nil "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js" ] var result = [String]() @@ -76,17 +77,17 @@ class UserscriptsTests: XCTestCase { let urls = [ "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", + "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", "https://cdn.frankerfacez.com/static/ffz_injector.user.js", "http://www.k21p.com/example.user.js" // add http protocol ] - var result = [String]() for url in urls { - if let contents = getRemoteFileContents(url) { - result.append(contents) + if getRemoteFileContents(url) == nil { + print(#function, url) + XCTAssert(false) } } - XCTAssert(result.count == urls.count) } func testFileRemoteUpdate() throws { @@ -118,8 +119,6 @@ class UserscriptsTests: XCTestCase { } func testMatching() throws { - var count = 0 - var result = [String]() let patternDict = [ "*://*/*": [ "https://www.bing.com/", @@ -174,22 +173,21 @@ class UserscriptsTests: XCTestCase { ] ] for (pattern, urls) in patternDict { - count = count + urls.count for url in urls { - if match(url, pattern) { - result.append("1") + if !match(url, pattern) { + print(#function, "patternDict", url, pattern) + XCTAssert(false) } } } for (pattern, urls) in patternDictFails { - // don't increment count since these tests should fail for url in urls { if match(url, pattern) { - result.removeLast() + print(#function, "patternDictFails", url, pattern) + XCTAssert(false) } } } - XCTAssert(result.count == count) } func testPerformanceExample() throws { diff --git a/xcode/Userscripts.xcodeproj/project.pbxproj b/xcode/Userscripts.xcodeproj/project.pbxproj index 5b6feb25..5ddbcdad 100644 --- a/xcode/Userscripts.xcodeproj/project.pbxproj +++ b/xcode/Userscripts.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 030C64F62AC62CC900548FBD /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 030C64F52AC62CC900548FBD /* Settings.bundle */; }; 030C64FC2AD0B24C00548FBD /* Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030C64FB2AD0B24C00548FBD /* Initialization.swift */; }; + 030FCAD42B182008004D13CD /* UrlCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FCAD32B182008004D13CD /* UrlCodecTests.swift */; }; + 030FCADC2B189AAB004D13CD /* UrlPolyfill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FCAD52B1851F8004D13CD /* UrlPolyfill.swift */; }; + 030FCADD2B18BF70004D13CD /* UrlPolyfill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FCAD52B1851F8004D13CD /* UrlPolyfill.swift */; }; + 030FCADF2B18BF72004D13CD /* UrlPolyfill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FCAD52B1851F8004D13CD /* UrlPolyfill.swift */; }; 0336619F294DF7C900CFE179 /* Functions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0336619E294DF7C900CFE179 /* Functions.swift */; }; 033661A529510B7900CFE179 /* View.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 033661A329510B7900CFE179 /* View.storyboard */; }; 039F59532AD9591C002E9977 /* dist in Resources */ = {isa = PBXBuildFile; fileRef = 039F59522AD9591C002E9977 /* dist */; }; @@ -102,6 +106,8 @@ /* Begin PBXFileReference section */ 030C64F52AC62CC900548FBD /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 030C64FB2AD0B24C00548FBD /* Initialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Initialization.swift; sourceTree = ""; }; + 030FCAD32B182008004D13CD /* UrlCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlCodecTests.swift; sourceTree = ""; }; + 030FCAD52B1851F8004D13CD /* UrlPolyfill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlPolyfill.swift; sourceTree = ""; }; 0336619E294DF7C900CFE179 /* Functions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Functions.swift; sourceTree = ""; }; 033661A429510B7900CFE179 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/View.storyboard; sourceTree = ""; }; 039F59522AD9591C002E9977 /* dist */ = {isa = PBXFileReference; lastKnownFileType = folder; path = dist; sourceTree = ""; }; @@ -208,6 +214,7 @@ isa = PBXGroup; children = ( 4A143AAB279DE6FF0029BFD0 /* UserscriptsTests.swift */, + 030FCAD32B182008004D13CD /* UrlCodecTests.swift */, ); path = "Tests-Mac"; sourceTree = ""; @@ -313,6 +320,7 @@ children = ( 78B25582299D4C27000B2E9B /* Utilities.swift */, 03C24F962ABD2CBB00F130F9 /* Preferences.swift */, + 030FCAD52B1851F8004D13CD /* UrlPolyfill.swift */, ); path = Shared; sourceTree = ""; @@ -595,6 +603,8 @@ buildActionMask = 2147483647; files = ( 7878ED24299BA27B00E36A24 /* SafariWebExtensionHandler.swift in Sources */, + 030FCADC2B189AAB004D13CD /* UrlPolyfill.swift in Sources */, + 030FCAD42B182008004D13CD /* UrlCodecTests.swift in Sources */, 4A143AB2279DEA170029BFD0 /* Functions.swift in Sources */, 03C24F9B2ABD2CBB00F130F9 /* Preferences.swift in Sources */, 78B25587299D4C27000B2E9B /* Utilities.swift in Sources */, @@ -622,6 +632,7 @@ files = ( 78B25586299D4C27000B2E9B /* Utilities.swift in Sources */, 4A301B2A270A474400C7E9E1 /* SafariWebExtensionHandler.swift in Sources */, + 030FCADD2B18BF70004D13CD /* UrlPolyfill.swift in Sources */, 03C24F9A2ABD2CBB00F130F9 /* Preferences.swift in Sources */, 4A301B32270A49F200C7E9E1 /* Functions.swift in Sources */, ); @@ -645,6 +656,7 @@ files = ( 78B25584299D4C27000B2E9B /* Utilities.swift in Sources */, 4A57BA08227235CE008A9763 /* SafariWebExtensionHandler.swift in Sources */, + 030FCADF2B18BF72004D13CD /* UrlPolyfill.swift in Sources */, 03C24F982ABD2CBB00F130F9 /* Preferences.swift in Sources */, 4AED6492268CDB58001794BF /* Functions.swift in Sources */, ); From d4e74e3c1fa01d2d18ec094859395cfdd41a2795 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:31:30 +0800 Subject: [PATCH 009/259] refactor(dev): improve xcode unit tests --- xcode/Shared/UrlPolyfill.swift | 4 +-- xcode/Tests-Mac/UrlCodecTests.swift | 40 ++++++++++++++++++-------- xcode/Tests-Mac/UserscriptsTests.swift | 4 ++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/xcode/Shared/UrlPolyfill.swift b/xcode/Shared/UrlPolyfill.swift index f0110d67..96cebed5 100644 --- a/xcode/Shared/UrlPolyfill.swift +++ b/xcode/Shared/UrlPolyfill.swift @@ -65,7 +65,7 @@ func jsLikeURL(_ urlString: String, baseString: String? = nil) -> [String: Strin "hash": fragment == "" ? "" : "#\(fragment)", "host": host, "hostname": hostname, -// "href": url.absoluteString, + // "href": url.absoluteString, "origin": "\(scheme)://\(host)", "password": url.password(percentEncoded: true) ?? "", "pathname": url.path(percentEncoded: true), @@ -83,7 +83,7 @@ func jsLikeURL(_ urlString: String, baseString: String? = nil) -> [String: Strin "hash": fragment == "" ? "" : "#\(fragment)", "host": host, "hostname": hostname, -// "href": url.absoluteString, + // "href": url.absoluteString, "origin": "\(scheme)://\(host)", "password": url.password ?? "", "pathname": url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? url.path, diff --git a/xcode/Tests-Mac/UrlCodecTests.swift b/xcode/Tests-Mac/UrlCodecTests.swift index 52a08bb3..a11ac25a 100644 --- a/xcode/Tests-Mac/UrlCodecTests.swift +++ b/xcode/Tests-Mac/UrlCodecTests.swift @@ -83,7 +83,7 @@ final class UrlCodecTests: XCTestCase { "hash": "#id", "host": "host", "hostname": "host", -// "href": "http://user:password@host/path?q=1#id", + // "href": "http://user:password@host/path?q=1#id", "origin": "http://host", "password": "password", "pathname": "/path", @@ -97,7 +97,7 @@ final class UrlCodecTests: XCTestCase { "hash": "#id", "host": "host.test:8080", "hostname": "host.test", -// "href": "http://host.test:8080/path?#id", + // "href": "http://host.test:8080/path?#id", "origin": "http://host.test:8080", "password": "", "pathname": "/path", @@ -111,7 +111,7 @@ final class UrlCodecTests: XCTestCase { "hash": "", "host": "host.test:8080", "hostname": "host.test", -// "href": "http://host.test:8080/path?", + // "href": "http://host.test:8080/path?", "origin": "http://host.test:8080", "password": "", "pathname": "/path", @@ -125,7 +125,7 @@ final class UrlCodecTests: XCTestCase { "hash": "#id", "host": "host.test", "hostname": "host.test", -// "href": "http://host.test/path#id", + // "href": "http://host.test/path#id", "origin": "http://host.test", "password": "", "pathname": "/path", @@ -139,7 +139,7 @@ final class UrlCodecTests: XCTestCase { "hash": "", "host": "host.test", "hostname": "host.test", -// "href": "http://host.test/path", + // "href": "http://host.test/path", "origin": "http://host.test", "password": "", "pathname": "/path", @@ -153,7 +153,7 @@ final class UrlCodecTests: XCTestCase { "hash": "", "host": "host.test", "hostname": "host.test", -// "href": "http://host.test/", + // "href": "http://host.test/", "origin": "http://host.test", "password": "", "pathname": "/", @@ -163,12 +163,12 @@ final class UrlCodecTests: XCTestCase { "username": "" ])) - XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + XCTAssert(check("https://用户名:密码@host.test:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ "hash": "#%E4%B8%AD%E6%96%87", - "host": "xn--fiq.xn--7dv:80", - "hostname": "xn--fiq.xn--7dv", -// "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", - "origin": "https://xn--fiq.xn--7dv:80", + "host": "host.test:80", + "hostname": "host.test", + // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@host.test:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://host.test:80", "password": "%E5%AF%86%E7%A0%81", "pathname": "/path/%E4%B8%AD%E6%96%87/", "port": "80", @@ -176,7 +176,23 @@ final class UrlCodecTests: XCTestCase { "search": "?a=%E4%B8%AD&b=%E6%96%87&c", "username": "%E7%94%A8%E6%88%B7%E5%90%8D" ])) + + if #available(macOS 14.0, iOS 17.0, *) { + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + "hash": "#%E4%B8%AD%E6%96%87", + "host": "xn--fiq.xn--7dv:80", + "hostname": "xn--fiq.xn--7dv", + // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://xn--fiq.xn--7dv:80", + "password": "%E5%AF%86%E7%A0%81", + "pathname": "/path/%E4%B8%AD%E6%96%87/", + "port": "80", + "protocol": "https:", + "search": "?a=%E4%B8%AD&b=%E6%96%87&c", + "username": "%E7%94%A8%E6%88%B7%E5%90%8D" + ])) + } - } + } // testJsLikeURL() -> END } diff --git a/xcode/Tests-Mac/UserscriptsTests.swift b/xcode/Tests-Mac/UserscriptsTests.swift index c8911bb1..bdb07d38 100644 --- a/xcode/Tests-Mac/UserscriptsTests.swift +++ b/xcode/Tests-Mac/UserscriptsTests.swift @@ -80,6 +80,7 @@ class UserscriptsTests: XCTestCase { "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", "https://cdn.frankerfacez.com/static/ffz_injector.user.js", + "https://☁️.com/", // punycode domian "http://www.k21p.com/example.user.js" // add http protocol ] for url in urls { @@ -123,7 +124,8 @@ class UserscriptsTests: XCTestCase { "*://*/*": [ "https://www.bing.com/", "https://example.org/foo/bar.html", - "https://a.org/some/path/" + "https://a.org/some/path/", + "https://☁️.com/" ], "*://*.mozilla.org/*": [ "http://mozilla.org/", From a992ef3222f976f92b9b3a075ebc20f90527117f Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sat, 2 Dec 2023 04:10:52 +0800 Subject: [PATCH 010/259] refactor(dev): improve tests and error logging --- xcode/Ext-Safari/Functions.swift | 6 ++++++ xcode/Tests-Mac/UserscriptsTests.swift | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/xcode/Ext-Safari/Functions.swift b/xcode/Ext-Safari/Functions.swift index 738fe244..03d3397a 100644 --- a/xcode/Ext-Safari/Functions.swift +++ b/xcode/Ext-Safari/Functions.swift @@ -887,14 +887,20 @@ func getRemoteFileContents(_ url: String) -> String? { if let r = response as? HTTPURLResponse, data != nil, error == nil { if r.statusCode == 200 { contents = String(data: data!, encoding: .utf8) ?? "" + } else { + logger?.error("\(#function, privacy: .public) - http statusCode (\(r.statusCode, privacy: .public)): \(url, privacy: .public)") } } + if let error = error { + logger?.error("\(#function, privacy: .public) - task error: \(error.localizedDescription, privacy: .public) (\(url, privacy: .public))") + } semaphore.signal() } task?.resume() // wait 30 seconds before timing out if semaphore.wait(timeout: .now() + 30) == .timedOut { task?.cancel() + logger?.error("\(#function, privacy: .public) - 30 seconds timeout: \(url, privacy: .public)") } // if made it to this point and contents still an empty string, something went wrong with the request diff --git a/xcode/Tests-Mac/UserscriptsTests.swift b/xcode/Tests-Mac/UserscriptsTests.swift index bdb07d38..24d70009 100644 --- a/xcode/Tests-Mac/UserscriptsTests.swift +++ b/xcode/Tests-Mac/UserscriptsTests.swift @@ -74,15 +74,19 @@ class UserscriptsTests: XCTestCase { } func testGetRemoteFileContents() throws { - let urls = [ + var urls:[String] = [ "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", "https://cdn.frankerfacez.com/static/ffz_injector.user.js", - "https://☁️.com/", // punycode domian "http://www.k21p.com/example.user.js" // add http protocol ] + if #available(macOS 14.0, iOS 17.0, *) { + urls += [ + "https://☁️.com/", // IDN / Non-Punycode-encoded domain name + ] + } for url in urls { if getRemoteFileContents(url) == nil { print(#function, url) From 962c4d81879e85362a5f878889d3aa869f4e6961 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sun, 3 Dec 2023 07:47:07 +0800 Subject: [PATCH 011/259] chore: xcode swift indent replace spaces to tabs --- xcode/App-Mac/AppDelegate.swift | 130 +- xcode/App-Mac/Functions.swift | 102 +- xcode/App-Mac/ViewController.swift | 134 +- xcode/App-Shared/USchemeHandler.swift | 100 +- xcode/App-Shared/ViewController.swift | 192 +- xcode/App-iOS/AppDelegate.swift | 18 +- xcode/App-iOS/Initialization.swift | 54 +- xcode/App-iOS/SceneDelegate.swift | 8 +- xcode/Ext-Safari/Functions.swift | 3582 ++++++++--------- .../SafariWebExtensionHandler.swift | 608 +-- xcode/Shared/Preferences.swift | 514 +-- xcode/Shared/UrlPolyfill.swift | 162 +- xcode/Shared/Utilities.swift | 52 +- xcode/Tests-Mac/UrlCodecTests.swift | 385 +- xcode/Tests-Mac/UserscriptsTests.swift | 382 +- xcode/Userscripts.xcodeproj/project.pbxproj | 1 + 16 files changed, 3213 insertions(+), 3211 deletions(-) diff --git a/xcode/App-Mac/AppDelegate.swift b/xcode/App-Mac/AppDelegate.swift index a834109c..ffdf0758 100644 --- a/xcode/App-Mac/AppDelegate.swift +++ b/xcode/App-Mac/AppDelegate.swift @@ -2,69 +2,69 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { - - private var window: NSWindow! - private var windowForego = false - private var windowLoaded = false - - @IBOutlet weak var enbaleNativeLogger: NSMenuItem! - - func application(_ application: NSApplication, open urls: [URL]) { - // if open panel is already open, stop processing the URL scheme - if NSApplication.shared.keyWindow?.accessibilityIdentifier() == "open-panel" { return } - for url in urls { - if url.host == "changesavelocation" { - // avoid opening the panel repeatedly and playing unnecessary warning sounds - if NSApplication.shared.keyWindow?.identifier?.rawValue == "changeSaveLocation" { continue } - if windowLoaded { - let viewController = window.contentViewController as? ViewController - viewController?.changeSaveLocation(nil) - } else { - windowForego = true - schemeChangeSaveLocation() - } - } - } - } - - func applicationDidFinishLaunching(_ aNotification: Notification) { - // Initialize menu items - enbaleNativeLogger.state = Preferences.enableLogger ? .on : .off - if windowForego { return } - let storyboard = NSStoryboard(name: "View", bundle: Bundle.main) - let windowController = storyboard.instantiateInitialController() as! NSWindowController -// let viewController = windowController.contentViewController as! ViewController - window = windowController.window - window.setIsVisible(true) - windowLoaded = true - } - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - return true - } - - func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down your application - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - @IBAction func enableLogger(_ sender: NSMenuItem) { - if sender.state == .on { - Preferences.enableLogger = false - sender.state = .off - } else { - Preferences.enableLogger = true - sender.state = .on - } - } - - @IBAction func applicationHelp(_ sender: NSMenuItem) { - if let url = URL(string: "https://github.com/quoid/userscripts") { - NSWorkspace.shared.open(url) - } - } - + + private var window: NSWindow! + private var windowForego = false + private var windowLoaded = false + + @IBOutlet weak var enbaleNativeLogger: NSMenuItem! + + func application(_ application: NSApplication, open urls: [URL]) { + // if open panel is already open, stop processing the URL scheme + if NSApplication.shared.keyWindow?.accessibilityIdentifier() == "open-panel" { return } + for url in urls { + if url.host == "changesavelocation" { + // avoid opening the panel repeatedly and playing unnecessary warning sounds + if NSApplication.shared.keyWindow?.identifier?.rawValue == "changeSaveLocation" { continue } + if windowLoaded { + let viewController = window.contentViewController as? ViewController + viewController?.changeSaveLocation(nil) + } else { + windowForego = true + schemeChangeSaveLocation() + } + } + } + } + + func applicationDidFinishLaunching(_ aNotification: Notification) { + // Initialize menu items + enbaleNativeLogger.state = Preferences.enableLogger ? .on : .off + if windowForego { return } + let storyboard = NSStoryboard(name: "View", bundle: Bundle.main) + let windowController = storyboard.instantiateInitialController() as! NSWindowController +// let viewController = windowController.contentViewController as! ViewController + window = windowController.window + window.setIsVisible(true) + windowLoaded = true + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + return true + } + + func applicationWillTerminate(_ aNotification: Notification) { + // Insert code here to tear down your application + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + @IBAction func enableLogger(_ sender: NSMenuItem) { + if sender.state == .on { + Preferences.enableLogger = false + sender.state = .off + } else { + Preferences.enableLogger = true + sender.state = .on + } + } + + @IBAction func applicationHelp(_ sender: NSMenuItem) { + if let url = URL(string: "https://github.com/quoid/userscripts") { + NSWorkspace.shared.open(url) + } + } + } diff --git a/xcode/App-Mac/Functions.swift b/xcode/App-Mac/Functions.swift index 650526e8..48770bfa 100644 --- a/xcode/App-Mac/Functions.swift +++ b/xcode/App-Mac/Functions.swift @@ -4,67 +4,67 @@ import SafariServices private let logger = USLogger(#fileID) func getSaveLocationURL() -> URL { - return Preferences.scriptsDirectoryUrl + return Preferences.scriptsDirectoryUrl } func setSaveLocationURL(url: URL) -> Bool { - guard FileManager.default.isWritableFile(atPath: url.path) else { - let alert = NSAlert() - alert.messageText = "Can not write to path. Choose a different path." - alert.runModal() - return false - } - Preferences.scriptsDirectoryUrl = url - return Preferences.scriptsDirectoryUrl == url + guard FileManager.default.isWritableFile(atPath: url.path) else { + let alert = NSAlert() + alert.messageText = "Can not write to path. Choose a different path." + alert.runModal() + return false + } + Preferences.scriptsDirectoryUrl = url + return Preferences.scriptsDirectoryUrl == url } func sendExtensionMessage(name: String, userInfo: [String : Any]? = nil, completion: ((Error?) -> Void)? = nil) { - SFSafariApplication.dispatchMessage( - withName: name, - toExtensionWithIdentifier: extIdentifier, - userInfo: userInfo - ) { error in // always be called - if error != nil { - debugPrint("Message attempted. Error info: \(String.init(describing: error))") - } - if let userHandle = completion { userHandle(error) } - } + SFSafariApplication.dispatchMessage( + withName: name, + toExtensionWithIdentifier: extIdentifier, + userInfo: userInfo + ) { error in // always be called + if error != nil { + debugPrint("Message attempted. Error info: \(String.init(describing: error))") + } + if let userHandle = completion { userHandle(error) } + } } func changeSaveLocationPanel(directoryURL: URL? = nil) -> NSOpenPanel { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = true - panel.canCreateDirectories = true - panel.canChooseFiles = false - if directoryURL != nil { - panel.directoryURL = directoryURL - } - panel.title = "Change Save Location - Userscripts" - panel.identifier = NSUserInterfaceItemIdentifier("changeSaveLocation") - return panel + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.canChooseFiles = false + if directoryURL != nil { + panel.directoryURL = directoryURL + } + panel.title = "Change Save Location - Userscripts" + panel.identifier = NSUserInterfaceItemIdentifier("changeSaveLocation") + return panel } func schemeChangeSaveLocation() { - let saveLocationURL = getSaveLocationURL() - let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) - // shows the path selection panel - let response = panel.runModal() - // check if clicked open button and there is a valid result - guard response == .OK, let url: URL = panel.urls.first else { return } - // revoke implicitly starts security-scoped access - defer { url.stopAccessingSecurityScopedResource() } - // check if path has indeed changed - if url.absoluteString == saveLocationURL.absoluteString { return } - // try set new save location path to bookmark - guard setSaveLocationURL(url: url) else { return } - // use semaphore to ensure the async func executed before app exits - let semaphore = DispatchSemaphore(value: 0) - // notify browser extension of relevant updates - sendExtensionMessage( - name: "SAVE_LOCATION_CHANGED", - userInfo: ["saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString], - completion: { _ in semaphore.signal() } - ) - semaphore.wait() + let saveLocationURL = getSaveLocationURL() + let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) + // shows the path selection panel + let response = panel.runModal() + // check if clicked open button and there is a valid result + guard response == .OK, let url: URL = panel.urls.first else { return } + // revoke implicitly starts security-scoped access + defer { url.stopAccessingSecurityScopedResource() } + // check if path has indeed changed + if url.absoluteString == saveLocationURL.absoluteString { return } + // try set new save location path to bookmark + guard setSaveLocationURL(url: url) else { return } + // use semaphore to ensure the async func executed before app exits + let semaphore = DispatchSemaphore(value: 0) + // notify browser extension of relevant updates + sendExtensionMessage( + name: "SAVE_LOCATION_CHANGED", + userInfo: ["saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString], + completion: { _ in semaphore.signal() } + ) + semaphore.wait() } diff --git a/xcode/App-Mac/ViewController.swift b/xcode/App-Mac/ViewController.swift index 494771e5..fc7598ee 100644 --- a/xcode/App-Mac/ViewController.swift +++ b/xcode/App-Mac/ViewController.swift @@ -4,79 +4,79 @@ private let logger = USLogger(#fileID) class ViewController: NSViewController { - @IBOutlet var appName: NSTextField! - @IBOutlet var saveLocation: NSTextField! - @IBOutlet weak var enabledText: NSTextField! - @IBOutlet weak var enabledIcon: NSView! + @IBOutlet var appName: NSTextField! + @IBOutlet var saveLocation: NSTextField! + @IBOutlet weak var enabledText: NSTextField! + @IBOutlet weak var enabledIcon: NSView! @IBOutlet weak var openButton: NSButton! - - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" - override func viewDidLoad() { - super.viewDidLoad() - self.appName.stringValue = "Userscripts Safari Version \(appVersion) (\(buildNumber))" - setExtensionState() - NotificationCenter.default.addObserver( - self, - selector: #selector(setExtensionState), - name: NSApplication.didBecomeActiveNotification, - object: nil - ) - let url = getSaveLocationURL() - self.saveLocation.stringValue = url.absoluteString - self.saveLocation.toolTip = url.absoluteString + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" + + override func viewDidLoad() { + super.viewDidLoad() + self.appName.stringValue = "Userscripts Safari Version \(appVersion) (\(buildNumber))" + setExtensionState() + NotificationCenter.default.addObserver( + self, + selector: #selector(setExtensionState), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + let url = getSaveLocationURL() + self.saveLocation.stringValue = url.absoluteString + self.saveLocation.toolTip = url.absoluteString if #available(macOS 13, *) { self.openButton.title = "Open Safari Settings" } - } + } - @objc func setExtensionState() { - SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extIdentifier) { (state, error) in - guard let state = state else { - self.enabledText.stringValue = "Safari Extension State Unknown" - if let error = error { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } else { - logger?.error("\(#function, privacy: .public) - couldn't get safari extension state in containing app") - } - return - } - DispatchQueue.main.async { - self.enabledIcon.layer?.backgroundColor = state.isEnabled ? NSColor.green.cgColor : NSColor.red.cgColor - self.enabledText.stringValue = state.isEnabled ? "Safari Extension Enabled" : "Safari Extension Disabled" - } - } - } + @objc func setExtensionState() { + SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extIdentifier) { (state, error) in + guard let state = state else { + self.enabledText.stringValue = "Safari Extension State Unknown" + if let error = error { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } else { + logger?.error("\(#function, privacy: .public) - couldn't get safari extension state in containing app") + } + return + } + DispatchQueue.main.async { + self.enabledIcon.layer?.backgroundColor = state.isEnabled ? NSColor.green.cgColor : NSColor.red.cgColor + self.enabledText.stringValue = state.isEnabled ? "Safari Extension Enabled" : "Safari Extension Disabled" + } + } + } - @IBAction func changeSaveLocation(_ sender: AnyObject?) { - guard let window = self.view.window else { return } - let saveLocationURL = getSaveLocationURL() - let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) - panel.beginSheetModal(for: window, completionHandler: { response in - // check if clicked open button and there is a valid result - guard response == .OK, let url: URL = panel.urls.first else { return } - // revoke implicitly starts security-scoped access - defer { url.stopAccessingSecurityScopedResource() } - // check if path has indeed changed - if url.absoluteString == saveLocationURL.absoluteString { return } - // try set new save location path to bookmark - guard setSaveLocationURL(url: url) else { return } - // update user interface text display - self.saveLocation.stringValue = url.absoluteString - self.saveLocation.toolTip = url.absoluteString - // notify browser extension of relevant updates - sendExtensionMessage( - name: "SAVE_LOCATION_CHANGED", - userInfo: [ - "saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString, - "returnApp": true - ] - ) - }) - } + @IBAction func changeSaveLocation(_ sender: AnyObject?) { + guard let window = self.view.window else { return } + let saveLocationURL = getSaveLocationURL() + let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) + panel.beginSheetModal(for: window, completionHandler: { response in + // check if clicked open button and there is a valid result + guard response == .OK, let url: URL = panel.urls.first else { return } + // revoke implicitly starts security-scoped access + defer { url.stopAccessingSecurityScopedResource() } + // check if path has indeed changed + if url.absoluteString == saveLocationURL.absoluteString { return } + // try set new save location path to bookmark + guard setSaveLocationURL(url: url) else { return } + // update user interface text display + self.saveLocation.stringValue = url.absoluteString + self.saveLocation.toolTip = url.absoluteString + // notify browser extension of relevant updates + sendExtensionMessage( + name: "SAVE_LOCATION_CHANGED", + userInfo: [ + "saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString, + "returnApp": true + ] + ) + }) + } - @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { - SFSafariApplication.showPreferencesForExtension(withIdentifier: extIdentifier) - } + @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { + SFSafariApplication.showPreferencesForExtension(withIdentifier: extIdentifier) + } } diff --git a/xcode/App-Shared/USchemeHandler.swift b/xcode/App-Shared/USchemeHandler.swift index 99c8acaf..28fa301b 100644 --- a/xcode/App-Shared/USchemeHandler.swift +++ b/xcode/App-Shared/USchemeHandler.swift @@ -8,59 +8,59 @@ let AppWebViewEntryPage = "entry-app-webview.html" // https://developer.apple.com/documentation/webkit/wkurlschemehandler // https://developer.apple.com/documentation/webkit/wkurlschemetask class USchemeHandler: NSObject, WKURLSchemeHandler { - - func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - // wrapper of didFailWithError - func failHandler(_ errmsg: String) { - logger?.error("\(#function) - \(errmsg, privacy: .public)") - // redirect to a customized error page -// DispatchQueue.main.async { -// webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) -// } - urlSchemeTask.didFailWithError(NSError(domain: "USchemeHandler", code: 0, userInfo: nil)) - } - // https://developer.apple.com/documentation/dispatch/dispatchqueue - DispatchQueue.global(qos: .userInteractive).async { - guard let url = urlSchemeTask.request.url else { - failHandler("failed to get request url") - return - } - guard url.scheme == AppWebViewUrlScheme else { - failHandler("unexpected url scheme: \(url)") - return - } - var name: String - if #available(macOS 13.0, iOS 16.0, *) { - name = url.path(percentEncoded: false) - } else { - name = url.path - } - if ["", "/"].contains(name) { - name = AppWebViewEntryPage - } - guard let file = Bundle.main.url(forResource: name, withExtension: nil, subdirectory: "dist") else { - failHandler("file not found: \(url)") - return - } - guard let data = try? Data(contentsOf: file) else { - failHandler("faild to get data from: \(url)") - return - } + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + // wrapper of didFailWithError + func failHandler(_ errmsg: String) { + logger?.error("\(#function) - \(errmsg, privacy: .public)") + // redirect to a customized error page +// DispatchQueue.main.async { +// webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) +// } + urlSchemeTask.didFailWithError(NSError(domain: "USchemeHandler", code: 0, userInfo: nil)) + } - // https://developer.apple.com/documentation/uniformtypeidentifiers - //let mime = UTType.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" - let mime = UTTypeReference.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + // https://developer.apple.com/documentation/dispatch/dispatchqueue + DispatchQueue.global(qos: .userInteractive).async { + guard let url = urlSchemeTask.request.url else { + failHandler("failed to get request url") + return + } + guard url.scheme == AppWebViewUrlScheme else { + failHandler("unexpected url scheme: \(url)") + return + } + var name: String + if #available(macOS 13.0, iOS 16.0, *) { + name = url.path(percentEncoded: false) + } else { + name = url.path + } + if ["", "/"].contains(name) { + name = AppWebViewEntryPage + } + guard let file = Bundle.main.url(forResource: name, withExtension: nil, subdirectory: "dist") else { + failHandler("file not found: \(url)") + return + } + guard let data = try? Data(contentsOf: file) else { + failHandler("faild to get data from: \(url)") + return + } - let response = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(data) - urlSchemeTask.didFinish() - } - } + // https://developer.apple.com/documentation/uniformtypeidentifiers + // let mime = UTType.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + let mime = UTTypeReference.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + let response = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + + } - } - } diff --git a/xcode/App-Shared/ViewController.swift b/xcode/App-Shared/ViewController.swift index a4dc6e4c..15430135 100644 --- a/xcode/App-Shared/ViewController.swift +++ b/xcode/App-Shared/ViewController.swift @@ -4,107 +4,107 @@ private let logger = USLogger(#fileID) class ViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHandler, UIDocumentPickerDelegate { - @IBOutlet var webView: WKWebView! - - // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621454-loadview - override func loadView() { - // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration - let configuration = WKWebViewConfiguration() - // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/2875766-seturlschemehandler - configuration.setURLSchemeHandler(USchemeHandler(), forURLScheme: AppWebViewUrlScheme) - // https://developer.apple.com/documentation/webkit/wkusercontentcontroller - configuration.userContentController.add(self, name: "controller") - // https://developer.apple.com/documentation/webkit/wkwebview - self.webView = WKWebView(frame: .zero, configuration: configuration) - // https://developer.apple.com/documentation/webkit/wknavigationdelegate - self.webView.navigationDelegate = self + @IBOutlet var webView: WKWebView! + + // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621454-loadview + override func loadView() { + // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration + let configuration = WKWebViewConfiguration() + // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/2875766-seturlschemehandler + configuration.setURLSchemeHandler(USchemeHandler(), forURLScheme: AppWebViewUrlScheme) + // https://developer.apple.com/documentation/webkit/wkusercontentcontroller + configuration.userContentController.add(self, name: "controller") + // https://developer.apple.com/documentation/webkit/wkwebview + self.webView = WKWebView(frame: .zero, configuration: configuration) + // https://developer.apple.com/documentation/webkit/wknavigationdelegate + self.webView.navigationDelegate = self #if DEBUG - // https://webkit.org/blog/13936/enabling-the-inspection-of-web-content-in-apps/ - if #available(macOS 13.3, iOS 16.4, tvOS 16.4, *) { - // https://developer.apple.com/documentation/webkit/wkwebview/4111163-inspectable/ - self.webView.isInspectable = true - } - logger?.debug("\(#function, privacy: .public) - DEBUG mode: isInspectable = true") + // https://webkit.org/blog/13936/enabling-the-inspection-of-web-content-in-apps/ + if #available(macOS 13.3, iOS 16.4, tvOS 16.4, *) { + // https://developer.apple.com/documentation/webkit/wkwebview/4111163-inspectable/ + self.webView.isInspectable = true + } + logger?.debug("\(#function, privacy: .public) - DEBUG mode: isInspectable = true") #endif - view = webView - self.webView.scrollView.isScrollEnabled = false - self.webView.isOpaque = false - 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.backgroundColor = backgroundColor - } + view = webView + self.webView.scrollView.isScrollEnabled = false + self.webView.isOpaque = false + 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.backgroundColor = backgroundColor + } - // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload - override func viewDidLoad() { - super.viewDidLoad() - webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) - } + // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload + override func viewDidLoad() { + super.viewDidLoad() + webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) + } - 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("APP.printVersion('v\(appVersion)', '(\(buildNumber))')") - webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") - } + 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("APP.printVersion('v\(appVersion)', '(\(buildNumber))')") + webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") + } - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if navigationAction.navigationType == .linkActivated { - guard let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - // allow registration scheme - if url.scheme == AppWebViewUrlScheme { - decisionHandler(.allow) - return - } - // allow specified url prefixes -// let allowPrefixes = [ -// "https://github.com/quoid/userscripts" -// ] -// for prefix in allowPrefixes { -// if url.absoluteString.lowercased().hasPrefix(prefix) { -// decisionHandler(.allow) -// return -// } -// } - // open from external app like safari - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - decisionHandler(.cancel) - return - } - decisionHandler(.allow) - } - decisionHandler(.allow) - } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + // allow registration scheme + if url.scheme == AppWebViewUrlScheme { + decisionHandler(.allow) + return + } + // allow specified url prefixes +// let allowPrefixes = [ +// "https://github.com/quoid/userscripts" +// ] +// for prefix in allowPrefixes { +// if url.absoluteString.lowercased().hasPrefix(prefix) { +// decisionHandler(.allow) +// return +// } +// } + // open from external app like safari + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } + 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 == "CHANGE_DIRECTORY" { - // 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) - } - if name == "OPEN_DIRECTORY" { - guard var components = URLComponents(url: Preferences.scriptsDirectoryUrl, resolvingAgainstBaseURL: true) else { - return - } - components.scheme = "shareddocuments" - if let url = components.url, UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - } + 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 == "CHANGE_DIRECTORY" { + // 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) + } + if name == "OPEN_DIRECTORY" { + guard var components = URLComponents(url: Preferences.scriptsDirectoryUrl, resolvingAgainstBaseURL: true) else { + return + } + components.scheme = "shareddocuments" + if let url = components.url, UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { - Preferences.scriptsDirectoryUrl = url - webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") - } + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + Preferences.scriptsDirectoryUrl = url + webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") + } } diff --git a/xcode/App-iOS/AppDelegate.swift b/xcode/App-iOS/AppDelegate.swift index 1857846c..9da1b4c6 100644 --- a/xcode/App-iOS/AppDelegate.swift +++ b/xcode/App-iOS/AppDelegate.swift @@ -12,16 +12,16 @@ import os @main class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? + var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - initializeFirstStart() - return true - } + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + initializeFirstStart() + return true + } - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } } diff --git a/xcode/App-iOS/Initialization.swift b/xcode/App-iOS/Initialization.swift index ab6f6e9f..7585ff7d 100644 --- a/xcode/App-iOS/Initialization.swift +++ b/xcode/App-iOS/Initialization.swift @@ -3,7 +3,7 @@ import Foundation private let logger = USLogger(#fileID) private func createDemoScript() { - let demoScript = """ + let demoScript = """ // ==UserScript== // @name Demo user script // @description I am a demo user script that you can safely delete (add any files to this folder and I will no longer automatically generate) @@ -15,35 +15,35 @@ private func createDemoScript() { // ==/UserScript== (function () { - 'use strict'; - // here is your code + 'use strict'; + // here is your code })(); """ - let url = getDocumentsDirectory() - // get a list of non-hidden files - guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [], options: .skipsHiddenFiles) else { - logger?.error("\(#function, privacy: .public) - failed get contentsOfDirectory") - return - } - if contents.isEmpty { - let fileURL = url.appendingPathComponent("Demo_user_script.user.js") - do { - logger?.info("\(#function, privacy: .public) - try to write demo user script") - try demoScript.write(to: fileURL, atomically: true, encoding: .utf8) - } catch { - logger?.error("\(#function, privacy: .public) - failed to write demo user script") - } - } + let url = getDocumentsDirectory() + // get a list of non-hidden files + guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [], options: .skipsHiddenFiles) else { + logger?.error("\(#function, privacy: .public) - failed get contentsOfDirectory") + return + } + if contents.isEmpty { + let fileURL = url.appendingPathComponent("Demo_user_script.user.js") + do { + logger?.info("\(#function, privacy: .public) - try to write demo user script") + try demoScript.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + logger?.error("\(#function, privacy: .public) - failed to write demo user script") + } + } } func initializeFirstStart() { - // set the scripts directory to the app document on first use - if isCurrentDefaultScriptsDirectory() { - logger?.info("\(#function, privacy: .public) - Initialize default directory") - Preferences.scriptsDirectoryUrl = getDocumentsDirectory() - } - // put a visible file to display the documents directory in files app - if isCurrentInitialScriptsDirectory() { - createDemoScript() - } + // set the scripts directory to the app document on first use + if isCurrentDefaultScriptsDirectory() { + logger?.info("\(#function, privacy: .public) - Initialize default directory") + Preferences.scriptsDirectoryUrl = getDocumentsDirectory() + } + // put a visible file to display the documents directory in files app + if isCurrentInitialScriptsDirectory() { + createDemoScript() + } } diff --git a/xcode/App-iOS/SceneDelegate.swift b/xcode/App-iOS/SceneDelegate.swift index 1d5426bd..e733d27e 100644 --- a/xcode/App-iOS/SceneDelegate.swift +++ b/xcode/App-iOS/SceneDelegate.swift @@ -10,10 +10,10 @@ import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? + var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let _ = (scene as? UIWindowScene) else { return } - } + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let _ = (scene as? UIWindowScene) else { return } + } } diff --git a/xcode/Ext-Safari/Functions.swift b/xcode/Ext-Safari/Functions.swift index 03d3397a..9ce1f86b 100644 --- a/xcode/Ext-Safari/Functions.swift +++ b/xcode/Ext-Safari/Functions.swift @@ -4,1954 +4,1954 @@ private let logger = USLogger(#fileID) // helpers func getRequireLocation() -> URL { - // simple helper in case required code save directory needs to change - return getDocumentsDirectory().appendingPathComponent("require") + // simple helper in case required code save directory needs to change + return getDocumentsDirectory().appendingPathComponent("require") } func dateToMilliseconds(_ date: Date) -> Int { - let since1970 = date.timeIntervalSince1970 - return Int(since1970 * 1000) + let since1970 = date.timeIntervalSince1970 + return Int(since1970 * 1000) } func sanitize(_ str: String) -> String { - // removes invalid filename characters from strings - var sanitized = str - if sanitized.first == "." { - sanitized = "%2" + str.dropFirst() - } - sanitized = sanitized.replacingOccurrences(of: "/", with: "%2F") - sanitized = sanitized.replacingOccurrences(of: ":", with: "%3A") - sanitized = sanitized.replacingOccurrences(of: "\\", with: "%5C") - return sanitized + // removes invalid filename characters from strings + var sanitized = str + if sanitized.first == "." { + sanitized = "%2" + str.dropFirst() + } + sanitized = sanitized.replacingOccurrences(of: "/", with: "%2F") + sanitized = sanitized.replacingOccurrences(of: ":", with: "%3A") + sanitized = sanitized.replacingOccurrences(of: "\\", with: "%5C") + return sanitized } func unsanitize(_ str: String) -> String { - var s = str - if s.hasPrefix("%2") && !s.hasPrefix("%2F") { - s = "." + s.dropFirst(2) - } - if s.removingPercentEncoding != s { - s = s.removingPercentEncoding ?? s - } - return s + var s = str + if s.hasPrefix("%2") && !s.hasPrefix("%2F") { + s = "." + s.dropFirst(2) + } + if s.removingPercentEncoding != s { + s = s.removingPercentEncoding ?? s + } + return s } func normalizeWeight(_ weight: String) -> String { - if let w = Int(weight) { - if w > 999 { - return "999" - } else if w < 1 { - return "1" - } else { - return weight - } - } else { - return "1" - } + if let w = Int(weight) { + if w > 999 { + return "999" + } else if w < 1 { + return "1" + } else { + return weight + } + } else { + return "1" + } } func getSaveLocation() -> URL? { #if os(iOS) - if isCurrentDefaultScriptsDirectory() { - logger?.info("\(#function, privacy: .public) - Uninitialized save location") - return nil - } + if isCurrentDefaultScriptsDirectory() { + logger?.info("\(#function, privacy: .public) - Uninitialized save location") + return nil + } #endif - let url = Preferences.scriptsDirectoryUrl - logger?.debug("\(#function, privacy: .public) - \(url, privacy: .public)") - return url + let url = Preferences.scriptsDirectoryUrl + logger?.debug("\(#function, privacy: .public) - \(url, privacy: .public)") + return url } func openSaveLocation() -> Bool { - #if os(macOS) - guard let saveLocation = getSaveLocation() else { - return false - } - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: saveLocation.path) - #endif - return true + #if os(macOS) + guard let saveLocation = getSaveLocation() else { + return false + } + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: saveLocation.path) + #endif + return true } func validateUrl(_ urlString: String) -> Bool { - guard - let parts = jsLikeURL(urlString), - let ptcl = parts["protocol"], - let path = parts["pathname"] - else { - logger?.error("\(#function, privacy: .public) - Invalid URL: \(urlString, privacy: .public)") - return false - } - if - (ptcl != "https:" && ptcl != "http:") - || (!path.hasSuffix(".css") && !path.hasSuffix(".js")) - { - return false - } - return true + guard + let parts = jsLikeURL(urlString), + let ptcl = parts["protocol"], + let path = parts["pathname"] + else { + logger?.error("\(#function, privacy: .public) - Invalid URL: \(urlString, privacy: .public)") + return false + } + if + (ptcl != "https:" && ptcl != "http:") + || (!path.hasSuffix(".css") && !path.hasSuffix(".js")) + { + return false + } + return true } func isVersionNewer(_ oldVersion: String, _ newVersion: String) -> Bool { - let oldVersions = oldVersion.components(separatedBy: ".") - let newVersions = newVersion.components(separatedBy: ".") - for (index, version) in newVersions.enumerated() { - let a = Int(version) ?? 0 - let oldVersionValue = oldVersions.indices.contains(index) ? oldVersions[index] : "0" - let b = Int(oldVersionValue) ?? 0 - if a > b { - return true - } - if a < b { - return false - } - } - return false + let oldVersions = oldVersion.components(separatedBy: ".") + let newVersions = newVersion.components(separatedBy: ".") + for (index, version) in newVersions.enumerated() { + let a = Int(version) ?? 0 + let oldVersionValue = oldVersions.indices.contains(index) ? oldVersions[index] : "0" + let b = Int(oldVersionValue) ?? 0 + if a > b { + return true + } + if a < b { + return false + } + } + return false } func isEncoded(_ str: String) -> Bool { - if let decoded = str.removingPercentEncoding { - return decoded != str - } - return false + if let decoded = str.removingPercentEncoding { + return decoded != str + } + return false } // parser func parse(_ content: String) -> [String: Any]? { - // returns structured data from content of file - // will fail to parse if metablock or required @name key missing - let pattern = #"(?:(\/\/ ==UserScript==[ \t]*?\r?\n([\S\s]*?)\r?\n\/\/ ==\/UserScript==)([\S\s]*)|(\/\* ==UserStyle==[ \t]*?\r?\n([\S\s]*?)\r?\n==\/UserStyle== \*\/)([\S\s]*))"# - // force try b/c pattern is known to be valid regex - let regex = try! NSRegularExpression(pattern: pattern, options: []) - let range = NSRange(location: 0, length: content.utf16.count) - // return nil/fail if metablock missing - guard let match = regex.firstMatch(in: content, options: [], range: range) else { - return nil - } - - // at this point the text content has passed initial validation, it contains valid metadata - // the metadata can be in userscript or userstyle format, need to check for this and adjust group numbers - // rather than being too strict, text content can precede the opening userscript tag, however it will be ignored - // adjust start index of file content while assigning group numbers to account for any text content preceding opening tag - let contentStartIndex = content.index(content.startIndex, offsetBy: match.range.lowerBound) - var g1, g2, g3:Int - if (content[contentStartIndex.. Bool { - let content = data - let url = getDocumentsDirectory().appendingPathComponent("manifest.json") - do { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let encoded = try encoder.encode(content) - let fileContent = String(decoding: encoded, as: UTF8.self) - try fileContent.write(to: url, atomically: false, encoding: .utf8) - return true - } catch { - logger?.error("\(#function, privacy: .public) - failed to update manifest: \(error.localizedDescription, privacy: .public)") - return false - } + let content = data + let url = getDocumentsDirectory().appendingPathComponent("manifest.json") + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let encoded = try encoder.encode(content) + let fileContent = String(decoding: encoded, as: UTF8.self) + try fileContent.write(to: url, atomically: false, encoding: .utf8) + return true + } catch { + logger?.error("\(#function, privacy: .public) - failed to update manifest: \(error.localizedDescription, privacy: .public)") + return false + } } func getManifest() -> Manifest { - let url = getDocumentsDirectory().appendingPathComponent("manifest.json") - if - let content = try? String(contentsOf: url, encoding: .utf8), - let data = content.data(using: .utf8), - let decoded = try? JSONDecoder().decode(Manifest.self, from: Data(data)) - { - return decoded - } else { - // manifest missing, improperly formatted or missing key - // create new manifest with default key/vals - let manifest = Manifest( - blacklist: [], - declarativeNetRequest: [], - disabled: [], - exclude: [:], - excludeMatch: [:], - include: [:], - match: [:], - require: [:], - settings: defaultSettings - ) - _ = updateManifest(with: manifest) - return manifest - } + let url = getDocumentsDirectory().appendingPathComponent("manifest.json") + if + let content = try? String(contentsOf: url, encoding: .utf8), + let data = content.data(using: .utf8), + let decoded = try? JSONDecoder().decode(Manifest.self, from: Data(data)) + { + return decoded + } else { + // manifest missing, improperly formatted or missing key + // create new manifest with default key/vals + let manifest = Manifest( + blacklist: [], + declarativeNetRequest: [], + disabled: [], + exclude: [:], + excludeMatch: [:], + include: [:], + match: [:], + require: [:], + settings: defaultSettings + ) + _ = updateManifest(with: manifest) + return manifest + } } func updateManifestMatches(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - // only get all files if files were not provided - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else {return false} - files = getFiles - } else { - files = optionalFilesArray - } - var manifest = getManifest() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let metadata = file["metadata"] as! [String: [String]] - let filename = file["filename"] as! String - // skip request type userscripts - let runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - continue - } - // populate excludes & matches - var excludeMatched = [String]() - var matched = [String]() - var excluded = [String]() - var included = [String]() - if metadata["exclude-match"] != nil { - excludeMatched.append(contentsOf: metadata["exclude-match"]!) - } - if metadata["match"] != nil { - matched.append(contentsOf: metadata["match"]!) - } - if metadata["include"] != nil { - included.append(contentsOf: metadata["include"]!) - } - if metadata["exclude"] != nil { - excluded.append(contentsOf: metadata["exclude"]!) - } - // if in declarativeNetRequest array, remove it - if manifest.declarativeNetRequest.contains(filename) { - if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { - manifest.declarativeNetRequest.remove(at: index) - } else { - logger?.error("\(#function, privacy: .public) - failed to remove \(filename, privacy: .public) from dNR array") - } - } - - // update manifest values - manifest.excludeMatch = updatePatternDict(filename, excludeMatched, manifest.excludeMatch) - manifest.match = updatePatternDict(filename, matched, manifest.match) - manifest.exclude = updatePatternDict(filename, excluded, manifest.exclude) - manifest.include = updatePatternDict(filename, included, manifest.include) - - if !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed to update manifest matches") - return false - } - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + // only get all files if files were not provided + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else {return false} + files = getFiles + } else { + files = optionalFilesArray + } + var manifest = getManifest() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let metadata = file["metadata"] as! [String: [String]] + let filename = file["filename"] as! String + // skip request type userscripts + let runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + continue + } + // populate excludes & matches + var excludeMatched = [String]() + var matched = [String]() + var excluded = [String]() + var included = [String]() + if metadata["exclude-match"] != nil { + excludeMatched.append(contentsOf: metadata["exclude-match"]!) + } + if metadata["match"] != nil { + matched.append(contentsOf: metadata["match"]!) + } + if metadata["include"] != nil { + included.append(contentsOf: metadata["include"]!) + } + if metadata["exclude"] != nil { + excluded.append(contentsOf: metadata["exclude"]!) + } + // if in declarativeNetRequest array, remove it + if manifest.declarativeNetRequest.contains(filename) { + if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { + manifest.declarativeNetRequest.remove(at: index) + } else { + logger?.error("\(#function, privacy: .public) - failed to remove \(filename, privacy: .public) from dNR array") + } + } + + // update manifest values + manifest.excludeMatch = updatePatternDict(filename, excludeMatched, manifest.excludeMatch) + manifest.match = updatePatternDict(filename, matched, manifest.match) + manifest.exclude = updatePatternDict(filename, excluded, manifest.exclude) + manifest.include = updatePatternDict(filename, included, manifest.include) + + if !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed to update manifest matches") + return false + } + } + logger?.info("\(#function, privacy: .public) - completed") + return true } func updatePatternDict(_ filename: String, _ filePatterns: [String], _ manifestKeys: [String: [String]]) -> [String: [String]] { - // will hold the exclude/match patterns in manifest that have file name as value - var patternsInManifestForFile = [String]() - // new var from func argument, so it can be manipulated - var returnDictionary = manifestKeys - // patterns from manifest - let keys = returnDictionary.keys - // determine what patterns already have this filename as a value - for key in keys { - // key is an array of filenames - guard let filenames = returnDictionary[key] else { - logger?.error("\(#function, privacy: .public) - failed to get values for manifest key, \(key, privacy: .public)") - continue - } - for name in filenames { - // name is a single filename - // if name is same as filename, file already added for this pattern - // add it to patternsInManifestForFile for later comparison - if name == filename { - patternsInManifestForFile.append(key) - } - } - } - // patterns in file metadata and patterns in manifest that have filename as a value - // filename already present in manifest for these patterns, do nothing with these - // let common = filePatterns.filter{patternsInManifestForFile.contains($0)} - // patterns in file metadata, but don't have the filename as a value within the manifest - // these are the manifest patterns that the filename needs to be added to - let addFilenameTo = filePatterns.filter{!patternsInManifestForFile.contains($0)} - - // the patterns that have the filename as a value, but not present in file metadata - // ie. these are the manifest patterns we need to remove the filename from - let removeFilenameFrom = patternsInManifestForFile.filter{!filePatterns.contains($0)} - - // check if filename needs to be added or new key/val needs to be created - for pattern in addFilenameTo { - if returnDictionary[pattern] != nil { - returnDictionary[pattern]?.append(filename) - } else { - returnDictionary[pattern] = [filename] - } - } - - for pattern in removeFilenameFrom { - // get the index of the filename within the array - let ind = returnDictionary[pattern]?.firstIndex(of: filename) - // remove filename from array by index - returnDictionary[pattern]?.remove(at: ind!) - // if filename was the last item in array, remove the url pattern from dictionary - if returnDictionary[pattern]!.isEmpty { - returnDictionary.removeValue(forKey: pattern) - } - } - - return returnDictionary + // will hold the exclude/match patterns in manifest that have file name as value + var patternsInManifestForFile = [String]() + // new var from func argument, so it can be manipulated + var returnDictionary = manifestKeys + // patterns from manifest + let keys = returnDictionary.keys + // determine what patterns already have this filename as a value + for key in keys { + // key is an array of filenames + guard let filenames = returnDictionary[key] else { + logger?.error("\(#function, privacy: .public) - failed to get values for manifest key, \(key, privacy: .public)") + continue + } + for name in filenames { + // name is a single filename + // if name is same as filename, file already added for this pattern + // add it to patternsInManifestForFile for later comparison + if name == filename { + patternsInManifestForFile.append(key) + } + } + } + // patterns in file metadata and patterns in manifest that have filename as a value + // filename already present in manifest for these patterns, do nothing with these + // let common = filePatterns.filter{patternsInManifestForFile.contains($0)} + // patterns in file metadata, but don't have the filename as a value within the manifest + // these are the manifest patterns that the filename needs to be added to + let addFilenameTo = filePatterns.filter{!patternsInManifestForFile.contains($0)} + + // the patterns that have the filename as a value, but not present in file metadata + // ie. these are the manifest patterns we need to remove the filename from + let removeFilenameFrom = patternsInManifestForFile.filter{!filePatterns.contains($0)} + + // check if filename needs to be added or new key/val needs to be created + for pattern in addFilenameTo { + if returnDictionary[pattern] != nil { + returnDictionary[pattern]?.append(filename) + } else { + returnDictionary[pattern] = [filename] + } + } + + for pattern in removeFilenameFrom { + // get the index of the filename within the array + let ind = returnDictionary[pattern]?.firstIndex(of: filename) + // remove filename from array by index + returnDictionary[pattern]?.remove(at: ind!) + // if filename was the last item in array, remove the url pattern from dictionary + if returnDictionary[pattern]!.isEmpty { + returnDictionary.removeValue(forKey: pattern) + } + } + + return returnDictionary } func updateManifestRequired(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - // only get all files if files were not provided - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else { - logger?.info("\(#function, privacy: .public) - count not get files") - return false - } - files = getFiles - } else { - files = optionalFilesArray - } - logger?.info("\(#function, privacy: .public) - will loop through \(files.count, privacy: .public)") - var manifest = getManifest() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let filename = file["filename"] as! String - let metadata = file["metadata"] as! [String: [String]] - let type = file["type"] as! String - let required = metadata["require"] ?? [] - logger?.info("\(#function, privacy: .public) - begin \(filename, privacy: .public)") - // get required resources for file, if fail, skip updating manifest - if !getRequiredCode(filename, required, type) { - logger?.error("\(#function, privacy: .public) - couldn't fetch remote content for \(filename, privacy: .public)") - continue - } - - // create filenames from sanitized resource urls - // getRequiredCode does the same thing when saving to disk - // populate array with entries for manifest - var r = [String]() - for resource in required { - let sanitizedResourceName = sanitize(resource) - r.append(sanitizedResourceName) - } - - // if there are values, write them to manifest - // if failed to write to manifest, continue to next file & log error - if !r.isEmpty && r != manifest.require[filename] { - manifest.require[filename] = r - if !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - couldn't update manifest when getting required resources") - } - } - logger?.info("\(#function, privacy: .public) - end \(filename, privacy: .public)") - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + // only get all files if files were not provided + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else { + logger?.info("\(#function, privacy: .public) - count not get files") + return false + } + files = getFiles + } else { + files = optionalFilesArray + } + logger?.info("\(#function, privacy: .public) - will loop through \(files.count, privacy: .public)") + var manifest = getManifest() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let filename = file["filename"] as! String + let metadata = file["metadata"] as! [String: [String]] + let type = file["type"] as! String + let required = metadata["require"] ?? [] + logger?.info("\(#function, privacy: .public) - begin \(filename, privacy: .public)") + // get required resources for file, if fail, skip updating manifest + if !getRequiredCode(filename, required, type) { + logger?.error("\(#function, privacy: .public) - couldn't fetch remote content for \(filename, privacy: .public)") + continue + } + + // create filenames from sanitized resource urls + // getRequiredCode does the same thing when saving to disk + // populate array with entries for manifest + var r = [String]() + for resource in required { + let sanitizedResourceName = sanitize(resource) + r.append(sanitizedResourceName) + } + + // if there are values, write them to manifest + // if failed to write to manifest, continue to next file & log error + if !r.isEmpty && r != manifest.require[filename] { + manifest.require[filename] = r + if !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - couldn't update manifest when getting required resources") + } + } + logger?.info("\(#function, privacy: .public) - end \(filename, privacy: .public)") + } + logger?.info("\(#function, privacy: .public) - completed") + return true } func updateManifestDeclarativeNetRequests(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return false - } - files = getFiles - } else { - files = optionalFilesArray - } - var manifest = getManifest() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - // and getAllFiles always returns the following - let metadata = file["metadata"] as! [String: [String]] - let filename = file["filename"] as! String - let runAt = metadata["run-at"]?[0] ?? "document-end" - // if not a request type, ignore - if runAt != "request" { - continue - } - var update = false - // if filename already in manifest - if !manifest.declarativeNetRequest.contains(filename) { - manifest.declarativeNetRequest.append(filename) - update = true - } - // if filename in another array remove it - for (pattern, filenames) in manifest.match { - for fn in filenames { - if fn == filename, let index = manifest.match[pattern]?.firstIndex(of: filename) { - manifest.match[pattern]?.remove(at: index) - update = true - } - } - } - for (pattern, filenames) in manifest.excludeMatch { - for fn in filenames { - if fn == filename, let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { - manifest.excludeMatch[pattern]?.remove(at: index) - update = true - } - } - } - for (pattern, filenames) in manifest.include { - for fn in filenames { - if fn == filename, let index = manifest.include[pattern]?.firstIndex(of: filename) { - manifest.include[pattern]?.remove(at: index) - update = true - } - } - } - for (pattern, filenames) in manifest.exclude { - for fn in filenames { - if fn == filename, let index = manifest.exclude[pattern]?.firstIndex(of: filename) { - manifest.exclude[pattern]?.remove(at: index) - update = true - } - } - } - if update, !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return false - } - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return false + } + files = getFiles + } else { + files = optionalFilesArray + } + var manifest = getManifest() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + // and getAllFiles always returns the following + let metadata = file["metadata"] as! [String: [String]] + let filename = file["filename"] as! String + let runAt = metadata["run-at"]?[0] ?? "document-end" + // if not a request type, ignore + if runAt != "request" { + continue + } + var update = false + // if filename already in manifest + if !manifest.declarativeNetRequest.contains(filename) { + manifest.declarativeNetRequest.append(filename) + update = true + } + // if filename in another array remove it + for (pattern, filenames) in manifest.match { + for fn in filenames { + if fn == filename, let index = manifest.match[pattern]?.firstIndex(of: filename) { + manifest.match[pattern]?.remove(at: index) + update = true + } + } + } + for (pattern, filenames) in manifest.excludeMatch { + for fn in filenames { + if fn == filename, let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { + manifest.excludeMatch[pattern]?.remove(at: index) + update = true + } + } + } + for (pattern, filenames) in manifest.include { + for fn in filenames { + if fn == filename, let index = manifest.include[pattern]?.firstIndex(of: filename) { + manifest.include[pattern]?.remove(at: index) + update = true + } + } + } + for (pattern, filenames) in manifest.exclude { + for fn in filenames { + if fn == filename, let index = manifest.exclude[pattern]?.firstIndex(of: filename) { + manifest.exclude[pattern]?.remove(at: index) + update = true + } + } + } + if update, !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return false + } + } + logger?.info("\(#function, privacy: .public) - completed") + return true } func purgeManifest(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - // purge all manifest keys of any stale entries - var update = false, manifest = getManifest(), allSaveLocationFilenames = [String]() - // only get all files if files were not provided - var allFiles = [[String: Any]]() - if optionalFilesArray.isEmpty { - // if getAllFiles fails to return, ignore and pass an empty array - let getFiles = getAllFiles() ?? [] - allFiles = getFiles - } else { - allFiles = optionalFilesArray - } - // populate array with filenames - for file in allFiles { - if let filename = file["filename"] as? String { - allSaveLocationFilenames.append(filename) - } - } - // loop through manifest keys, if no file exists for value, remove value from manifest - // if there are no more filenames in pattern, remove pattern from manifest - for (pattern, filenames) in manifest.match { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.match[pattern]?.firstIndex(of: filename) { - manifest.match[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from match pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.match[pattern]?.count { - if length < 1, let ind = manifest.match.index(forKey: pattern) { - manifest.match.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) match pattern, removed from manifest") - } - } - } - for (pattern, filenames) in manifest.excludeMatch { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { - manifest.excludeMatch[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude-match pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.excludeMatch[pattern]?.count { - if length < 1, let ind = manifest.excludeMatch.index(forKey: pattern) { - manifest.excludeMatch.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude-match pattern, removed from manifest") - } - } - } - for (pattern, filenames) in manifest.exclude { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.exclude[pattern]?.firstIndex(of: filename) { - manifest.exclude[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.exclude[pattern]?.count { - if length < 1, let ind = manifest.exclude.index(forKey: pattern) { - manifest.exclude.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") - } - } - } - for (pattern, filenames) in manifest.include { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.include[pattern]?.firstIndex(of: filename) { - manifest.include[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.include[pattern]?.count { - if length < 1, let ind = manifest.include.index(forKey: pattern) { - manifest.include.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") - } - } - } - // loop through manifest required - for (filename, _) in manifest.require { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.require.index(forKey: filename) { - manifest.require.remove(at: index) - // remove associated resources - if !getRequiredCode(filename, [], (filename as NSString).pathExtension) { - logger?.error("\(#function, privacy: .public) - failed to remove required resources when purging \(filename, privacy: .public) from manifest required records") - } - update = true - logger?.info("\(#function, privacy: .public) - No more required resources for \(filename, privacy: .public), removed from manifest along with resource folder") - } - } - } - // loop through manifest disabled - for filename in manifest.disabled { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.disabled.firstIndex(of: filename) { - manifest.disabled.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from disabled") - } - } - } - // loop through manifest declarativeNetRequest - for filename in manifest.declarativeNetRequest { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { - manifest.declarativeNetRequest.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from dNR") - } - } - } - // remove obsolete settings - for setting in manifest.settings { - if !defaultSettings.keys.contains(setting.key) { - manifest.settings.removeValue(forKey: setting.key) - update = true - logger?.info("\(#function, privacy: .public) - Removed obsolete setting - \(setting.key, privacy: .public)") - } - } - if update, !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed to purge manifest") - return false - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + // purge all manifest keys of any stale entries + var update = false, manifest = getManifest(), allSaveLocationFilenames = [String]() + // only get all files if files were not provided + var allFiles = [[String: Any]]() + if optionalFilesArray.isEmpty { + // if getAllFiles fails to return, ignore and pass an empty array + let getFiles = getAllFiles() ?? [] + allFiles = getFiles + } else { + allFiles = optionalFilesArray + } + // populate array with filenames + for file in allFiles { + if let filename = file["filename"] as? String { + allSaveLocationFilenames.append(filename) + } + } + // loop through manifest keys, if no file exists for value, remove value from manifest + // if there are no more filenames in pattern, remove pattern from manifest + for (pattern, filenames) in manifest.match { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.match[pattern]?.firstIndex(of: filename) { + manifest.match[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from match pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.match[pattern]?.count { + if length < 1, let ind = manifest.match.index(forKey: pattern) { + manifest.match.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) match pattern, removed from manifest") + } + } + } + for (pattern, filenames) in manifest.excludeMatch { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { + manifest.excludeMatch[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude-match pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.excludeMatch[pattern]?.count { + if length < 1, let ind = manifest.excludeMatch.index(forKey: pattern) { + manifest.excludeMatch.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude-match pattern, removed from manifest") + } + } + } + for (pattern, filenames) in manifest.exclude { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.exclude[pattern]?.firstIndex(of: filename) { + manifest.exclude[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.exclude[pattern]?.count { + if length < 1, let ind = manifest.exclude.index(forKey: pattern) { + manifest.exclude.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") + } + } + } + for (pattern, filenames) in manifest.include { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.include[pattern]?.firstIndex(of: filename) { + manifest.include[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.include[pattern]?.count { + if length < 1, let ind = manifest.include.index(forKey: pattern) { + manifest.include.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") + } + } + } + // loop through manifest required + for (filename, _) in manifest.require { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.require.index(forKey: filename) { + manifest.require.remove(at: index) + // remove associated resources + if !getRequiredCode(filename, [], (filename as NSString).pathExtension) { + logger?.error("\(#function, privacy: .public) - failed to remove required resources when purging \(filename, privacy: .public) from manifest required records") + } + update = true + logger?.info("\(#function, privacy: .public) - No more required resources for \(filename, privacy: .public), removed from manifest along with resource folder") + } + } + } + // loop through manifest disabled + for filename in manifest.disabled { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.disabled.firstIndex(of: filename) { + manifest.disabled.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from disabled") + } + } + } + // loop through manifest declarativeNetRequest + for filename in manifest.declarativeNetRequest { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { + manifest.declarativeNetRequest.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from dNR") + } + } + } + // remove obsolete settings + for setting in manifest.settings { + if !defaultSettings.keys.contains(setting.key) { + manifest.settings.removeValue(forKey: setting.key) + update = true + logger?.info("\(#function, privacy: .public) - Removed obsolete setting - \(setting.key, privacy: .public)") + } + } + if update, !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed to purge manifest") + return false + } + logger?.info("\(#function, privacy: .public) - completed") + return true } // settings func checkSettings() -> Bool { - // iterate over default settings and individually check if each present - // if missing add setting to manifest about to be returned - // missing keys will occur when new settings introduced - var manifest = getManifest() - var update = false - for (key, value) in defaultSettings { - if manifest.settings[key] == nil { - manifest.settings[key] = value - update = true - } - } - if update, !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed to update manifest settings") - return false - } - return true + // iterate over default settings and individually check if each present + // if missing add setting to manifest about to be returned + // missing keys will occur when new settings introduced + var manifest = getManifest() + var update = false + for (key, value) in defaultSettings { + if manifest.settings[key] == nil { + manifest.settings[key] = value + update = true + } + } + if update, !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed to update manifest settings") + return false + } + return true } func updateSettings(_ settings: [String: String]) -> Bool { - var manifest = getManifest() - manifest.settings = settings - if updateManifest(with: manifest) != true { - logger?.error("\(#function, privacy: .public) - failed to update settings") - return false - } - return true + var manifest = getManifest() + manifest.settings = settings + if updateManifest(with: manifest) != true { + logger?.error("\(#function, privacy: .public) - failed to update settings") + return false + } + return true } // files func getAllFiles(includeCode: Bool = false) -> [[String: Any]]? { - logger?.info("\(#function, privacy: .public) - started") - // returns all files of proper type with filenames, metadata & more - var files = [[String: Any]]() - let fm = FileManager.default - let manifest = getManifest() - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - // get all file urls within save location - guard let urls = try? fm.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - for url in urls { - var fileData = [String: Any]() - // only read contents for css & js files - let filename = url.lastPathComponent - if (!filename.hasSuffix(".css") && !filename.hasSuffix(".js")) { - continue - } - // file will be skipped if metablock is missing - guard - let content = try? String(contentsOf: url, encoding: .utf8), - let dateMod = try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date, - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let type = filename.split(separator: ".").last - else { - logger?.info("\(#function, privacy: .public) - ignoring \(filename, privacy: .public), file missing or metadata missing from file contents") - continue - } - fileData["canUpdate"] = false - fileData["content"] = content - fileData["disabled"] = manifest.disabled.contains(filename) - fileData["filename"] = filename - fileData["lastModified"] = dateToMilliseconds(dateMod) - fileData["metadata"] = metadata - // force unwrap name since parser ensure it exists - fileData["name"] = metadata["name"]![0] - fileData["type"] = "\(type)" - // add extra data if a request userscript - let runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - fileData["request"] = true - } - if metadata["description"] != nil { - fileData["description"] = metadata["description"]![0] - } - if metadata["version"] != nil && metadata["updateURL"] != nil { - fileData["canUpdate"] = true - } - fileData["noframes"] = metadata["noframes"] != nil ? true : false - // if asked, also return the code string - if (includeCode) { - // can force unwrap because always returned from parser - fileData["code"] = parsed["code"] as! String - } - files.append(fileData) - } - logger?.info("\(#function, privacy: .public) - completed") - return files + logger?.info("\(#function, privacy: .public) - started") + // returns all files of proper type with filenames, metadata & more + var files = [[String: Any]]() + let fm = FileManager.default + let manifest = getManifest() + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + // get all file urls within save location + guard let urls = try? fm.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + for url in urls { + var fileData = [String: Any]() + // only read contents for css & js files + let filename = url.lastPathComponent + if (!filename.hasSuffix(".css") && !filename.hasSuffix(".js")) { + continue + } + // file will be skipped if metablock is missing + guard + let content = try? String(contentsOf: url, encoding: .utf8), + let dateMod = try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date, + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let type = filename.split(separator: ".").last + else { + logger?.info("\(#function, privacy: .public) - ignoring \(filename, privacy: .public), file missing or metadata missing from file contents") + continue + } + fileData["canUpdate"] = false + fileData["content"] = content + fileData["disabled"] = manifest.disabled.contains(filename) + fileData["filename"] = filename + fileData["lastModified"] = dateToMilliseconds(dateMod) + fileData["metadata"] = metadata + // force unwrap name since parser ensure it exists + fileData["name"] = metadata["name"]![0] + fileData["type"] = "\(type)" + // add extra data if a request userscript + let runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + fileData["request"] = true + } + if metadata["description"] != nil { + fileData["description"] = metadata["description"]![0] + } + if metadata["version"] != nil && metadata["updateURL"] != nil { + fileData["canUpdate"] = true + } + fileData["noframes"] = metadata["noframes"] != nil ? true : false + // if asked, also return the code string + if (includeCode) { + // can force unwrap because always returned from parser + fileData["code"] = parsed["code"] as! String + } + files.append(fileData) + } + logger?.info("\(#function, privacy: .public) - completed") + return files } func getRequiredCode(_ filename: String, _ resources: [String], _ fileType: String) -> Bool { - let directory = getRequireLocation().appendingPathComponent(filename) - // if file requires no resource but directory exists, remove it - // this resource is not user-generated and can be downloaded again, so just remove it instead of moves to the trash - // also in ios, the volume “Data” has no trash and item can only be removed directly - if resources.isEmpty { - if FileManager.default.fileExists(atPath: directory.path) { - do { - try FileManager.default.removeItem(at: directory) - } catch { - logger?.error("\(#function, privacy: .public) - failed to remove directory: \(error.localizedDescription, privacy: .public)") - } - } - return true - } - // record URLs for subsequent processing - var resourceUrls = Set() - // loop through resource urls and attempt to fetch it - for resourceUrlString in resources { - // get the path of the url string - guard let resourceUrlPath = URLComponents(string: resourceUrlString)?.path else { - // if path can not be obtained, skip and log - logger?.info("\(#function, privacy: .public) - failed to get path on \(filename, privacy: .public) for \(resourceUrlString, privacy: .public)") - continue - } - // skip urls pointing to files of different types - if resourceUrlPath.hasSuffix(fileType) { - let resourceFilename = sanitize(resourceUrlString) - let fileURL = directory.appendingPathComponent(resourceFilename) - // insert url to resolve symlink into set - resourceUrls.insert(fileURL.standardizedFileURL) - // only attempt to get resource if it does not yet exist - if FileManager.default.fileExists(atPath: fileURL.path) {continue} - // get the remote file contents - guard let contents = getRemoteFileContents(resourceUrlString) else {continue} - // check if file specific folder exists at requires directory - if !FileManager.default.fileExists(atPath: directory.path) { - guard ((try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)) != nil) else { - logger?.info("\(#function, privacy: .public) - failed to create required code directory for \(filename, privacy: .public)") - return false - } - } - // finally write file to directory - guard ((try? contents.write(to: fileURL, atomically: false, encoding: .utf8)) != nil) else { - logger?.info("\(#function, privacy: .public) - failed to write content to file for \(filename, privacy: .public) from \(resourceUrlString, privacy: .public)") - return false - } - } - } - // cleanup downloaded files that are no longer required - do { - var downloadedUrls = Set() - // get all downloaded resources url - let fileUrls = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: []) - // insert url to resolve symlink into set - for url in fileUrls { downloadedUrls.insert(url.standardizedFileURL) } - // exclude currently required resources - let abandonedUrls = downloadedUrls.subtracting(resourceUrls) - // loop through abandoned urls and attempt to remove it - for abandonFileUrl in abandonedUrls { - do { - try FileManager.default.removeItem(at: abandonFileUrl) - logger?.info("\(#function, privacy: .public) - cleanup abandoned resource: \(unsanitize(abandonFileUrl.lastPathComponent), privacy: .public)") - } catch { - logger?.error("\(#function, privacy: .public) - failed to remove abandoned resource: \(error.localizedDescription, privacy: .public)") - } - } - } catch { - logger?.error("\(#function, privacy: .public) - failed to cleanup resources: \(error.localizedDescription, privacy: .public)") - } - return true + let directory = getRequireLocation().appendingPathComponent(filename) + // if file requires no resource but directory exists, remove it + // this resource is not user-generated and can be downloaded again, so just remove it instead of moves to the trash + // also in ios, the volume “Data” has no trash and item can only be removed directly + if resources.isEmpty { + if FileManager.default.fileExists(atPath: directory.path) { + do { + try FileManager.default.removeItem(at: directory) + } catch { + logger?.error("\(#function, privacy: .public) - failed to remove directory: \(error.localizedDescription, privacy: .public)") + } + } + return true + } + // record URLs for subsequent processing + var resourceUrls = Set() + // loop through resource urls and attempt to fetch it + for resourceUrlString in resources { + // get the path of the url string + guard let resourceUrlPath = URLComponents(string: resourceUrlString)?.path else { + // if path can not be obtained, skip and log + logger?.info("\(#function, privacy: .public) - failed to get path on \(filename, privacy: .public) for \(resourceUrlString, privacy: .public)") + continue + } + // skip urls pointing to files of different types + if resourceUrlPath.hasSuffix(fileType) { + let resourceFilename = sanitize(resourceUrlString) + let fileURL = directory.appendingPathComponent(resourceFilename) + // insert url to resolve symlink into set + resourceUrls.insert(fileURL.standardizedFileURL) + // only attempt to get resource if it does not yet exist + if FileManager.default.fileExists(atPath: fileURL.path) {continue} + // get the remote file contents + guard let contents = getRemoteFileContents(resourceUrlString) else {continue} + // check if file specific folder exists at requires directory + if !FileManager.default.fileExists(atPath: directory.path) { + guard ((try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)) != nil) else { + logger?.info("\(#function, privacy: .public) - failed to create required code directory for \(filename, privacy: .public)") + return false + } + } + // finally write file to directory + guard ((try? contents.write(to: fileURL, atomically: false, encoding: .utf8)) != nil) else { + logger?.info("\(#function, privacy: .public) - failed to write content to file for \(filename, privacy: .public) from \(resourceUrlString, privacy: .public)") + return false + } + } + } + // cleanup downloaded files that are no longer required + do { + var downloadedUrls = Set() + // get all downloaded resources url + let fileUrls = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: []) + // insert url to resolve symlink into set + for url in fileUrls { downloadedUrls.insert(url.standardizedFileURL) } + // exclude currently required resources + let abandonedUrls = downloadedUrls.subtracting(resourceUrls) + // loop through abandoned urls and attempt to remove it + for abandonFileUrl in abandonedUrls { + do { + try FileManager.default.removeItem(at: abandonFileUrl) + logger?.info("\(#function, privacy: .public) - cleanup abandoned resource: \(unsanitize(abandonFileUrl.lastPathComponent), privacy: .public)") + } catch { + logger?.error("\(#function, privacy: .public) - failed to remove abandoned resource: \(error.localizedDescription, privacy: .public)") + } + } + } catch { + logger?.error("\(#function, privacy: .public) - failed to cleanup resources: \(error.localizedDescription, privacy: .public)") + } + return true } func checkForRemoteUpdates(_ optionalFilesArray: [[String: Any]] = []) -> [[String: String]]? { - // only get all files if files were not provided - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - files = getFiles - } else { - files = optionalFilesArray - } - - var hasUpdates = [[String: String]]() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let filename = file["filename"] as! String - let canUpdate = file["canUpdate"] as! Bool - let metadata = file["metadata"] as! [String: [String]] - let type = file["type"] as! String - let name = metadata["name"]![0] - logger?.info("\(#function, privacy: .public) - Checking for remote updates for \(filename, privacy: .public)") - if canUpdate { - let currentVersion = metadata["version"]![0] - let updateUrl = metadata["updateURL"]![0] - // before fetching remote contents, ensure it points to a file of the same type - if !updateUrl.hasSuffix(type) {continue} - guard - let remoteFileContents = getRemoteFileContents(updateUrl), - let remoteFileContentsParsed = parse(remoteFileContents), - let remoteMetadata = remoteFileContentsParsed["metadata"] as? [String: [String]], - let remoteVersion = remoteMetadata["version"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed to parse remote file contents") - return nil - } - let remoteVersionNewer = isVersionNewer(currentVersion, remoteVersion) - if remoteVersionNewer { - hasUpdates.append(["name": name, "filename": filename, "type": type, "url": updateUrl]) - } - } - } - logger?.info("\(#function, privacy: .public) - Finished checking for remote updates for \(files.count, privacy: .public) files") - return hasUpdates + // only get all files if files were not provided + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + files = getFiles + } else { + files = optionalFilesArray + } + + var hasUpdates = [[String: String]]() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let filename = file["filename"] as! String + let canUpdate = file["canUpdate"] as! Bool + let metadata = file["metadata"] as! [String: [String]] + let type = file["type"] as! String + let name = metadata["name"]![0] + logger?.info("\(#function, privacy: .public) - Checking for remote updates for \(filename, privacy: .public)") + if canUpdate { + let currentVersion = metadata["version"]![0] + let updateUrl = metadata["updateURL"]![0] + // before fetching remote contents, ensure it points to a file of the same type + if !updateUrl.hasSuffix(type) {continue} + guard + let remoteFileContents = getRemoteFileContents(updateUrl), + let remoteFileContentsParsed = parse(remoteFileContents), + let remoteMetadata = remoteFileContentsParsed["metadata"] as? [String: [String]], + let remoteVersion = remoteMetadata["version"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed to parse remote file contents") + return nil + } + let remoteVersionNewer = isVersionNewer(currentVersion, remoteVersion) + if remoteVersionNewer { + hasUpdates.append(["name": name, "filename": filename, "type": type, "url": updateUrl]) + } + } + } + logger?.info("\(#function, privacy: .public) - Finished checking for remote updates for \(files.count, privacy: .public) files") + return hasUpdates } func getRemoteFileContents(_ url: String) -> String? { - logger?.info("\(#function, privacy: .public) - started for \(url, privacy: .public)") - // if url is http change to https - var urlChecked = url - if urlChecked.hasPrefix("http:") { - urlChecked = urlChecked.replacingOccurrences(of: "http:", with: "https:") - logger?.info("\(#function, privacy: .public) - \(url, privacy: .public) is using insecure http, attempt to fetch remote content with https") - } - // convert url string to url - guard let solidURL = fixedURL(string: urlChecked) else { - logger?.error("\(#function, privacy: .public) - failed at (1), invalid URL: \(url, privacy: .public)") - return nil - } - var contents = "" - // get remote file contents, synchronously - let semaphore = DispatchSemaphore(value: 0) - var task: URLSessionDataTask? - task = URLSession.shared.dataTask(with: solidURL) { data, response, error in - if let r = response as? HTTPURLResponse, data != nil, error == nil { - if r.statusCode == 200 { - contents = String(data: data!, encoding: .utf8) ?? "" - } else { - logger?.error("\(#function, privacy: .public) - http statusCode (\(r.statusCode, privacy: .public)): \(url, privacy: .public)") - } - } - if let error = error { - logger?.error("\(#function, privacy: .public) - task error: \(error.localizedDescription, privacy: .public) (\(url, privacy: .public))") - } - semaphore.signal() - } - task?.resume() - // wait 30 seconds before timing out - if semaphore.wait(timeout: .now() + 30) == .timedOut { - task?.cancel() - logger?.error("\(#function, privacy: .public) - 30 seconds timeout: \(url, privacy: .public)") - } - - // if made it to this point and contents still an empty string, something went wrong with the request - if contents.isEmpty { - logger?.error("\(#function, privacy: .public) - failed at (2), contents empty: \(url, privacy: .public)") - return nil - } - logger?.info("\(#function, privacy: .public) - completed for \(url, privacy: .public)") - return contents + logger?.info("\(#function, privacy: .public) - started for \(url, privacy: .public)") + // if url is http change to https + var urlChecked = url + if urlChecked.hasPrefix("http:") { + urlChecked = urlChecked.replacingOccurrences(of: "http:", with: "https:") + logger?.info("\(#function, privacy: .public) - \(url, privacy: .public) is using insecure http, attempt to fetch remote content with https") + } + // convert url string to url + guard let solidURL = fixedURL(string: urlChecked) else { + logger?.error("\(#function, privacy: .public) - failed at (1), invalid URL: \(url, privacy: .public)") + return nil + } + var contents = "" + // get remote file contents, synchronously + let semaphore = DispatchSemaphore(value: 0) + var task: URLSessionDataTask? + task = URLSession.shared.dataTask(with: solidURL) { data, response, error in + if let r = response as? HTTPURLResponse, data != nil, error == nil { + if r.statusCode == 200 { + contents = String(data: data!, encoding: .utf8) ?? "" + } else { + logger?.error("\(#function, privacy: .public) - http statusCode (\(r.statusCode, privacy: .public)): \(url, privacy: .public)") + } + } + if let error = error { + logger?.error("\(#function, privacy: .public) - task error: \(error.localizedDescription, privacy: .public) (\(url, privacy: .public))") + } + semaphore.signal() + } + task?.resume() + // wait 30 seconds before timing out + if semaphore.wait(timeout: .now() + 30) == .timedOut { + task?.cancel() + logger?.error("\(#function, privacy: .public) - 30 seconds timeout: \(url, privacy: .public)") + } + + // if made it to this point and contents still an empty string, something went wrong with the request + if contents.isEmpty { + logger?.error("\(#function, privacy: .public) - failed at (2), contents empty: \(url, privacy: .public)") + return nil + } + logger?.info("\(#function, privacy: .public) - completed for \(url, privacy: .public)") + return contents } func updateAllFiles(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - // get names of all files with updates available - guard - let filesWithUpdates = checkForRemoteUpdates(optionalFilesArray), - let saveLocation = getSaveLocation() - else { - logger?.error("\(#function, privacy: .public) - failed to update files (1)") - return false - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - for file in filesWithUpdates { - // can be force unwrapped because checkForRemoteUpdates didn't return nil - let filename = file["filename"]! - let fileUrl = saveLocation.appendingPathComponent(filename) - guard - let content = try? String(contentsOf: fileUrl, encoding: .utf8), - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let updateUrl = metadata["updateURL"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed to update files (2)") - continue - } - let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl - guard - let remoteFileContents = getRemoteFileContents(downloadUrl), - ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) - else { - logger?.error("\(#function, privacy: .public) - failed to update files (3)") - continue - } - logger?.info("\(#function, privacy: .public) - updated \(filename, privacy: .public) with contents fetched from \(downloadUrl, privacy: .public)") - } - return true + // get names of all files with updates available + guard + let filesWithUpdates = checkForRemoteUpdates(optionalFilesArray), + let saveLocation = getSaveLocation() + else { + logger?.error("\(#function, privacy: .public) - failed to update files (1)") + return false + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + for file in filesWithUpdates { + // can be force unwrapped because checkForRemoteUpdates didn't return nil + let filename = file["filename"]! + let fileUrl = saveLocation.appendingPathComponent(filename) + guard + let content = try? String(contentsOf: fileUrl, encoding: .utf8), + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let updateUrl = metadata["updateURL"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed to update files (2)") + continue + } + let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl + guard + let remoteFileContents = getRemoteFileContents(downloadUrl), + ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) + else { + logger?.error("\(#function, privacy: .public) - failed to update files (3)") + continue + } + logger?.info("\(#function, privacy: .public) - updated \(filename, privacy: .public) with contents fetched from \(downloadUrl, privacy: .public)") + } + return true } func toggleFile(_ filename: String,_ action: String) -> Bool { - // if file doesn't exist return false - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return false - } - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - let path = saveLocation.appendingPathComponent(filename).path - if !FileManager.default.fileExists(atPath: path) { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return false - } - var manifest = getManifest() - // if file is already disabled - if action == "disable" && manifest.disabled.contains(filename) || action == "enabled" && !manifest.disabled.contains(filename) { - return true - } - // add filename to disabled array if disabling - if (action == "disable") {manifest.disabled.append(filename)} - // remove filename from disabled array if enabling - if (action == "enable") { - guard let index = manifest.disabled.firstIndex(of: filename) else { - logger?.error("\(#function, privacy: .public) - failed at (3)") - return false - } - manifest.disabled.remove(at: index) - } - if !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed at (4)") - return false - } - return true + // if file doesn't exist return false + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return false + } + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + let path = saveLocation.appendingPathComponent(filename).path + if !FileManager.default.fileExists(atPath: path) { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return false + } + var manifest = getManifest() + // if file is already disabled + if action == "disable" && manifest.disabled.contains(filename) || action == "enabled" && !manifest.disabled.contains(filename) { + return true + } + // add filename to disabled array if disabling + if (action == "disable") {manifest.disabled.append(filename)} + // remove filename from disabled array if enabling + if (action == "enable") { + guard let index = manifest.disabled.firstIndex(of: filename) else { + logger?.error("\(#function, privacy: .public) - failed at (3)") + return false + } + manifest.disabled.remove(at: index) + } + if !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed at (4)") + return false + } + return true } func checkDefaultDirectories() -> Bool { - let defaultSaveLocation = getDocumentsDirectory().appendingPathComponent("scripts") - let requireLocation = getRequireLocation() - let urls = [defaultSaveLocation, requireLocation] - for url in urls { - if !FileManager.default.fileExists(atPath: url.path) { - do { - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) - } catch { - // could not create the save location directory, show error - logger?.error("\(#function, privacy: .public) - failed at (1) - \(url, privacy: .public) - \(error.localizedDescription, privacy: .public)") - return false - } - } - } - return true + let defaultSaveLocation = getDocumentsDirectory().appendingPathComponent("scripts") + let requireLocation = getRequireLocation() + let urls = [defaultSaveLocation, requireLocation] + for url in urls { + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + } catch { + // could not create the save location directory, show error + logger?.error("\(#function, privacy: .public) - failed at (1) - \(url, privacy: .public) - \(error.localizedDescription, privacy: .public)") + return false + } + } + } + return true } // matching func stringToRegex(_ stringPattern: String) -> NSRegularExpression? { - let pattern = #"[\.|\?|\^|\$|\+|\{|\}|\[|\]|\||\\(|\)|\/]"# - var patternReplace = "^\(stringPattern.replacingOccurrences(of: pattern, with: #"\\$0"#, options: .regularExpression))$" - patternReplace = patternReplace.replacingOccurrences(of: "*", with: ".*") - guard let regex = try? NSRegularExpression(pattern: patternReplace, options: .caseInsensitive) else { - return nil - } - return regex + let pattern = #"[\.|\?|\^|\$|\+|\{|\}|\[|\]|\||\\(|\)|\/]"# + var patternReplace = "^\(stringPattern.replacingOccurrences(of: pattern, with: #"\\$0"#, options: .regularExpression))$" + patternReplace = patternReplace.replacingOccurrences(of: "*", with: ".*") + guard let regex = try? NSRegularExpression(pattern: patternReplace, options: .caseInsensitive) else { + return nil + } + return regex } func match(_ url: String, _ matchPattern: String) -> Bool { - guard - let parts = jsLikeURL(url), - let ptcl = parts["protocol"], - let host = parts["hostname"], - var path = parts["pathname"] - else { - logger?.error("\(#function, privacy: .public) - invalid url \(url, privacy: .public)") - return false - } - - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#path - // The value for the path matches against the string which is the URL path plus the URL query string - if let search = parts["search"], search.count > 0 { - path += search - } - - // matchPattern is the value from metatdata key @match or @exclude-match - if (matchPattern == "") { - return true - } - // currently only http/s supported - if (ptcl != "http:" && ptcl != "https:") { - return false - } - let partsPattern = #"^(http:|https:|\*:)\/\/((?:\*\.)?(?:[a-z0-9-]+\.)+(?:[a-z0-9]+)|\*\.[a-z]+|\*|[a-z0-9]+)(\/[^\s]*)$"# - let partsPatternReg = try! NSRegularExpression(pattern: partsPattern, options: .caseInsensitive) - let range = NSMakeRange(0, matchPattern.utf16.count) - guard let parts = partsPatternReg.firstMatch(in: matchPattern, options: [], range: range) else { - logger?.error("\(#function, privacy: .public) - malformed regex match pattern") - return false - } - // ensure url protocol matches pattern protocol - let protocolPattern = matchPattern[Range(parts.range(at: 1), in: matchPattern)!] - if (protocolPattern != "*:" && ptcl != protocolPattern) { - return false - } - // construct host regex from matchPattern - let matchPatternHost = matchPattern[Range(parts.range(at: 2), in: matchPattern)!] - var hostPattern = "^\(matchPatternHost.replacingOccurrences(of: ".", with: "\\."))$" - hostPattern = hostPattern.replacingOccurrences(of: "^*$", with: ".*") - hostPattern = hostPattern.replacingOccurrences(of: "*\\.", with: "(.*\\.)?") - guard let hostRegEx = try? NSRegularExpression(pattern: hostPattern, options: .caseInsensitive) else { - logger?.error("\(#function, privacy: .public) - invalid host regex") - return false - } - // construct path regex from matchPattern - let matchPatternPath = matchPattern[Range(parts.range(at: 3), in: matchPattern)!] - guard let pathRegEx = stringToRegex(String(matchPatternPath)) else { - logger?.error("\(#function, privacy: .public) - invalid path regex") - return false - } - guard - (hostRegEx.firstMatch(in: host, options: [], range: NSMakeRange(0, host.utf16.count)) != nil), - (pathRegEx.firstMatch(in: path, options: [], range: NSMakeRange(0, path.utf16.count)) != nil) - else { - return false - } - - return true + guard + let parts = jsLikeURL(url), + let ptcl = parts["protocol"], + let host = parts["hostname"], + var path = parts["pathname"] + else { + logger?.error("\(#function, privacy: .public) - invalid url \(url, privacy: .public)") + return false + } + + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#path + // The value for the path matches against the string which is the URL path plus the URL query string + if let search = parts["search"], search.count > 0 { + path += search + } + + // matchPattern is the value from metatdata key @match or @exclude-match + if (matchPattern == "") { + return true + } + // currently only http/s supported + if (ptcl != "http:" && ptcl != "https:") { + return false + } + let partsPattern = #"^(http:|https:|\*:)\/\/((?:\*\.)?(?:[a-z0-9-]+\.)+(?:[a-z0-9]+)|\*\.[a-z]+|\*|[a-z0-9]+)(\/[^\s]*)$"# + let partsPatternReg = try! NSRegularExpression(pattern: partsPattern, options: .caseInsensitive) + let range = NSMakeRange(0, matchPattern.utf16.count) + guard let parts = partsPatternReg.firstMatch(in: matchPattern, options: [], range: range) else { + logger?.error("\(#function, privacy: .public) - malformed regex match pattern") + return false + } + // ensure url protocol matches pattern protocol + let protocolPattern = matchPattern[Range(parts.range(at: 1), in: matchPattern)!] + if (protocolPattern != "*:" && ptcl != protocolPattern) { + return false + } + // construct host regex from matchPattern + let matchPatternHost = matchPattern[Range(parts.range(at: 2), in: matchPattern)!] + var hostPattern = "^\(matchPatternHost.replacingOccurrences(of: ".", with: "\\."))$" + hostPattern = hostPattern.replacingOccurrences(of: "^*$", with: ".*") + hostPattern = hostPattern.replacingOccurrences(of: "*\\.", with: "(.*\\.)?") + guard let hostRegEx = try? NSRegularExpression(pattern: hostPattern, options: .caseInsensitive) else { + logger?.error("\(#function, privacy: .public) - invalid host regex") + return false + } + // construct path regex from matchPattern + let matchPatternPath = matchPattern[Range(parts.range(at: 3), in: matchPattern)!] + guard let pathRegEx = stringToRegex(String(matchPatternPath)) else { + logger?.error("\(#function, privacy: .public) - invalid path regex") + return false + } + guard + (hostRegEx.firstMatch(in: host, options: [], range: NSMakeRange(0, host.utf16.count)) != nil), + (pathRegEx.firstMatch(in: path, options: [], range: NSMakeRange(0, path.utf16.count)) != nil) + else { + return false + } + + return true } func include(_ url: String,_ pattern: String) -> Bool { - var regex:NSRegularExpression - if pattern.hasPrefix("/") && pattern.hasSuffix("/") { - let p = String(pattern.dropFirst().dropLast()) - guard let exp = try? NSRegularExpression(pattern: p, options: .caseInsensitive) else { - logger?.error("\(#function, privacy: .public) - invalid regex") - return false - } - regex = exp - } else { - guard let exp = stringToRegex(pattern) else { - logger?.error("\(#function, privacy: .public) - coudn't convert string to regex") - return false - } - regex = exp - } - if (regex.firstMatch(in: url, options: [], range: NSMakeRange(0, url.utf16.count)) == nil) { - return false - } - return true + var regex:NSRegularExpression + if pattern.hasPrefix("/") && pattern.hasSuffix("/") { + let p = String(pattern.dropFirst().dropLast()) + guard let exp = try? NSRegularExpression(pattern: p, options: .caseInsensitive) else { + logger?.error("\(#function, privacy: .public) - invalid regex") + return false + } + regex = exp + } else { + guard let exp = stringToRegex(pattern) else { + logger?.error("\(#function, privacy: .public) - coudn't convert string to regex") + return false + } + regex = exp + } + if (regex.firstMatch(in: url, options: [], range: NSMakeRange(0, url.utf16.count)) == nil) { + return false + } + return true } func getMatchedFiles(_ url: String, _ optionalManifest: Manifest?, _ checkBlocklist: Bool) -> [String] { - logger?.info("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .private(mask: .hash))") - // logger?.debug("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .public)") - let manifest = optionalManifest ?? getManifest() - - // filenames that should not load for the passed url - // the manifest values from @exclude and @exclude-match populate this set - var excludedFilenames: Set = [] - // filenames that should load for the passed url - // the manifest values from @include and @match populate this set - var matchedFilenames: Set = [] - // all exclude-match patterns from manifest - let excludeMatchPatterns = manifest.excludeMatch.keys - // all match patterns from manifest - let matchPatterns = manifest.match.keys - // all include expressions from manifest - let includeExpressions = manifest.include.keys - // all exclude expressions from manifest - let excludeExpressions = manifest.exclude.keys - - // if url matches a pattern in blocklist, no injection for this url - if (checkBlocklist) { - for pattern in manifest.blacklist { - if match(url, pattern) { - // return empty array - return Array(matchedFilenames) - } - } - } - - // loop through all the @exclude-match patterns - // if any match passed url, push all filenames to excludedFilenames set - for pattern in excludeMatchPatterns { - if match(url, pattern) { - guard let filenames = manifest.excludeMatch[pattern] else { - logger?.error("\(#function, privacy: .public) - failed at (2) for \(pattern, privacy: .public)") - continue - } - excludedFilenames = excludedFilenames.union(filenames) - } - } - for exp in excludeExpressions { - if include(url, exp) { - guard let filenames = manifest.exclude[exp] else { - logger?.error("\(#function, privacy: .public) - failed at (3) for \(exp, privacy: .public)") - continue - } - excludedFilenames = excludedFilenames.union(filenames) - } - } - for pattern in matchPatterns { - if match(url, pattern) { - guard let filenames = manifest.match[pattern] else { - logger?.error("\(#function, privacy: .public) - failed at (4) for \(pattern, privacy: .public)") - continue - } - matchedFilenames = matchedFilenames.union(filenames) - } - } - for exp in includeExpressions { - if include(url, exp) { - guard let filenames = manifest.include[exp] else { - logger?.error("\(#function, privacy: .public) - failed at (5) for \(exp, privacy: .public)") - continue - } - matchedFilenames = matchedFilenames.union(filenames) - } - } - matchedFilenames = matchedFilenames.subtracting(excludedFilenames) - logger?.info("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .private(mask: .hash))") - // logger?.debug("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .public)") - return Array(matchedFilenames) + logger?.info("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .private(mask: .hash))") + // logger?.debug("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .public)") + let manifest = optionalManifest ?? getManifest() + + // filenames that should not load for the passed url + // the manifest values from @exclude and @exclude-match populate this set + var excludedFilenames: Set = [] + // filenames that should load for the passed url + // the manifest values from @include and @match populate this set + var matchedFilenames: Set = [] + // all exclude-match patterns from manifest + let excludeMatchPatterns = manifest.excludeMatch.keys + // all match patterns from manifest + let matchPatterns = manifest.match.keys + // all include expressions from manifest + let includeExpressions = manifest.include.keys + // all exclude expressions from manifest + let excludeExpressions = manifest.exclude.keys + + // if url matches a pattern in blocklist, no injection for this url + if (checkBlocklist) { + for pattern in manifest.blacklist { + if match(url, pattern) { + // return empty array + return Array(matchedFilenames) + } + } + } + + // loop through all the @exclude-match patterns + // if any match passed url, push all filenames to excludedFilenames set + for pattern in excludeMatchPatterns { + if match(url, pattern) { + guard let filenames = manifest.excludeMatch[pattern] else { + logger?.error("\(#function, privacy: .public) - failed at (2) for \(pattern, privacy: .public)") + continue + } + excludedFilenames = excludedFilenames.union(filenames) + } + } + for exp in excludeExpressions { + if include(url, exp) { + guard let filenames = manifest.exclude[exp] else { + logger?.error("\(#function, privacy: .public) - failed at (3) for \(exp, privacy: .public)") + continue + } + excludedFilenames = excludedFilenames.union(filenames) + } + } + for pattern in matchPatterns { + if match(url, pattern) { + guard let filenames = manifest.match[pattern] else { + logger?.error("\(#function, privacy: .public) - failed at (4) for \(pattern, privacy: .public)") + continue + } + matchedFilenames = matchedFilenames.union(filenames) + } + } + for exp in includeExpressions { + if include(url, exp) { + guard let filenames = manifest.include[exp] else { + logger?.error("\(#function, privacy: .public) - failed at (5) for \(exp, privacy: .public)") + continue + } + matchedFilenames = matchedFilenames.union(filenames) + } + } + matchedFilenames = matchedFilenames.subtracting(excludedFilenames) + logger?.info("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .private(mask: .hash))") + // logger?.debug("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .public)") + return Array(matchedFilenames) } // injection func getCode(_ filenames: [String], _ isTop: Bool)-> [String: Any]? { - var cssFiles = [Any]() - var jsFiles = [Any]() - var menuFiles = [Any]() - - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - - for filename in filenames { - guard - let contents = getFileContentsParsed(saveLocation.appendingPathComponent(filename)), - var code = contents["code"] as? String, - let type = filename.split(separator: ".").last - else { - // if guard fails, log error continue to next file - logger?.error("\(#function, privacy: .public) - failed at (2) for \(filename, privacy: .public)") - continue - } - // can force unwrap b/c getFileContentsParsed ensures metadata exists - let metadata = contents["metadata"] as! [String: [String]] - let name = metadata["name"]![0] - - // if metadata has noframes option and the url is not the top window, don't load - if (metadata["noframes"] != nil && !isTop) { - continue - } - - // get run-at values and set default if missing - // if type request, ignore - var runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - continue - } - - // normalize weight - var weight = metadata["weight"]?[0] ?? "1" - weight = normalizeWeight(weight) - - // get inject-into and set default if missing - var injectInto = metadata["inject-into"]?[0] ?? "auto" - let injectVals: Set = ["auto", "content", "page"] - let runAtVals: Set = ["context-menu", "document-start", "document-end", "document-idle"] - let validGrants: Set = [ - "GM.info", - "GM_info", - "GM.addStyle", - "GM.openInTab", - "GM.closeTab", - "GM.setValue", - "GM.getValue", - "GM.deleteValue", - "GM.listValues", - "GM.setClipboard", - "GM.getTab", - "GM.saveTab", - "GM_xmlhttpRequest", - "GM.xmlHttpRequest" - ] - // if either is invalid use default value - if !injectVals.contains(injectInto) { - injectInto = "auto" - } - if !runAtVals.contains(runAt) { - runAt = "document-end" - } - - // attempt to get all @grant value - var grants = metadata["grant"] ?? [] - // remove duplicates, if any exist - if !grants.isEmpty { - grants = Array(Set(grants)) - } - - // filter out grant values that are not in validGrant set - grants = grants.filter{validGrants.contains($0)} - - // set GM.info data - let description = metadata["description"]?[0] ?? "" - let excludes = metadata["exclude"] ?? [] - let excludeMatches = metadata["exclude-match"] ?? [] - let icon = metadata["icon"]?[0] ?? "" - let includes = metadata["include"] ?? [] - let matches = metadata["match"] ?? [] - let requires = metadata["require"] ?? [] - let version = metadata["version"]?[0] ?? "" - let noframes = metadata["noframes"] != nil ? true : false - var scriptObject:[String: Any] = [ - "description": description, - "excludes": excludes, - "exclude-match": excludeMatches, - "filename": filename, - "grant": grants, - "icon": icon, - "includes": includes, - "inject-into": injectInto, - "matches": matches, - "name": name, - "noframes": noframes, - "namespace": "", - "resources": "", - "require": requires, - "run-at": runAt, - "version": version - ] - // certain metadata keys use a different key name then the actual key name - // for compatibility keeping this when applicable, although the rationale is not clear to me - // for unique keys passed to scriptObject, using the same key name that is present in actual userscript - // this key map is used to check for existence of keys in next loop - let keyMap = [ - "exclude": "excludes", - "include": "includes", - "match": "matches", - "resource": "resources", - ] - for metaline in metadata { - let key = keyMap[metaline.key] ?? metaline.key - if !scriptObject.keys.contains(key) { - let value = metaline.value - // metalines without values aren't included in parsed metadata object - // the only exception is @noframes - scriptObject[key] = !value.isEmpty ? value : value[0] - } - } - let scriptMetaStr = contents["metablock"] as? String ?? "??" - - // attempt to get require resource from disk - // if required resource is inaccessible, log error and continue - if let required = metadata["require"] { - // reverse required metadata - // if required is ["A", "B", "C"], C gets added above B which is above A, etc.. - // the reverse of that is desired - for require in required.reversed() { - let sanitizedName = sanitize(require) - let requiredFileURL = getRequireLocation().appendingPathComponent(filename).appendingPathComponent(sanitizedName) - if let requiredContent = try? String(contentsOf: requiredFileURL, encoding: .utf8) { - code = "\(requiredContent)\n\(code)" - } else { - logger?.error("\(#function, privacy: .public) - failed at (3) for \(require, privacy: .public)") - } - } - } - - if type == "css" { - cssFiles.append([ - "code": code, - "filename": filename, - "name": name, - "type": "css", - "weight": weight - ]) - } else if type == "js" { - if runAt == "context-menu" { - #if os(macOS) - menuFiles.append([ - "code": code, - "scriptMetaStr": scriptMetaStr, - "scriptObject": scriptObject, - "type": "js", - "weight": weight - ]) - #endif - } else { - jsFiles.append([ - "code": code, - "scriptMetaStr": scriptMetaStr, - "scriptObject": scriptObject, - "type": "js", - "weight": weight - ]) - } - } - } - let resp = [ - "files": ["css": cssFiles, "js": jsFiles, "menu": menuFiles], - "scriptHandler": "Userscripts", - "scriptHandlerVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" - ] as [String : Any] - return resp + var cssFiles = [Any]() + var jsFiles = [Any]() + var menuFiles = [Any]() + + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + + for filename in filenames { + guard + let contents = getFileContentsParsed(saveLocation.appendingPathComponent(filename)), + var code = contents["code"] as? String, + let type = filename.split(separator: ".").last + else { + // if guard fails, log error continue to next file + logger?.error("\(#function, privacy: .public) - failed at (2) for \(filename, privacy: .public)") + continue + } + // can force unwrap b/c getFileContentsParsed ensures metadata exists + let metadata = contents["metadata"] as! [String: [String]] + let name = metadata["name"]![0] + + // if metadata has noframes option and the url is not the top window, don't load + if (metadata["noframes"] != nil && !isTop) { + continue + } + + // get run-at values and set default if missing + // if type request, ignore + var runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + continue + } + + // normalize weight + var weight = metadata["weight"]?[0] ?? "1" + weight = normalizeWeight(weight) + + // get inject-into and set default if missing + var injectInto = metadata["inject-into"]?[0] ?? "auto" + let injectVals: Set = ["auto", "content", "page"] + let runAtVals: Set = ["context-menu", "document-start", "document-end", "document-idle"] + let validGrants: Set = [ + "GM.info", + "GM_info", + "GM.addStyle", + "GM.openInTab", + "GM.closeTab", + "GM.setValue", + "GM.getValue", + "GM.deleteValue", + "GM.listValues", + "GM.setClipboard", + "GM.getTab", + "GM.saveTab", + "GM_xmlhttpRequest", + "GM.xmlHttpRequest" + ] + // if either is invalid use default value + if !injectVals.contains(injectInto) { + injectInto = "auto" + } + if !runAtVals.contains(runAt) { + runAt = "document-end" + } + + // attempt to get all @grant value + var grants = metadata["grant"] ?? [] + // remove duplicates, if any exist + if !grants.isEmpty { + grants = Array(Set(grants)) + } + + // filter out grant values that are not in validGrant set + grants = grants.filter{validGrants.contains($0)} + + // set GM.info data + let description = metadata["description"]?[0] ?? "" + let excludes = metadata["exclude"] ?? [] + let excludeMatches = metadata["exclude-match"] ?? [] + let icon = metadata["icon"]?[0] ?? "" + let includes = metadata["include"] ?? [] + let matches = metadata["match"] ?? [] + let requires = metadata["require"] ?? [] + let version = metadata["version"]?[0] ?? "" + let noframes = metadata["noframes"] != nil ? true : false + var scriptObject:[String: Any] = [ + "description": description, + "excludes": excludes, + "exclude-match": excludeMatches, + "filename": filename, + "grant": grants, + "icon": icon, + "includes": includes, + "inject-into": injectInto, + "matches": matches, + "name": name, + "noframes": noframes, + "namespace": "", + "resources": "", + "require": requires, + "run-at": runAt, + "version": version + ] + // certain metadata keys use a different key name then the actual key name + // for compatibility keeping this when applicable, although the rationale is not clear to me + // for unique keys passed to scriptObject, using the same key name that is present in actual userscript + // this key map is used to check for existence of keys in next loop + let keyMap = [ + "exclude": "excludes", + "include": "includes", + "match": "matches", + "resource": "resources", + ] + for metaline in metadata { + let key = keyMap[metaline.key] ?? metaline.key + if !scriptObject.keys.contains(key) { + let value = metaline.value + // metalines without values aren't included in parsed metadata object + // the only exception is @noframes + scriptObject[key] = !value.isEmpty ? value : value[0] + } + } + let scriptMetaStr = contents["metablock"] as? String ?? "??" + + // attempt to get require resource from disk + // if required resource is inaccessible, log error and continue + if let required = metadata["require"] { + // reverse required metadata + // if required is ["A", "B", "C"], C gets added above B which is above A, etc.. + // the reverse of that is desired + for require in required.reversed() { + let sanitizedName = sanitize(require) + let requiredFileURL = getRequireLocation().appendingPathComponent(filename).appendingPathComponent(sanitizedName) + if let requiredContent = try? String(contentsOf: requiredFileURL, encoding: .utf8) { + code = "\(requiredContent)\n\(code)" + } else { + logger?.error("\(#function, privacy: .public) - failed at (3) for \(require, privacy: .public)") + } + } + } + + if type == "css" { + cssFiles.append([ + "code": code, + "filename": filename, + "name": name, + "type": "css", + "weight": weight + ]) + } else if type == "js" { + if runAt == "context-menu" { + #if os(macOS) + menuFiles.append([ + "code": code, + "scriptMetaStr": scriptMetaStr, + "scriptObject": scriptObject, + "type": "js", + "weight": weight + ]) + #endif + } else { + jsFiles.append([ + "code": code, + "scriptMetaStr": scriptMetaStr, + "scriptObject": scriptObject, + "type": "js", + "weight": weight + ]) + } + } + } + let resp = [ + "files": ["css": cssFiles, "js": jsFiles, "menu": menuFiles], + "scriptHandler": "Userscripts", + "scriptHandlerVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + ] as [String : Any] + return resp } func getFileContentsParsed(_ url: URL) -> [String: Any]? { - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - // check that url is a valid path to a directory or single file - guard - FileManager.default.fileExists(atPath: url.path), - let content = try? String(contentsOf: url, encoding: .utf8), - let parsed = parse(content) - else { - return nil - } - return parsed + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + // check that url is a valid path to a directory or single file + guard + FileManager.default.fileExists(atPath: url.path), + let content = try? String(contentsOf: url, encoding: .utf8), + let parsed = parse(content) + else { + return nil + } + return parsed } func getInjectionFilenames(_ url: String) -> [String]? { - var filenames = [String]() - let manifest = getManifest() - let matched = getMatchedFiles(url, manifest, true) - guard let active = manifest.settings["active"] else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // if injection is disabled return empty array - if active != "true" { - return filenames - } - // filter out all disabled files - filenames = matched.filter{!manifest.disabled.contains($0)} - return filenames + var filenames = [String]() + let manifest = getManifest() + let matched = getMatchedFiles(url, manifest, true) + guard let active = manifest.settings["active"] else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // if injection is disabled return empty array + if active != "true" { + return filenames + } + // filter out all disabled files + filenames = matched.filter{!manifest.disabled.contains($0)} + return filenames } func getRequestScripts() -> [[String: String]]? { - var requestScripts = [[String: String]]() - // check the manifest to see if injection is enabled - let manifest = getManifest() - guard let active = manifest.settings["active"] else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // if not enabled, do not apply any net requests, ie. return empty array - if active != "true" { - return requestScripts - } - guard let files = getAllFiles(includeCode: true) else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - for file in files { - let isRequest = file["request"] as? Bool ?? false - // skip any non-request userscripts - if !isRequest { - continue - } - // can be force unwrapped because getAllFiles always returns these - let name = file["name"] as! String - let code = file["code"] as! String - let filename = file["filename"] as! String - - if !manifest.disabled.contains(filename) { - requestScripts.append(["name": name, "code": code]) - } - } - return requestScripts + var requestScripts = [[String: String]]() + // check the manifest to see if injection is enabled + let manifest = getManifest() + guard let active = manifest.settings["active"] else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // if not enabled, do not apply any net requests, ie. return empty array + if active != "true" { + return requestScripts + } + guard let files = getAllFiles(includeCode: true) else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + for file in files { + let isRequest = file["request"] as? Bool ?? false + // skip any non-request userscripts + if !isRequest { + continue + } + // can be force unwrapped because getAllFiles always returns these + let name = file["name"] as! String + let code = file["code"] as! String + let filename = file["filename"] as! String + + if !manifest.disabled.contains(filename) { + requestScripts.append(["name": name, "code": code]) + } + } + return requestScripts } func getContextMenuScripts() -> [String: Any]? { - var menuFilenames = [String]() - // check the manifest to see if injection is enabled - let manifest = getManifest() - guard let active = manifest.settings["active"] else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // if not enabled return empty array - if active != "true" { - return ["files": ["menu": []]] - } - // get all files at save location - guard let files = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - // loop through files and find @run-at context-menu script filenames - for file in files { - guard let filename = file["filename"] as? String else { - logger?.error("\(#function, privacy: .public) - failed at (3), couldn't get filename") - continue - } - guard let fileMetadata = file["metadata"] as? [String: [String]] else { - logger?.error("\(#function, privacy: .public) - failed at (4), couldn't get metadata for \(filename, privacy: .public)") - continue - } - let runAt = fileMetadata["run-at"]?[0] ?? "document-end" - if runAt != "context-menu" || manifest.disabled.contains(filename) { - continue - } - menuFilenames.append(filename) - } - // get and return script objects for all context-menu scripts - guard let scripts = getCode(menuFilenames, true) else { - logger?.error("\(#function, privacy: .public) - failed at (5)") - return nil - } - return scripts + var menuFilenames = [String]() + // check the manifest to see if injection is enabled + let manifest = getManifest() + guard let active = manifest.settings["active"] else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // if not enabled return empty array + if active != "true" { + return ["files": ["menu": []]] + } + // get all files at save location + guard let files = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + // loop through files and find @run-at context-menu script filenames + for file in files { + guard let filename = file["filename"] as? String else { + logger?.error("\(#function, privacy: .public) - failed at (3), couldn't get filename") + continue + } + guard let fileMetadata = file["metadata"] as? [String: [String]] else { + logger?.error("\(#function, privacy: .public) - failed at (4), couldn't get metadata for \(filename, privacy: .public)") + continue + } + let runAt = fileMetadata["run-at"]?[0] ?? "document-end" + if runAt != "context-menu" || manifest.disabled.contains(filename) { + continue + } + menuFilenames.append(filename) + } + // get and return script objects for all context-menu scripts + guard let scripts = getCode(menuFilenames, true) else { + logger?.error("\(#function, privacy: .public) - failed at (5)") + return nil + } + return scripts } // popup func getPopupMatches(_ url: String, _ subframeUrls: [String]) -> [[String: Any]]? { - var matches = [[String: Any]]() - // if the url doesn't start with http/s return empty array - if !url.starts(with: "http://") && !url.starts(with: "https://") { - return matches - } - // get all the files saved to manifest that match the passed url - let matched = getMatchedFiles(url, nil, false) - // get all the files at the save location - guard - let files = getAllFiles() - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // filter out the files that are present in both files and matched - // force unwrap filename to string since getAllFiles always returns it - matches = files.filter{matched.contains($0["filename"] as! String)} - - // get the subframe url matches - var frameUrlsMatched = [[String: Any]]() - var frameUrlsMatches = [String]() - // filter out the top page url from the frame urls - let frameUrls = subframeUrls.filter{$0 != url} - // for each url just pushed to frameUrls, get all the files saved to manifest that match their url - for frameUrl in frameUrls { - let frameMatches = getMatchedFiles(frameUrl, nil, false) - for frameMatch in frameMatches { - // for the match against the frameUrl, see if it has @noframes - // if so, it should not be appended to frameUrlsMatches - // filter all files for the first one that matches the frameMatch filename - // can force unwrap filename b/c getAllFiles always returns it - let frameMatchMetadata = files.filter{$0["filename"] as! String == frameMatch}.first - // can force unwrap noframes b/c getAllFiles always returns it - let noFrames = frameMatchMetadata?["noframes"] as? Bool ?? false - if !matched.contains(frameMatch) && !noFrames { - frameUrlsMatches.append(frameMatch) - } - } - } - - // filter out the files that are present in both files and frameUrlsMatches - // force unwrap filename to string since getAllFiles always returns it - frameUrlsMatched = files.filter{frameUrlsMatches.contains($0["filename"] as! String)} - // loop through frameUrlsMatched and add subframe key/val - for (index, var frameUrlsMatch) in frameUrlsMatched.enumerated() { - frameUrlsMatch["subframe"] = true - frameUrlsMatched[index] = frameUrlsMatch - } - // add frameUrlsMatched to matches array - matches.append(contentsOf: frameUrlsMatched) - return matches + var matches = [[String: Any]]() + // if the url doesn't start with http/s return empty array + if !url.starts(with: "http://") && !url.starts(with: "https://") { + return matches + } + // get all the files saved to manifest that match the passed url + let matched = getMatchedFiles(url, nil, false) + // get all the files at the save location + guard + let files = getAllFiles() + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // filter out the files that are present in both files and matched + // force unwrap filename to string since getAllFiles always returns it + matches = files.filter{matched.contains($0["filename"] as! String)} + + // get the subframe url matches + var frameUrlsMatched = [[String: Any]]() + var frameUrlsMatches = [String]() + // filter out the top page url from the frame urls + let frameUrls = subframeUrls.filter{$0 != url} + // for each url just pushed to frameUrls, get all the files saved to manifest that match their url + for frameUrl in frameUrls { + let frameMatches = getMatchedFiles(frameUrl, nil, false) + for frameMatch in frameMatches { + // for the match against the frameUrl, see if it has @noframes + // if so, it should not be appended to frameUrlsMatches + // filter all files for the first one that matches the frameMatch filename + // can force unwrap filename b/c getAllFiles always returns it + let frameMatchMetadata = files.filter{$0["filename"] as! String == frameMatch}.first + // can force unwrap noframes b/c getAllFiles always returns it + let noFrames = frameMatchMetadata?["noframes"] as? Bool ?? false + if !matched.contains(frameMatch) && !noFrames { + frameUrlsMatches.append(frameMatch) + } + } + } + + // filter out the files that are present in both files and frameUrlsMatches + // force unwrap filename to string since getAllFiles always returns it + frameUrlsMatched = files.filter{frameUrlsMatches.contains($0["filename"] as! String)} + // loop through frameUrlsMatched and add subframe key/val + for (index, var frameUrlsMatch) in frameUrlsMatched.enumerated() { + frameUrlsMatch["subframe"] = true + frameUrlsMatched[index] = frameUrlsMatch + } + // add frameUrlsMatched to matches array + matches.append(contentsOf: frameUrlsMatched) + return matches } func popupUpdateAll() -> Bool { - guard - let files = getAllFiles(), - updateAllFiles(files), - updateManifestMatches(files), - updateManifestRequired(files), - purgeManifest(files) - else { - return false - } - return true + guard + let files = getAllFiles(), + updateAllFiles(files), + updateManifestMatches(files), + updateManifestRequired(files), + purgeManifest(files) + else { + return false + } + return true } func getPopupBadgeCount(_ url: String, _ subframeUrls: [String]) -> Int? { - if !url.starts(with: "http://") && !url.starts(with: "https://") { - return 0 - } - let manifest = getManifest() - guard - var matches = getPopupMatches(url, subframeUrls) - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - for pattern in manifest.blacklist { - if match(url, pattern) { - return 0 - } - } - matches = matches.filter{!manifest.disabled.contains($0["filename"] as! String)} - return matches.count + if !url.starts(with: "http://") && !url.starts(with: "https://") { + return 0 + } + let manifest = getManifest() + guard + var matches = getPopupMatches(url, subframeUrls) + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + for pattern in manifest.blacklist { + if match(url, pattern) { + return 0 + } + } + matches = matches.filter{!manifest.disabled.contains($0["filename"] as! String)} + return matches.count } func popupUpdateSingle(_ filename: String, _ url: String, _ subframeUrls: [String]) -> [[String: Any]]? { - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - let fileUrl = saveLocation.appendingPathComponent(filename) - guard - let content = try? String(contentsOf: fileUrl, encoding: .utf8), - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let updateUrl = metadata["updateURL"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl - guard - let remoteFileContents = getRemoteFileContents(downloadUrl), - ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) - else { - logger?.error("\(#function, privacy: .public) - failed at (3)") - return nil - } - guard - let files = getAllFiles(), - updateManifestMatches(files), - updateManifestRequired(files), - purgeManifest(files), - let matches = getPopupMatches(url, subframeUrls) - else { - logger?.error("\(#function, privacy: .public) - failed at (4)") - return nil - } - return matches + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + let fileUrl = saveLocation.appendingPathComponent(filename) + guard + let content = try? String(contentsOf: fileUrl, encoding: .utf8), + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let updateUrl = metadata["updateURL"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl + guard + let remoteFileContents = getRemoteFileContents(downloadUrl), + ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) + else { + logger?.error("\(#function, privacy: .public) - failed at (3)") + return nil + } + guard + let files = getAllFiles(), + updateManifestMatches(files), + updateManifestRequired(files), + purgeManifest(files), + let matches = getPopupMatches(url, subframeUrls) + else { + logger?.error("\(#function, privacy: .public) - failed at (4)") + return nil + } + return matches } // page func getInitData() -> [String: Any]? { - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - return [ - "saveLocation": saveLocation.path, - "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??", - "build": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" - ] + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + return [ + "saveLocation": saveLocation.path, + "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??", + "build": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" + ] } func getLegacyData() -> [String: Any]? { - let manifest = getManifest() - var data:[String: Any] = manifest.settings - data["blacklist"] = manifest.blacklist - return data + let manifest = getManifest() + var data:[String: Any] = manifest.settings + data["blacklist"] = manifest.blacklist + return data } func saveFile(_ item: [String: Any],_ content: String) -> [String: Any] { - var response = [String: Any]() - let newContent = content - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return ["error": "failed to get save location when attempting to save"] - } - guard - let oldFilename = item["filename"] as? String, - let type = item["type"] as? String - else { - return ["error": "invalid argument in save function"] - } - guard - let parsed = parse(newContent), - let metadata = parsed["metadata"] as? [String: [String]] - else { - return ["error": "failed to parse metadata"] - } - guard let n = metadata["name"]?[0] else { - return ["error": "@name not found in metadata"] - } - var name = sanitize(n) - - // construct new file name - let newFilename = "\(name).user.\(type)" - - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - guard - let allFilesUrls = try? FileManager.default.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) - else { - return ["error": "failed to read save urls in save function"] - } - - // validate file before save - var allFilenames:[String] = [] // stores the indv filenames for later comparison - // old and new filenames are equal, overwriting and can skip - if oldFilename.lowercased() != newFilename.lowercased() { - // loop through all the file urls in the save location and save filename to var - for fileUrl in allFilesUrls { - // skip file if it is not of the proper type - let filename = fileUrl.lastPathComponent - if (!filename.hasSuffix(type)) { - continue - } - // if file is of the proper type, add it to the allFilenames array - allFilenames.append(filename.lowercased()) - } - } - - if allFilenames.contains(newFilename.lowercased()) { - return ["error": "filename already taken"] - } - - if newFilename.count > 250 { - return ["error": "filename too long"] - } - - // file passed validation - - // attempt to save to disk - let newFileUrl = saveLocation.appendingPathComponent(newFilename) - do { - try newContent.write(to: newFileUrl, atomically: false, encoding: .utf8) - } catch { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return ["error": "failed to write file to disk"] - } - - // saved to disk successfully - - // get the file last modified date - guard - let dateMod = try? FileManager.default.attributesOfItem(atPath: newFileUrl.path)[.modificationDate] as? Date - else { - logger?.error("\(#function, privacy: .public) - failed at (3)") - return ["error": "failed to read modified date in save function"] - } - - // remove old file and manifest records for old file if they exist - if oldFilename.lowercased() != newFilename.lowercased() { - // if user changed the filename, remove file with old filename - let oldFileUrl = saveLocation.appendingPathComponent(oldFilename) - // however, when creating a new file, if user changes the temp given name by app... - // oldFilename (the temp name in activeItem) and newFilename (@name in file contents) will differ - // the file with oldFilename will not be on the filesystem and can not be deleted - // for that edge case, using try? rather than try(!) to allow failures - try? FileManager.default.trashItem(at: oldFileUrl, resultingItemURL: nil) - } - - // update manifest for new file and purge anything from old file - guard - let allFiles = getAllFiles(), - updateManifestMatches(allFiles), - updateManifestRequired(allFiles), - updateManifestDeclarativeNetRequests(allFiles), - purgeManifest(allFiles) - else { - logger?.error("\(#function, privacy: .public) - failed at (4)") - return ["error": "file save but manifest couldn't be updated"] - } - - // un-santized name - name = unsanitize(name) - - // build response dict - response["canUpdate"] = false - response["content"] = newContent - response["filename"] = newFilename - response["lastModified"] = dateToMilliseconds(dateMod) - response["name"] = name - if metadata["description"] != nil { - response["description"] = metadata["description"]![0] - } - if metadata["version"] != nil && metadata["updateURL"] != nil { - response["canUpdate"] = true - } - // if a request "type" userscript add key/val - let runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - response["request"] = true - } - - return response + var response = [String: Any]() + let newContent = content + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return ["error": "failed to get save location when attempting to save"] + } + guard + let oldFilename = item["filename"] as? String, + let type = item["type"] as? String + else { + return ["error": "invalid argument in save function"] + } + guard + let parsed = parse(newContent), + let metadata = parsed["metadata"] as? [String: [String]] + else { + return ["error": "failed to parse metadata"] + } + guard let n = metadata["name"]?[0] else { + return ["error": "@name not found in metadata"] + } + var name = sanitize(n) + + // construct new file name + let newFilename = "\(name).user.\(type)" + + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + guard + let allFilesUrls = try? FileManager.default.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) + else { + return ["error": "failed to read save urls in save function"] + } + + // validate file before save + var allFilenames:[String] = [] // stores the indv filenames for later comparison + // old and new filenames are equal, overwriting and can skip + if oldFilename.lowercased() != newFilename.lowercased() { + // loop through all the file urls in the save location and save filename to var + for fileUrl in allFilesUrls { + // skip file if it is not of the proper type + let filename = fileUrl.lastPathComponent + if (!filename.hasSuffix(type)) { + continue + } + // if file is of the proper type, add it to the allFilenames array + allFilenames.append(filename.lowercased()) + } + } + + if allFilenames.contains(newFilename.lowercased()) { + return ["error": "filename already taken"] + } + + if newFilename.count > 250 { + return ["error": "filename too long"] + } + + // file passed validation + + // attempt to save to disk + let newFileUrl = saveLocation.appendingPathComponent(newFilename) + do { + try newContent.write(to: newFileUrl, atomically: false, encoding: .utf8) + } catch { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return ["error": "failed to write file to disk"] + } + + // saved to disk successfully + + // get the file last modified date + guard + let dateMod = try? FileManager.default.attributesOfItem(atPath: newFileUrl.path)[.modificationDate] as? Date + else { + logger?.error("\(#function, privacy: .public) - failed at (3)") + return ["error": "failed to read modified date in save function"] + } + + // remove old file and manifest records for old file if they exist + if oldFilename.lowercased() != newFilename.lowercased() { + // if user changed the filename, remove file with old filename + let oldFileUrl = saveLocation.appendingPathComponent(oldFilename) + // however, when creating a new file, if user changes the temp given name by app... + // oldFilename (the temp name in activeItem) and newFilename (@name in file contents) will differ + // the file with oldFilename will not be on the filesystem and can not be deleted + // for that edge case, using try? rather than try(!) to allow failures + try? FileManager.default.trashItem(at: oldFileUrl, resultingItemURL: nil) + } + + // update manifest for new file and purge anything from old file + guard + let allFiles = getAllFiles(), + updateManifestMatches(allFiles), + updateManifestRequired(allFiles), + updateManifestDeclarativeNetRequests(allFiles), + purgeManifest(allFiles) + else { + logger?.error("\(#function, privacy: .public) - failed at (4)") + return ["error": "file save but manifest couldn't be updated"] + } + + // un-santized name + name = unsanitize(name) + + // build response dict + response["canUpdate"] = false + response["content"] = newContent + response["filename"] = newFilename + response["lastModified"] = dateToMilliseconds(dateMod) + response["name"] = name + if metadata["description"] != nil { + response["description"] = metadata["description"]![0] + } + if metadata["version"] != nil && metadata["updateURL"] != nil { + response["canUpdate"] = true + } + // if a request "type" userscript add key/val + let runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + response["request"] = true + } + + return response } func trashFile(_ item: [String: Any]) -> Bool { - guard - let saveLocation = getSaveLocation(), - let filename = item["filename"] as? String - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return false - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - let url = saveLocation.appendingPathComponent(filename) - // if file is already removed from path, assume it was removed by user and return true - if (FileManager.default.fileExists(atPath: url.path)) { - do { - try FileManager.default.trashItem(at: url, resultingItemURL: nil) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - return false - } - } - // update manifest - guard updateManifestMatches(), updateManifestRequired(), purgeManifest() else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return false - } - return true; + guard + let saveLocation = getSaveLocation(), + let filename = item["filename"] as? String + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return false + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + let url = saveLocation.appendingPathComponent(filename) + // if file is already removed from path, assume it was removed by user and return true + if (FileManager.default.fileExists(atPath: url.path)) { + do { + try FileManager.default.trashItem(at: url, resultingItemURL: nil) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + return false + } + } + // update manifest + guard updateManifestMatches(), updateManifestRequired(), purgeManifest() else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return false + } + return true; } func getFileRemoteUpdate(_ content: String) -> [String: String] { - guard - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]] - else { - // can't parse editor contents - return ["error": "Update failed, metadata missing"] - } - // editor contents missing version value - guard let version = metadata["version"]?[0] else { - return ["error": "Update failed, version value required"] - } - // editor contents missing updateURL - guard let updateURL = metadata["updateURL"]?[0] else { - return ["error": "Update failed, update url required"] - } - // set download url - let downloadURL = (metadata["downloadURL"] != nil) ? metadata["downloadURL"]![0] : updateURL - // basic url validation - guard validateUrl(updateURL) else { - return ["error": "Update failed, invalid updateURL"] - } - guard validateUrl(downloadURL) else { - return ["error": "Update failed, invalid downloadURL"] - } - // get the remote file contents for checking version - guard var remoteContent = getRemoteFileContents(updateURL) else { - return ["error": "Update failed, updateURL unreachable"] - } - // parse remote file contents - guard - let remoteParsed = parse(remoteContent), - let remoteMetadata = remoteParsed["metadata"] as? [String: [String]], - let remoteVersion = remoteMetadata["version"]?[0] - else { - // can't parse editor contents - return ["error": "Update failed, couldn't parse remote file contents"] - } - // check if update is needed - if version >= remoteVersion { - return ["info": "No updates found"] - } - // at this point it is known an update is available, get new code from downloadURL - // is there's a specific downloadURL overwrite remoteContents with code from downloadURL - if updateURL != downloadURL { - guard let remoteDownloadContent = getRemoteFileContents(downloadURL) else { - return ["error": "Update failed, downloadURL unreachable"] - } - remoteContent = remoteDownloadContent - } - return ["content": remoteContent] + guard + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]] + else { + // can't parse editor contents + return ["error": "Update failed, metadata missing"] + } + // editor contents missing version value + guard let version = metadata["version"]?[0] else { + return ["error": "Update failed, version value required"] + } + // editor contents missing updateURL + guard let updateURL = metadata["updateURL"]?[0] else { + return ["error": "Update failed, update url required"] + } + // set download url + let downloadURL = (metadata["downloadURL"] != nil) ? metadata["downloadURL"]![0] : updateURL + // basic url validation + guard validateUrl(updateURL) else { + return ["error": "Update failed, invalid updateURL"] + } + guard validateUrl(downloadURL) else { + return ["error": "Update failed, invalid downloadURL"] + } + // get the remote file contents for checking version + guard var remoteContent = getRemoteFileContents(updateURL) else { + return ["error": "Update failed, updateURL unreachable"] + } + // parse remote file contents + guard + let remoteParsed = parse(remoteContent), + let remoteMetadata = remoteParsed["metadata"] as? [String: [String]], + let remoteVersion = remoteMetadata["version"]?[0] + else { + // can't parse editor contents + return ["error": "Update failed, couldn't parse remote file contents"] + } + // check if update is needed + if version >= remoteVersion { + return ["info": "No updates found"] + } + // at this point it is known an update is available, get new code from downloadURL + // is there's a specific downloadURL overwrite remoteContents with code from downloadURL + if updateURL != downloadURL { + guard let remoteDownloadContent = getRemoteFileContents(downloadURL) else { + return ["error": "Update failed, downloadURL unreachable"] + } + remoteContent = remoteDownloadContent + } + return ["content": remoteContent] } // background func nativeChecks() -> [String: String] { - logger?.info("\(#function, privacy: .public) - started") - #if os(iOS) - // check the save location is set - guard (getSaveLocation() != nil) else { - logger?.error("\(#function, privacy: .public) - save location unset (iOS)") - return [ - "error": "Native checks error (0)", - "saveLocation": "unset", - "scheme": Bundle.main.infoDictionary?["US_URL_SCHEME"] as! String - ] - } - #endif - // check the default directories - guard checkDefaultDirectories() else { - logger?.error("\(#function, privacy: .public) - checkDefaultDirectories failed") - return ["error": "Native checks error (1)"] - } - // check the settings - guard checkSettings() else { - logger?.error("\(#function, privacy: .public) - checkSettings failed") - return ["error": "Native checks error (2)"] - } - // get all files to pass as arguments to function below - guard let allFiles = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - getAllFiles failed") - return ["error": "Native checks error (3)"] - } - // purge the manifest of old records - guard purgeManifest(allFiles) else { - logger?.error("\(#function, privacy: .public) - purgeManifest failed") - return ["error": "Native checks error (4)"] - } - // update matches in manifest - guard updateManifestMatches(allFiles) else { - logger?.error("\(#function, privacy: .public) - updateManifestMatches failed") - return ["error": "Native checks error (5)"] - } - // update the required resources - guard updateManifestRequired(allFiles) else { - logger?.error("\(#function, privacy: .public) - updateManifestRequired failed") - return ["error": "Native checks error (6)"] - } - // update declarativeNetRequest - guard updateManifestDeclarativeNetRequests(allFiles) else { - logger?.error("\(#function, privacy: .public) - updateManifestDeclarativeNetRequests failed") - return ["error": "Native checks error (7)"] - } - // pass some info in response - logger?.info("\(#function, privacy: .public) - completed") - return ["success": "Native checks complete"] + logger?.info("\(#function, privacy: .public) - started") + #if os(iOS) + // check the save location is set + guard (getSaveLocation() != nil) else { + logger?.error("\(#function, privacy: .public) - save location unset (iOS)") + return [ + "error": "Native checks error (0)", + "saveLocation": "unset", + "scheme": Bundle.main.infoDictionary?["US_URL_SCHEME"] as! String + ] + } + #endif + // check the default directories + guard checkDefaultDirectories() else { + logger?.error("\(#function, privacy: .public) - checkDefaultDirectories failed") + return ["error": "Native checks error (1)"] + } + // check the settings + guard checkSettings() else { + logger?.error("\(#function, privacy: .public) - checkSettings failed") + return ["error": "Native checks error (2)"] + } + // get all files to pass as arguments to function below + guard let allFiles = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - getAllFiles failed") + return ["error": "Native checks error (3)"] + } + // purge the manifest of old records + guard purgeManifest(allFiles) else { + logger?.error("\(#function, privacy: .public) - purgeManifest failed") + return ["error": "Native checks error (4)"] + } + // update matches in manifest + guard updateManifestMatches(allFiles) else { + logger?.error("\(#function, privacy: .public) - updateManifestMatches failed") + return ["error": "Native checks error (5)"] + } + // update the required resources + guard updateManifestRequired(allFiles) else { + logger?.error("\(#function, privacy: .public) - updateManifestRequired failed") + return ["error": "Native checks error (6)"] + } + // update declarativeNetRequest + guard updateManifestDeclarativeNetRequests(allFiles) else { + logger?.error("\(#function, privacy: .public) - updateManifestDeclarativeNetRequests failed") + return ["error": "Native checks error (7)"] + } + // pass some info in response + logger?.info("\(#function, privacy: .public) - completed") + return ["success": "Native checks complete"] } // userscript install func installCheck(_ content: String) -> [String: Any] { - // this func checks a userscript's metadata to determine if it's already installed - - guard let files = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return ["error": "installCheck failed at (1)"] - } - - guard - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let newName = metadata["name"]?[0] - else { - return ["error": "userscript metadata is invalid"] - } - - // loop through all files nad get their names and filenames - // we will check the new name/filename to see if this is a unique userscript - // or if it will overwrite an existing userscript - var names = [String]() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let name = file["name"] as! String - - // populate array - names.append(name) - } - - var directive = "" - #if os(macOS) - directive = "Click" - #elseif os(iOS) - directive = "Tap" - #endif - - if names.contains(newName) { - return [ - "success": "\(directive) to re-install", - "metadata": metadata, - "installed": true - ] - } - - return [ - "success": "\(directive) to install", - "metadata": metadata, - "installed": false - ]; + // this func checks a userscript's metadata to determine if it's already installed + + guard let files = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return ["error": "installCheck failed at (1)"] + } + + guard + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let newName = metadata["name"]?[0] + else { + return ["error": "userscript metadata is invalid"] + } + + // loop through all files nad get their names and filenames + // we will check the new name/filename to see if this is a unique userscript + // or if it will overwrite an existing userscript + var names = [String]() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let name = file["name"] as! String + + // populate array + names.append(name) + } + + var directive = "" + #if os(macOS) + directive = "Click" + #elseif os(iOS) + directive = "Tap" + #endif + + if names.contains(newName) { + return [ + "success": "\(directive) to re-install", + "metadata": metadata, + "installed": true + ] + } + + return [ + "success": "\(directive) to install", + "metadata": metadata, + "installed": false + ]; } func installUserscript(_ content: String) -> [String: Any] { - guard - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let n = metadata["name"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return ["error": "installUserscript failed at (1)"] - } - let name = sanitize(n) - let filename = "\(name).user.js" - - let saved = saveFile(["filename": filename, "type": "js"], content) - return saved + guard + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let n = metadata["name"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return ["error": "installUserscript failed at (1)"] + } + let name = sanitize(n) + let filename = "\(name).user.js" + + let saved = saveFile(["filename": filename, "type": "js"], content) + return saved } diff --git a/xcode/Ext-Safari/SafariWebExtensionHandler.swift b/xcode/Ext-Safari/SafariWebExtensionHandler.swift index 2386ab40..ac5cec13 100644 --- a/xcode/Ext-Safari/SafariWebExtensionHandler.swift +++ b/xcode/Ext-Safari/SafariWebExtensionHandler.swift @@ -1,308 +1,308 @@ import SafariServices class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { - func beginRequest(with context: NSExtensionContext) { - let logger = USLogger(#fileID) - let item = context.inputItems[0] as? NSExtensionItem - let message = item?.userInfo?[SFExtensionMessageKey] as? [String: Any] - // if message received without name, ignore - guard let name = message?["name"] as? String else { - logger?.error("\(#function, privacy: .public) - could not get message name from web extension") - return - } - logger?.info("\(#function, privacy: .public) - Got message with name: \(name, privacy: .public)") - // got a valid message, construct response based on message received - let response = NSExtensionItem() - // send standard error when there's an issue parsing inbound message - var inBoundError = false - // these if/else if statement are formatted so that they can be neatly collapsed in Xcode - // typically the "else if" would be on the same line as the preceding statements close bracket - // ie. } else if { - if name == "OPEN_APP" { - if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], - let url = URL(string: "\(scheme):") { - #if os(macOS) - NSWorkspace.shared.open(url) - #endif - } - } - else if name == "NATIVE_CHECKS" { - let result = nativeChecks() - response.userInfo = [SFExtensionMessageKey: result] - } - else if name == "REQ_PLATFORM" { - let platform = getPlatform() - response.userInfo = [SFExtensionMessageKey: ["platform": platform]] - } - else if name == "REQ_USERSCRIPTS" { - if let url = message?["url"] as? String, let isTop = message?["isTop"] as? Bool { - if - let matches = getInjectionFilenames(url), - let code = getCode(matches, isTop) - { - response.userInfo = [SFExtensionMessageKey: code] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "REQ_USERSCRIPTS failed"]] - } - } else { - inBoundError = true - } - } - else if name == "REQ_REQUESTS" { - if let requestScripts = getRequestScripts() { - response.userInfo = [SFExtensionMessageKey: requestScripts] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get requestScripts"]] - } - } - else if name == "REQ_CONTEXT_MENU_SCRIPTS" { - if let contextMenuScripts = getContextMenuScripts() { - response.userInfo = [SFExtensionMessageKey: contextMenuScripts] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get contextMenuScripts"]] - } - } - else if name == "POPUP_BADGE_COUNT" { - if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { - if let matches = getPopupBadgeCount(url, frameUrls) { - response.userInfo = [SFExtensionMessageKey: ["count": matches]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to update badge count"]] - } - } else { - inBoundError = true - } - } - else if name == "POPUP_MATCHES"{ - if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { - if let matches = getPopupMatches(url, frameUrls) { - response.userInfo = [SFExtensionMessageKey: ["matches": matches]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get matches"]] - } - } else { - inBoundError = true - } - } - else if name == "POPUP_UPDATES" { - if let updates = checkForRemoteUpdates() { - response.userInfo = [SFExtensionMessageKey: ["updates": updates]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get updates"]] - } - } - else if name == "POPUP_UPDATE_ALL" { - if popupUpdateAll(), let updates = checkForRemoteUpdates() { - response.userInfo = [SFExtensionMessageKey: ["updates": updates]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to run update sequence"]] - } - } - else if name == "POPUP_UPDATE_SINGLE" { - if - let filename = message?["filename"] as? String, - let url = message?["url"] as? String, - let frameUrls = message?["frameUrls"] as? [String] - { - if let matches = popupUpdateSingle(filename, url, frameUrls) { - response.userInfo = [SFExtensionMessageKey: ["items": matches]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to update file"]] - } - } else { - inBoundError = true - } - } - else if name == "POPUP_CHECK_UPDATES" { - if let updates = checkForRemoteUpdates() { - response.userInfo = [SFExtensionMessageKey: ["updates": updates]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to check for updates"]] - } - } - else if name == "TOGGLE_EXTENSION" { - if let active = message?["active"] as? String, ["true", "false"].contains(active) { - var manifest = getManifest() - manifest.settings["active"] = active - if updateManifest(with: manifest) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to update injection state"]] - } - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "missing or wrong message content"]] - } - } - else if name == "TOGGLE_ITEM" { - // the current status of the item to toggle comes in as 0 || 1 - if - let item = message?["item"] as? [String: Any], - let filename = item["filename"] as? String, - let current = item["disabled"] as? Int - { - let action = current == 0 ? "disable" : "enable" - if toggleFile(filename, action) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to toggle file"]] - } - } else { - inBoundError = true - } - } - else if name == "OPEN_SAVE_LOCATION" { - #if os(macOS) - if openSaveLocation() { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } - #elseif os(iOS) - if let files = getAllFiles() { - response.userInfo = [SFExtensionMessageKey: ["items": files]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] - } - #endif - } - else if name == "CHANGE_SAVE_LOCATION" { - #if os(macOS) - if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], - let url = URL(string: "\(scheme)://changesavelocation") { - NSWorkspace.shared.open(url) - } - #endif - } - else if name == "POPUP_INSTALL_CHECK" { - if let content = message?["content"] as? String { - response.userInfo = [SFExtensionMessageKey: installCheck(content)] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content"]] - } - } - else if name == "POPUP_INSTALL_SCRIPT" { - if let content = message?["content"] as? String { - response.userInfo = [SFExtensionMessageKey: installUserscript(content)] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content (2)"]] - } - } - else if name == "PAGE_INIT_DATA" { - #if os(macOS) - if let initData = getInitData(), checkDefaultDirectories() { - response.userInfo = [SFExtensionMessageKey: initData] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]] - } - #endif - } - else if name == "PAGE_LEGACY_IMPORT" { - #if os(macOS) - if let settings = getLegacyData() { - response.userInfo = [SFExtensionMessageKey: settings] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get legacy data"]] - } - #endif - } - else if name == "PAGE_ALL_FILES" { - #if os(macOS) - if let files = getAllFiles() { - response.userInfo = [SFExtensionMessageKey: files] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] - } - #endif - } - else if name == "PAGE_SAVE" { - #if os(macOS) - if - let item = message?["item"] as? [String: Any], - let content = message?["content"] as? String - { - let saveResponse = saveFile(item, content) - response.userInfo = [SFExtensionMessageKey: saveResponse] - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_TRASH" { - #if os(macOS) - if let item = message?["item"] as? [String: Any] { - if trashFile(item) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to trash file"]] - } - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_UPDATE" { - #if os(macOS) - if let content = message?["content"] as? String { - let updateResponse = getFileRemoteUpdate(content) - response.userInfo = [SFExtensionMessageKey: updateResponse] - } else { - inBoundError = true - } - #endif - } - else if name == "CANCEL_REQUESTS" { - URLSession.shared.getAllTasks { tasks in - for task in tasks { - task.cancel() - } - } - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } - else if name == "PAGE_NEW_REMOTE" { - #if os(macOS) - if let url = message?["url"] as? String { - if !validateUrl(url) { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content, invalid url"]] - } else if let content = getRemoteFileContents(url) { - response.userInfo = [SFExtensionMessageKey: content] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content"]] - } - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_UPDATE_SETTINGS" { - #if os(macOS) - if let settings = message?["settings"] as? [String: String] { - if updateSettings(settings) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save settings to disk"]] - } - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_UPDATE_BLACKLIST" { - #if os(macOS) - if let blacklist = message?["blacklist"] as? [String] { - var manifest = getManifest() - manifest.blacklist = blacklist - if !updateManifest(with: manifest) { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save blacklist to disk"]] - } else { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } - } else { - inBoundError = true - } - #endif - } - // send inBoundError if found - if inBoundError { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to parse inbound message"]] - } - context.completeRequest(returningItems: [response], completionHandler: nil) - } + func beginRequest(with context: NSExtensionContext) { + let logger = USLogger(#fileID) + let item = context.inputItems[0] as? NSExtensionItem + let message = item?.userInfo?[SFExtensionMessageKey] as? [String: Any] + // if message received without name, ignore + guard let name = message?["name"] as? String else { + logger?.error("\(#function, privacy: .public) - could not get message name from web extension") + return + } + logger?.info("\(#function, privacy: .public) - Got message with name: \(name, privacy: .public)") + // got a valid message, construct response based on message received + let response = NSExtensionItem() + // send standard error when there's an issue parsing inbound message + var inBoundError = false + // these if/else if statement are formatted so that they can be neatly collapsed in Xcode + // typically the "else if" would be on the same line as the preceding statements close bracket + // ie. } else if { + if name == "OPEN_APP" { + if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], + let url = URL(string: "\(scheme):") { + #if os(macOS) + NSWorkspace.shared.open(url) + #endif + } + } + else if name == "NATIVE_CHECKS" { + let result = nativeChecks() + response.userInfo = [SFExtensionMessageKey: result] + } + else if name == "REQ_PLATFORM" { + let platform = getPlatform() + response.userInfo = [SFExtensionMessageKey: ["platform": platform]] + } + else if name == "REQ_USERSCRIPTS" { + if let url = message?["url"] as? String, let isTop = message?["isTop"] as? Bool { + if + let matches = getInjectionFilenames(url), + let code = getCode(matches, isTop) + { + response.userInfo = [SFExtensionMessageKey: code] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "REQ_USERSCRIPTS failed"]] + } + } else { + inBoundError = true + } + } + else if name == "REQ_REQUESTS" { + if let requestScripts = getRequestScripts() { + response.userInfo = [SFExtensionMessageKey: requestScripts] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get requestScripts"]] + } + } + else if name == "REQ_CONTEXT_MENU_SCRIPTS" { + if let contextMenuScripts = getContextMenuScripts() { + response.userInfo = [SFExtensionMessageKey: contextMenuScripts] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get contextMenuScripts"]] + } + } + else if name == "POPUP_BADGE_COUNT" { + if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { + if let matches = getPopupBadgeCount(url, frameUrls) { + response.userInfo = [SFExtensionMessageKey: ["count": matches]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to update badge count"]] + } + } else { + inBoundError = true + } + } + else if name == "POPUP_MATCHES"{ + if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { + if let matches = getPopupMatches(url, frameUrls) { + response.userInfo = [SFExtensionMessageKey: ["matches": matches]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get matches"]] + } + } else { + inBoundError = true + } + } + else if name == "POPUP_UPDATES" { + if let updates = checkForRemoteUpdates() { + response.userInfo = [SFExtensionMessageKey: ["updates": updates]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get updates"]] + } + } + else if name == "POPUP_UPDATE_ALL" { + if popupUpdateAll(), let updates = checkForRemoteUpdates() { + response.userInfo = [SFExtensionMessageKey: ["updates": updates]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to run update sequence"]] + } + } + else if name == "POPUP_UPDATE_SINGLE" { + if + let filename = message?["filename"] as? String, + let url = message?["url"] as? String, + let frameUrls = message?["frameUrls"] as? [String] + { + if let matches = popupUpdateSingle(filename, url, frameUrls) { + response.userInfo = [SFExtensionMessageKey: ["items": matches]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to update file"]] + } + } else { + inBoundError = true + } + } + else if name == "POPUP_CHECK_UPDATES" { + if let updates = checkForRemoteUpdates() { + response.userInfo = [SFExtensionMessageKey: ["updates": updates]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to check for updates"]] + } + } + else if name == "TOGGLE_EXTENSION" { + if let active = message?["active"] as? String, ["true", "false"].contains(active) { + var manifest = getManifest() + manifest.settings["active"] = active + if updateManifest(with: manifest) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to update injection state"]] + } + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "missing or wrong message content"]] + } + } + else if name == "TOGGLE_ITEM" { + // the current status of the item to toggle comes in as 0 || 1 + if + let item = message?["item"] as? [String: Any], + let filename = item["filename"] as? String, + let current = item["disabled"] as? Int + { + let action = current == 0 ? "disable" : "enable" + if toggleFile(filename, action) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to toggle file"]] + } + } else { + inBoundError = true + } + } + else if name == "OPEN_SAVE_LOCATION" { + #if os(macOS) + if openSaveLocation() { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } + #elseif os(iOS) + if let files = getAllFiles() { + response.userInfo = [SFExtensionMessageKey: ["items": files]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] + } + #endif + } + else if name == "CHANGE_SAVE_LOCATION" { + #if os(macOS) + if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], + let url = URL(string: "\(scheme)://changesavelocation") { + NSWorkspace.shared.open(url) + } + #endif + } + else if name == "POPUP_INSTALL_CHECK" { + if let content = message?["content"] as? String { + response.userInfo = [SFExtensionMessageKey: installCheck(content)] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content"]] + } + } + else if name == "POPUP_INSTALL_SCRIPT" { + if let content = message?["content"] as? String { + response.userInfo = [SFExtensionMessageKey: installUserscript(content)] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content (2)"]] + } + } + else if name == "PAGE_INIT_DATA" { + #if os(macOS) + if let initData = getInitData(), checkDefaultDirectories() { + response.userInfo = [SFExtensionMessageKey: initData] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]] + } + #endif + } + else if name == "PAGE_LEGACY_IMPORT" { + #if os(macOS) + if let settings = getLegacyData() { + response.userInfo = [SFExtensionMessageKey: settings] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get legacy data"]] + } + #endif + } + else if name == "PAGE_ALL_FILES" { + #if os(macOS) + if let files = getAllFiles() { + response.userInfo = [SFExtensionMessageKey: files] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] + } + #endif + } + else if name == "PAGE_SAVE" { + #if os(macOS) + if + let item = message?["item"] as? [String: Any], + let content = message?["content"] as? String + { + let saveResponse = saveFile(item, content) + response.userInfo = [SFExtensionMessageKey: saveResponse] + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_TRASH" { + #if os(macOS) + if let item = message?["item"] as? [String: Any] { + if trashFile(item) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to trash file"]] + } + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_UPDATE" { + #if os(macOS) + if let content = message?["content"] as? String { + let updateResponse = getFileRemoteUpdate(content) + response.userInfo = [SFExtensionMessageKey: updateResponse] + } else { + inBoundError = true + } + #endif + } + else if name == "CANCEL_REQUESTS" { + URLSession.shared.getAllTasks { tasks in + for task in tasks { + task.cancel() + } + } + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } + else if name == "PAGE_NEW_REMOTE" { + #if os(macOS) + if let url = message?["url"] as? String { + if !validateUrl(url) { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content, invalid url"]] + } else if let content = getRemoteFileContents(url) { + response.userInfo = [SFExtensionMessageKey: content] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content"]] + } + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_UPDATE_SETTINGS" { + #if os(macOS) + if let settings = message?["settings"] as? [String: String] { + if updateSettings(settings) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save settings to disk"]] + } + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_UPDATE_BLACKLIST" { + #if os(macOS) + if let blacklist = message?["blacklist"] as? [String] { + var manifest = getManifest() + manifest.blacklist = blacklist + if !updateManifest(with: manifest) { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save blacklist to disk"]] + } else { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } + } else { + inBoundError = true + } + #endif + } + // send inBoundError if found + if inBoundError { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to parse inbound message"]] + } + context.completeRequest(returningItems: [response], completionHandler: nil) + } } diff --git a/xcode/Shared/Preferences.swift b/xcode/Shared/Preferences.swift index 377ccd3e..f486cede 100644 --- a/xcode/Shared/Preferences.swift +++ b/xcode/Shared/Preferences.swift @@ -8,64 +8,64 @@ let bundleIdentifier = Bundle.main.bundleIdentifier! let extIdentifier = Bundle.main.infoDictionary?["US_EXT_IDENTIFIER"] as! String func getDefaultScriptsDirectoryUrl() -> URL { - var url: URL - if #available(macOS 13.0, iOS 16.0, *) { - url = getDocumentsDirectory().appending(path: "scripts") - } else { - url = getDocumentsDirectory().appendingPathComponent("scripts") - } - // if not in safari extension environment, replace with extension path - if bundleIdentifier != extIdentifier { - var path: String - if #available(macOS 13.0, iOS 16.0, *) { - path = url.path(percentEncoded: false) - } else { - path = url.path.removingPercentEncoding ?? url.path - } - let truePath = path.replacingOccurrences(of: bundleIdentifier, with: extIdentifier) - url = URL(fileURLWithPath: truePath, isDirectory: true) - } - return url + var url: URL + if #available(macOS 13.0, iOS 16.0, *) { + url = getDocumentsDirectory().appending(path: "scripts") + } else { + url = getDocumentsDirectory().appendingPathComponent("scripts") + } + // if not in safari extension environment, replace with extension path + if bundleIdentifier != extIdentifier { + var path: String + if #available(macOS 13.0, iOS 16.0, *) { + path = url.path(percentEncoded: false) + } else { + path = url.path.removingPercentEncoding ?? url.path + } + let truePath = path.replacingOccurrences(of: bundleIdentifier, with: extIdentifier) + url = URL(fileURLWithPath: truePath, isDirectory: true) + } + return url } func isCurrentDefaultScriptsDirectory() -> Bool { - return Preferences.scriptsDirectoryUrl == getDefaultScriptsDirectoryUrl() + return Preferences.scriptsDirectoryUrl == getDefaultScriptsDirectoryUrl() } func isCurrentInitialScriptsDirectory() -> Bool { - let url = Preferences.scriptsDirectoryUrl.standardizedFileURL - return url == getDocumentsDirectory().standardizedFileURL + let url = Preferences.scriptsDirectoryUrl.standardizedFileURL + return url == getDocumentsDirectory().standardizedFileURL } func getCurrentScriptsDirectoryString() -> String { - let url = Preferences.scriptsDirectoryUrl.standardizedFileURL - if url == getDocumentsDirectory().standardizedFileURL { - return "Userscripts App Documents" - } - if #available(macOS 13.0, iOS 16.0, *) { - return url.path(percentEncoded: false) - } else { - return url.path.removingPercentEncoding ?? url.path - } + let url = Preferences.scriptsDirectoryUrl.standardizedFileURL + if url == getDocumentsDirectory().standardizedFileURL { + return "Userscripts App Documents" + } + if #available(macOS 13.0, iOS 16.0, *) { + return url.path(percentEncoded: false) + } else { + return url.path.removingPercentEncoding ?? url.path + } } // https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties#Property-Wrappers // https://docs.swift.org/swift-book/documentation/the-swift-programming-language/generics/ @propertyWrapper private struct SharedUserDefaults { - private let key: String - private let defaultValue: T + private let key: String + private let defaultValue: T - init(wrappedValue: T, _ key: String) { - self.key = key - self.defaultValue = wrappedValue - SDefaults?.register(defaults: [key: wrappedValue]) - } + init(wrappedValue: T, _ key: String) { + self.key = key + self.defaultValue = wrappedValue + SDefaults?.register(defaults: [key: wrappedValue]) + } - var wrappedValue: T { - get { SDefaults?.object(forKey: key) as? T ?? defaultValue } - set { SDefaults?.set(newValue, forKey: key) } - } + var wrappedValue: T { + get { SDefaults?.object(forKey: key) as? T ?? defaultValue } + set { SDefaults?.set(newValue, forKey: key) } + } } #if os(macOS) @@ -84,149 +84,149 @@ private struct SharedUserDefaults { */ @propertyWrapper private struct SecurityScopedBookmark { - private let key: String - private let keySetter: String - private let keyTransfer: String - private let keySecurityScoped: String - private let defaultValue: URL - - init(wrappedValue: URL, _ key: String) { - self.key = key - self.keySetter = key + "/SetterId" - self.keyTransfer = key + "/Transfer" - self.keySecurityScoped = key + "/SecurityScoped/" + bundleIdentifier - self.defaultValue = wrappedValue - } + private let key: String + private let keySetter: String + private let keyTransfer: String + private let keySecurityScoped: String + private let defaultValue: URL - private func createBookmark(_ url: URL, _ withSecurityScope: Bool) -> Data? { - do { - return try url.bookmarkData( - options: withSecurityScope ? .withSecurityScope : [], - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } + init(wrappedValue: URL, _ key: String) { + self.key = key + self.keySetter = key + "/SetterId" + self.keyTransfer = key + "/Transfer" + self.keySecurityScoped = key + "/SecurityScoped/" + bundleIdentifier + self.defaultValue = wrappedValue + } - private func resolvBookmark(_ data: Data, _ withSecurityScope: Bool, _ isStale: inout Bool) -> URL? { - do { - return try URL( - resolvingBookmarkData: data, - options: withSecurityScope ? .withSecurityScope : [], - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } + private func createBookmark(_ url: URL, _ withSecurityScope: Bool) -> Data? { + do { + return try url.bookmarkData( + options: withSecurityScope ? .withSecurityScope : [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } - private func updateBookmark() { - guard let setterIdentifier = SDefaults?.string(forKey: keySetter) else { - logger?.debug("\(#function, privacy: .public) - setterId not exist: \(key, privacy: .public)") - return - } - guard bundleIdentifier != setterIdentifier else { return } // update only in non-setter environment - guard let data = SDefaults?.data(forKey: keyTransfer) else { // no need to update due empty data - logger?.debug("\(#function, privacy: .public) - no update: \(key, privacy: .public)") - return - } - var isStale = false // no need to renew since will remove anyway - guard let url = resolvBookmark(data, false, &isStale) else { // get URL bookmark with an implicit security scope - removeBookmark() - return - } - defer { url.stopAccessingSecurityScopedResource() } // revoke implicitly starts security-scoped access - if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope - logger?.info("\(#function, privacy: .public) - update bookmark: \(key, privacy: .public) \(url, privacy: .public)") - SDefaults?.removeObject(forKey: keyTransfer) - SDefaults?.set(data, forKey: keySecurityScoped) - } - } - - private func removeBookmark() { - logger?.info("\(#function, privacy: .public) - remove invalid bookmark: \(key, privacy: .public)") - SDefaults?.removeObject(forKey: keyTransfer) - SDefaults?.removeObject(forKey: keySecurityScoped) - if let setterIdentifier = SDefaults?.string(forKey: keySetter) { - SDefaults?.removeObject(forKey: key + "/SecurityScoped/" + setterIdentifier) - } - } - - // NOTE: This function will be deleted after several public versions of v4.5.0 - private func legacyBookmarkImporter() { - guard key == "ScriptsDirectoryUrlBookmarkData" else { return } - guard bundleIdentifier == extIdentifier else { return } - func legacyCleanup() { - logger?.debug("\(#function, privacy: .public) - cleanup legacy bookmark data") - SDefaults?.removeObject(forKey: "hostSelectedSaveLocation") // shared - UserDefaults.standard.removeObject(forKey: "saveLocation") // ext - UserDefaults.standard.removeObject(forKey: "userSaveLocation") // ext - } - guard let data = UserDefaults.standard.data(forKey: "userSaveLocation") else { // get from old key - logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") - return - } - guard SDefaults?.string(forKey: keySetter) == nil else { // already a new key - return legacyCleanup() - } - logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") - var isStale = false // no need to renew since will remove anyway - guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope - return legacyCleanup() - } - guard url.startAccessingSecurityScopedResource() else { - return legacyCleanup() - } - defer { url.stopAccessingSecurityScopedResource() } - guard let transferData = createBookmark(url, false) else { - return legacyCleanup() - } - SDefaults?.set(bundleIdentifier, forKey: keySetter) - SDefaults?.set(transferData, forKey: keyTransfer) // set URL bookmark with an implicit security scope - SDefaults?.set(data, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope - legacyCleanup() - } + private func resolvBookmark(_ data: Data, _ withSecurityScope: Bool, _ isStale: inout Bool) -> URL? { + do { + return try URL( + resolvingBookmarkData: data, + options: withSecurityScope ? .withSecurityScope : [], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } - var wrappedValue: URL { - get { - legacyBookmarkImporter() - updateBookmark() - logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") - guard let data = SDefaults?.data(forKey: keySecurityScoped) else { - logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") - return defaultValue - } - var isStale = false - guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope - removeBookmark() // remove data that cannot be resolved - return defaultValue - } - if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark - defer { url.stopAccessingSecurityScopedResource() } - if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope - logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") - SDefaults?.set(data, forKey: keySecurityScoped) - } - } - return url - } - set(url) { - let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter - logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") - guard let tdata = createBookmark(url, false), let sdata = createBookmark(url, true) else { - logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") - return - } - SDefaults?.set(bundleIdentifier, forKey: keySetter) - SDefaults?.set(tdata, forKey: keyTransfer) // set URL bookmark with an implicit security scope - SDefaults?.set(sdata, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope - } - } + private func updateBookmark() { + guard let setterIdentifier = SDefaults?.string(forKey: keySetter) else { + logger?.debug("\(#function, privacy: .public) - setterId not exist: \(key, privacy: .public)") + return + } + guard bundleIdentifier != setterIdentifier else { return } // update only in non-setter environment + guard let data = SDefaults?.data(forKey: keyTransfer) else { // no need to update due empty data + logger?.debug("\(#function, privacy: .public) - no update: \(key, privacy: .public)") + return + } + var isStale = false // no need to renew since will remove anyway + guard let url = resolvBookmark(data, false, &isStale) else { // get URL bookmark with an implicit security scope + removeBookmark() + return + } + defer { url.stopAccessingSecurityScopedResource() } // revoke implicitly starts security-scoped access + if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope + logger?.info("\(#function, privacy: .public) - update bookmark: \(key, privacy: .public) \(url, privacy: .public)") + SDefaults?.removeObject(forKey: keyTransfer) + SDefaults?.set(data, forKey: keySecurityScoped) + } + } + + private func removeBookmark() { + logger?.info("\(#function, privacy: .public) - remove invalid bookmark: \(key, privacy: .public)") + SDefaults?.removeObject(forKey: keyTransfer) + SDefaults?.removeObject(forKey: keySecurityScoped) + if let setterIdentifier = SDefaults?.string(forKey: keySetter) { + SDefaults?.removeObject(forKey: key + "/SecurityScoped/" + setterIdentifier) + } + } + + // NOTE: This function will be deleted after several public versions of v4.5.0 + private func legacyBookmarkImporter() { + guard key == "ScriptsDirectoryUrlBookmarkData" else { return } + guard bundleIdentifier == extIdentifier else { return } + func legacyCleanup() { + logger?.debug("\(#function, privacy: .public) - cleanup legacy bookmark data") + SDefaults?.removeObject(forKey: "hostSelectedSaveLocation") // shared + UserDefaults.standard.removeObject(forKey: "saveLocation") // ext + UserDefaults.standard.removeObject(forKey: "userSaveLocation") // ext + } + guard let data = UserDefaults.standard.data(forKey: "userSaveLocation") else { // get from old key + logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") + return + } + guard SDefaults?.string(forKey: keySetter) == nil else { // already a new key + return legacyCleanup() + } + logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") + var isStale = false // no need to renew since will remove anyway + guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope + return legacyCleanup() + } + guard url.startAccessingSecurityScopedResource() else { + return legacyCleanup() + } + defer { url.stopAccessingSecurityScopedResource() } + guard let transferData = createBookmark(url, false) else { + return legacyCleanup() + } + SDefaults?.set(bundleIdentifier, forKey: keySetter) + SDefaults?.set(transferData, forKey: keyTransfer) // set URL bookmark with an implicit security scope + SDefaults?.set(data, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope + legacyCleanup() + } + + var wrappedValue: URL { + get { + legacyBookmarkImporter() + updateBookmark() + logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") + guard let data = SDefaults?.data(forKey: keySecurityScoped) else { + logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") + return defaultValue + } + var isStale = false + guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope + removeBookmark() // remove data that cannot be resolved + return defaultValue + } + if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark + defer { url.stopAccessingSecurityScopedResource() } + if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope + logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") + SDefaults?.set(data, forKey: keySecurityScoped) + } + } + return url + } + set(url) { + let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter + logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") + guard let tdata = createBookmark(url, false), let sdata = createBookmark(url, true) else { + logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") + return + } + SDefaults?.set(bundleIdentifier, forKey: keySetter) + SDefaults?.set(tdata, forKey: keyTransfer) // set URL bookmark with an implicit security scope + SDefaults?.set(sdata, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope + } + } } #elseif os(iOS) /* https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories#3331285 @@ -236,91 +236,91 @@ private struct SecurityScopedBookmark { */ @propertyWrapper private struct SecurityScopedBookmark { - private let key: String - private let defaultValue: URL - - init(wrappedValue: URL, _ key: String) { - self.key = key - self.defaultValue = wrappedValue - } - - private func createBookmark(_ url: URL) -> Data? { - do { - return try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } + private let key: String + private let defaultValue: URL + + init(wrappedValue: URL, _ key: String) { + self.key = key + self.defaultValue = wrappedValue + } + + private func createBookmark(_ url: URL) -> Data? { + do { + return try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } + + private func resolvBookmark(_ data: Data, _ isStale: inout Bool) -> URL? { + do { + return try URL(resolvingBookmarkData: data, bookmarkDataIsStale: &isStale) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } - private func resolvBookmark(_ data: Data, _ isStale: inout Bool) -> URL? { - do { - return try URL(resolvingBookmarkData: data, bookmarkDataIsStale: &isStale) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } - - // NOTE: This function will be deleted after several public versions of v4.5.0 - private func legacyBookmarkImporter() { - guard let data = SDefaults?.data(forKey: "iosReadLocation") else { // get from old key - logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") - return - } - logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") - SDefaults?.set(data, forKey: key) - SDefaults?.removeObject(forKey: "iosReadLocation") - } + // NOTE: This function will be deleted after several public versions of v4.5.0 + private func legacyBookmarkImporter() { + guard let data = SDefaults?.data(forKey: "iosReadLocation") else { // get from old key + logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") + return + } + logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") + SDefaults?.set(data, forKey: key) + SDefaults?.removeObject(forKey: "iosReadLocation") + } - var wrappedValue: URL { - get { - legacyBookmarkImporter() - logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") - guard let data = SDefaults?.data(forKey: key) else { - logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") - return defaultValue - } - var isStale = false - guard let url = resolvBookmark(data, &isStale) else { // get security-scoped URL - SDefaults?.removeObject(forKey: key) // remove data that cannot be resolved - return defaultValue - } - if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark - defer { url.stopAccessingSecurityScopedResource() } - if let data = createBookmark(url) { // set security-scoped URL - logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") - SDefaults?.set(data, forKey: key) - } - } - return url - } - set(url) { - let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter - logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") - let didStartAccessing = url.startAccessingSecurityScopedResource() - defer { - if didStartAccessing { url.stopAccessingSecurityScopedResource() } - } - guard let data = createBookmark(url) else { - logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") - return - } - SDefaults?.set(data, forKey: key) // set security-scoped URL - } - } + var wrappedValue: URL { + get { + legacyBookmarkImporter() + logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") + guard let data = SDefaults?.data(forKey: key) else { + logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") + return defaultValue + } + var isStale = false + guard let url = resolvBookmark(data, &isStale) else { // get security-scoped URL + SDefaults?.removeObject(forKey: key) // remove data that cannot be resolved + return defaultValue + } + if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark + defer { url.stopAccessingSecurityScopedResource() } + if let data = createBookmark(url) { // set security-scoped URL + logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") + SDefaults?.set(data, forKey: key) + } + } + return url + } + set(url) { + let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter + logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { url.stopAccessingSecurityScopedResource() } + } + guard let data = createBookmark(url) else { + logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") + return + } + SDefaults?.set(data, forKey: key) // set security-scoped URL + } + } } #endif // Define shared preferences, default values ​​determine data type struct Preferences { /* Example preference, can be get or set with: Preferences.propertyName - @SharedUserDefaults("SharedUserDefaultsKeyName") - static var propertyName = "DefaultValue" // String + @SharedUserDefaults("SharedUserDefaultsKeyName") + static var propertyName = "DefaultValue" // String */ - @SecurityScopedBookmark("ScriptsDirectoryUrlBookmarkData") - static var scriptsDirectoryUrl = getDefaultScriptsDirectoryUrl() + @SecurityScopedBookmark("ScriptsDirectoryUrlBookmarkData") + static var scriptsDirectoryUrl = getDefaultScriptsDirectoryUrl() - @SharedUserDefaults("EnableLogger") - static var enableLogger = false + @SharedUserDefaults("EnableLogger") + static var enableLogger = false } diff --git a/xcode/Shared/UrlPolyfill.swift b/xcode/Shared/UrlPolyfill.swift index 96cebed5..dc76e2b3 100644 --- a/xcode/Shared/UrlPolyfill.swift +++ b/xcode/Shared/UrlPolyfill.swift @@ -1,96 +1,96 @@ import Foundation extension CharacterSet { - // https://developer.apple.com/documentation/foundation/characterset#2902136 - public static let urlAllowed_ = CharacterSet(charactersIn: "#") - .union(.urlFragmentAllowed) - .union(.urlHostAllowed) - .union(.urlPasswordAllowed) - .union(.urlPathAllowed) - .union(.urlQueryAllowed) - .union(.urlUserAllowed) + // https://developer.apple.com/documentation/foundation/characterset#2902136 + public static let urlAllowed_ = CharacterSet(charactersIn: "#") + .union(.urlFragmentAllowed) + .union(.urlHostAllowed) + .union(.urlPasswordAllowed) + .union(.urlPathAllowed) + .union(.urlQueryAllowed) + .union(.urlUserAllowed) } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI func encodeURI(_ uri: String) -> String { - // https://developer.apple.com/documentation/foundation/characterset#2902136 -// var urlAllowed = CharacterSet(charactersIn: "#") -// urlAllowed.formUnion(.urlFragmentAllowed) -// urlAllowed.formUnion(.urlHostAllowed) -// urlAllowed.formUnion(.urlPasswordAllowed) -// urlAllowed.formUnion(.urlPathAllowed) -// urlAllowed.formUnion(.urlQueryAllowed) -// urlAllowed.formUnion(.urlUserAllowed) - return uri.addingPercentEncoding(withAllowedCharacters: .urlAllowed_) ?? uri + // https://developer.apple.com/documentation/foundation/characterset#2902136 +// var urlAllowed = CharacterSet(charactersIn: "#") +// urlAllowed.formUnion(.urlFragmentAllowed) +// urlAllowed.formUnion(.urlHostAllowed) +// urlAllowed.formUnion(.urlPasswordAllowed) +// urlAllowed.formUnion(.urlPathAllowed) +// urlAllowed.formUnion(.urlQueryAllowed) +// urlAllowed.formUnion(.urlUserAllowed) + return uri.addingPercentEncoding(withAllowedCharacters: .urlAllowed_) ?? uri } func fixedURL(string urlString: String) -> URL? { - let rawUrlString = urlString.removingPercentEncoding ?? urlString - var url: URL? - if #available(macOS 14.0, iOS 17.0, *) { - url = URL(string: rawUrlString, encodingInvalidCharacters: true) - } else { - url = URL(string: encodeURI(rawUrlString)) - } - return url + let rawUrlString = urlString.removingPercentEncoding ?? urlString + var url: URL? + if #available(macOS 14.0, iOS 17.0, *) { + url = URL(string: rawUrlString, encodingInvalidCharacters: true) + } else { + url = URL(string: encodeURI(rawUrlString)) + } + return url } // https://developer.mozilla.org/en-US/docs/Web/API/URL func jsLikeURL(_ urlString: String, baseString: String? = nil) -> [String: String]? { - var _url: URL? - if let baseString = baseString { - guard let baseUrl = fixedURL(string: baseString) else { - return nil - } - _url = URL(string: urlString, relativeTo: baseUrl) - } else { - _url = fixedURL(string: urlString) - } - guard let url = _url else { - return nil - } - - guard let scheme = url.scheme else { - return nil - } - var port = (url.port == nil) ? "" : String(url.port!) - if (scheme == "http" && port == "80") { port = "" } - if (scheme == "https" && port == "443") { port = "" } - if #available(macOS 13.0, iOS 16.0, *) { - let hostname = url.host(percentEncoded: true) ?? "" - let host = (port == "") ? hostname : "\(hostname):\(port)" - let query = url.query(percentEncoded: true) ?? "" - let fragment = url.fragment(percentEncoded: true) ?? "" - return [ - "hash": fragment == "" ? "" : "#\(fragment)", - "host": host, - "hostname": hostname, - // "href": url.absoluteString, - "origin": "\(scheme)://\(host)", - "password": url.password(percentEncoded: true) ?? "", - "pathname": url.path(percentEncoded: true), - "port": port, - "protocol": "\(scheme):", - "search": query == "" ? "" : "?\(query)", - "username": url.user(percentEncoded: true) ?? "" - ] - } else { - let hostname = url.host ?? "" - let host = (port == "") ? hostname : "\(hostname):\(port)" - let query = url.query ?? "" - let fragment = url.fragment ?? "" - return [ - "hash": fragment == "" ? "" : "#\(fragment)", - "host": host, - "hostname": hostname, - // "href": url.absoluteString, - "origin": "\(scheme)://\(host)", - "password": url.password ?? "", - "pathname": url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? url.path, - "port": port, - "protocol": "\(scheme):", - "search": query == "" ? "" : "?\(query)", - "username": url.user?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? "" - ] - } + var _url: URL? + if let baseString = baseString { + guard let baseUrl = fixedURL(string: baseString) else { + return nil + } + _url = URL(string: urlString, relativeTo: baseUrl) + } else { + _url = fixedURL(string: urlString) + } + guard let url = _url else { + return nil + } + + guard let scheme = url.scheme else { + return nil + } + var port = (url.port == nil) ? "" : String(url.port!) + if (scheme == "http" && port == "80") { port = "" } + if (scheme == "https" && port == "443") { port = "" } + if #available(macOS 13.0, iOS 16.0, *) { + let hostname = url.host(percentEncoded: true) ?? "" + let host = (port == "") ? hostname : "\(hostname):\(port)" + let query = url.query(percentEncoded: true) ?? "" + let fragment = url.fragment(percentEncoded: true) ?? "" + return [ + "hash": fragment == "" ? "" : "#\(fragment)", + "host": host, + "hostname": hostname, + // "href": url.absoluteString, + "origin": "\(scheme)://\(host)", + "password": url.password(percentEncoded: true) ?? "", + "pathname": url.path(percentEncoded: true), + "port": port, + "protocol": "\(scheme):", + "search": query == "" ? "" : "?\(query)", + "username": url.user(percentEncoded: true) ?? "" + ] + } else { + let hostname = url.host ?? "" + let host = (port == "") ? hostname : "\(hostname):\(port)" + let query = url.query ?? "" + let fragment = url.fragment ?? "" + return [ + "hash": fragment == "" ? "" : "#\(fragment)", + "host": host, + "hostname": hostname, + // "href": url.absoluteString, + "origin": "\(scheme)://\(host)", + "password": url.password ?? "", + "pathname": url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? url.path, + "port": port, + "protocol": "\(scheme):", + "search": query == "" ? "" : "?\(query)", + "username": url.user?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? "" + ] + } } diff --git a/xcode/Shared/Utilities.swift b/xcode/Shared/Utilities.swift index d5c55cc9..c64c2fc6 100644 --- a/xcode/Shared/Utilities.swift +++ b/xcode/Shared/Utilities.swift @@ -2,42 +2,42 @@ import SwiftUI import os.log func USLogger(_ category: String) -> Logger? { - let subsystem = Bundle.main.bundleIdentifier! + let subsystem = Bundle.main.bundleIdentifier! #if DEBUG // Always enable logger in DEBUG builds - return Logger(subsystem: subsystem, category: category) + return Logger(subsystem: subsystem, category: category) #else - if Preferences.enableLogger { - return Logger(subsystem: subsystem, category: category) - } - return nil + if Preferences.enableLogger { + return Logger(subsystem: subsystem, category: category) + } + return nil #endif } func getDocumentsDirectory() -> URL { - let fm = FileManager.default - let paths = fm.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = paths[0] - return documentsDirectory + let fm = FileManager.default + let paths = fm.urls(for: .documentDirectory, in: .userDomainMask) + let documentsDirectory = paths[0] + return documentsDirectory } func directoryExists(path: String) -> Bool { - var isDirectory = ObjCBool(true) - let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - let inTrash = path.contains(".Trash") ? false : true - return exists && inTrash && isDirectory.boolValue + var isDirectory = ObjCBool(true) + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + let inTrash = path.contains(".Trash") ? false : true + return exists && inTrash && isDirectory.boolValue } func getPlatform() -> String { - var platform:String - #if os(iOS) - if UIDevice.current.userInterfaceIdiom == .pad { - platform = "ipados" - } - else { - platform = "ios" - } - #elseif os(macOS) - platform = "macos" - #endif - return platform + var platform:String +#if os(iOS) + if UIDevice.current.userInterfaceIdiom == .pad { + platform = "ipados" + } + else { + platform = "ios" + } +#elseif os(macOS) + platform = "macos" +#endif + return platform } diff --git a/xcode/Tests-Mac/UrlCodecTests.swift b/xcode/Tests-Mac/UrlCodecTests.swift index a11ac25a..0188cd00 100644 --- a/xcode/Tests-Mac/UrlCodecTests.swift +++ b/xcode/Tests-Mac/UrlCodecTests.swift @@ -2,197 +2,198 @@ import XCTest final class UrlCodecTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - - func testEncodeURI() throws { - - func check(_ urlString: String, _ res: String) -> Bool { - let url = encodeURI(urlString) - if (url != res) { - print(urlString, url) - return false - } - return true - } - - XCTAssert(check("http://user:password@host:80/path?q=1#id", - "http://user:password@host:80/path?q=1#id")) - - XCTAssert(check("https://用户名:密码@中.文:80/path/中文/?a=中文#中文", - "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD%E6%96%87#%E4%B8%AD%E6%96%87")) - - XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", - "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%25E6%2596%2587/?a=%E4%B8%AD&b=%25E6%2596%2587&c#%E4%B8%AD%E6%96%87")) - - } - - func testJsLikeURL() throws { - - func diffPrint(_ dA: Dictionary?, _ dB: Dictionary) { - guard let dA = dA else { - return print("dA is nil") - } - for k in dA.keys { - if dA[k] != dB[k] { - print(k, dA[k] ?? "nil", dB[k] ?? "nil") - } - } - } - - func check(_ urlString: String, _ res: Dictionary) -> Bool { - let url = jsLikeURL(urlString) - if (url != res) { - print(urlString) - diffPrint(url, res) - return false - } - return true - } - - /** javascript get res - url = new URL("http://user:password@host:80/path?q=1#id") - res = {} - for(let k of ["hash","host","hostname","href","origin","password","pathname","port","protocol","search","username"]){ - res[k] = url[k]; - } - JSON.stringify(res) - */ - - XCTAssert(check("http://user:password@host:80/path?q=1#id", [ - "hash": "#id", - "host": "host", - "hostname": "host", - // "href": "http://user:password@host/path?q=1#id", - "origin": "http://host", - "password": "password", - "pathname": "/path", - "port": "", - "protocol": "http:", - "search": "?q=1", - "username": "user" - ])) - - XCTAssert(check("http://host.test:8080/path?#id", [ - "hash": "#id", - "host": "host.test:8080", - "hostname": "host.test", - // "href": "http://host.test:8080/path?#id", - "origin": "http://host.test:8080", - "password": "", - "pathname": "/path", - "port": "8080", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test:8080/path?", [ - "hash": "", - "host": "host.test:8080", - "hostname": "host.test", - // "href": "http://host.test:8080/path?", - "origin": "http://host.test:8080", - "password": "", - "pathname": "/path", - "port": "8080", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test/path#id", [ - "hash": "#id", - "host": "host.test", - "hostname": "host.test", - // "href": "http://host.test/path#id", - "origin": "http://host.test", - "password": "", - "pathname": "/path", - "port": "", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test/path", [ - "hash": "", - "host": "host.test", - "hostname": "host.test", - // "href": "http://host.test/path", - "origin": "http://host.test", - "password": "", - "pathname": "/path", - "port": "", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test/", [ - "hash": "", - "host": "host.test", - "hostname": "host.test", - // "href": "http://host.test/", - "origin": "http://host.test", - "password": "", - "pathname": "/", - "port": "", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("https://用户名:密码@host.test:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ - "hash": "#%E4%B8%AD%E6%96%87", - "host": "host.test:80", - "hostname": "host.test", - // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@host.test:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", - "origin": "https://host.test:80", - "password": "%E5%AF%86%E7%A0%81", - "pathname": "/path/%E4%B8%AD%E6%96%87/", - "port": "80", - "protocol": "https:", - "search": "?a=%E4%B8%AD&b=%E6%96%87&c", - "username": "%E7%94%A8%E6%88%B7%E5%90%8D" - ])) - - if #available(macOS 14.0, iOS 17.0, *) { - XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ - "hash": "#%E4%B8%AD%E6%96%87", - "host": "xn--fiq.xn--7dv:80", - "hostname": "xn--fiq.xn--7dv", - // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", - "origin": "https://xn--fiq.xn--7dv:80", - "password": "%E5%AF%86%E7%A0%81", - "pathname": "/path/%E4%B8%AD%E6%96%87/", - "port": "80", - "protocol": "https:", - "search": "?a=%E4%B8%AD&b=%E6%96%87&c", - "username": "%E7%94%A8%E6%88%B7%E5%90%8D" - ])) - } - - } // testJsLikeURL() -> END + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + + func testEncodeURI() throws { + + func check(_ urlString: String, _ res: String) -> Bool { + let url = encodeURI(urlString) + if (url != res) { + print(urlString, url) + return false + } + return true + } + + XCTAssert(check("http://user:password@host:80/path?q=1#id", + "http://user:password@host:80/path?q=1#id")) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中文/?a=中文#中文", + "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD%E6%96%87#%E4%B8%AD%E6%96%87")) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", + "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%25E6%2596%2587/?a=%E4%B8%AD&b=%25E6%2596%2587&c#%E4%B8%AD%E6%96%87")) + + } + + func testJsLikeURL() throws { + + func diffPrint(_ dA: Dictionary?, _ dB: Dictionary) { + guard let dA = dA else { + return print("dA is nil") + } + for k in dA.keys { + if dA[k] != dB[k] { + print(k, dA[k] ?? "nil", dB[k] ?? "nil") + } + } + } + + func check(_ urlString: String, _ res: Dictionary) -> Bool { + let url = jsLikeURL(urlString) + if (url != res) { + print(urlString) + diffPrint(url, res) + return false + } + return true + } + + /** javascript get res + + url = new URL("http://user:password@host:80/path?q=1#id") + res = {} + for(let k of ["hash","host","hostname","href","origin","password","pathname","port","protocol","search","username"]){ + res[k] = url[k]; + } + JSON.stringify(res) + */ + + XCTAssert(check("http://user:password@host:80/path?q=1#id", [ + "hash": "#id", + "host": "host", + "hostname": "host", + // "href": "http://user:password@host/path?q=1#id", + "origin": "http://host", + "password": "password", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "?q=1", + "username": "user" + ])) + + XCTAssert(check("http://host.test:8080/path?#id", [ + "hash": "#id", + "host": "host.test:8080", + "hostname": "host.test", + // "href": "http://host.test:8080/path?#id", + "origin": "http://host.test:8080", + "password": "", + "pathname": "/path", + "port": "8080", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test:8080/path?", [ + "hash": "", + "host": "host.test:8080", + "hostname": "host.test", + // "href": "http://host.test:8080/path?", + "origin": "http://host.test:8080", + "password": "", + "pathname": "/path", + "port": "8080", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/path#id", [ + "hash": "#id", + "host": "host.test", + "hostname": "host.test", + // "href": "http://host.test/path#id", + "origin": "http://host.test", + "password": "", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/path", [ + "hash": "", + "host": "host.test", + "hostname": "host.test", + // "href": "http://host.test/path", + "origin": "http://host.test", + "password": "", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/", [ + "hash": "", + "host": "host.test", + "hostname": "host.test", + // "href": "http://host.test/", + "origin": "http://host.test", + "password": "", + "pathname": "/", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("https://用户名:密码@host.test:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + "hash": "#%E4%B8%AD%E6%96%87", + "host": "host.test:80", + "hostname": "host.test", + // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@host.test:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://host.test:80", + "password": "%E5%AF%86%E7%A0%81", + "pathname": "/path/%E4%B8%AD%E6%96%87/", + "port": "80", + "protocol": "https:", + "search": "?a=%E4%B8%AD&b=%E6%96%87&c", + "username": "%E7%94%A8%E6%88%B7%E5%90%8D" + ])) + + if #available(macOS 14.0, iOS 17.0, *) { + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + "hash": "#%E4%B8%AD%E6%96%87", + "host": "xn--fiq.xn--7dv:80", + "hostname": "xn--fiq.xn--7dv", + // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://xn--fiq.xn--7dv:80", + "password": "%E5%AF%86%E7%A0%81", + "pathname": "/path/%E4%B8%AD%E6%96%87/", + "port": "80", + "protocol": "https:", + "search": "?a=%E4%B8%AD&b=%E6%96%87&c", + "username": "%E7%94%A8%E6%88%B7%E5%90%8D" + ])) + } + + } // testJsLikeURL() -> END } diff --git a/xcode/Tests-Mac/UserscriptsTests.swift b/xcode/Tests-Mac/UserscriptsTests.swift index 24d70009..451f9e99 100644 --- a/xcode/Tests-Mac/UserscriptsTests.swift +++ b/xcode/Tests-Mac/UserscriptsTests.swift @@ -11,196 +11,196 @@ import XCTest class UserscriptsTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testStringSanitization() throws { - // given - let strs = [ - "String", - "https://something.com/?foo=12", - "I have backslashes \\\\", - ".....ok", - ":Akneh.,><>dkie:lm", - "..解锁B站大会员番剧、", - "解锁B站大会员番剧、B站视频解析下载;全网VIP视频免费破解去广告;全网音乐直接下载;油管、Facebook等国外视频解析下载;网盘搜索引擎破解无限下载等", - "5CLksm3AAbb2F2F2f----___--+87363&^#%o%3O3", - "Example Userscript Name" - ] - - // when - var result = [String]() - for str in strs { - let sanitizedString = sanitize(str) - let unsanitizedString = unsanitize(sanitizedString) - result.append(unsanitizedString) - } - - // then - XCTAssert(result.elementsEqual(strs)) - } - - func testEncodedCheck() throws { - let urls = [ - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", - "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", - "https://cdn.frankerfacez.com/static/ffz_injector.user.js", - "http://www.k21p.com/example.user.js", // add http protocol - "https://example.test/%%%test.user.js", // removingPercentEncoding -> nil - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js" - ] - var result = [String]() - for url in urls { - if isEncoded(url) { - result.append(url) - } - } - // 2 urls already percent encoded - XCTAssert(result.count == 2) - } - - func testGetRemoteFileContents() throws { - var urls:[String] = [ - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", - "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", - "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", - "https://cdn.frankerfacez.com/static/ffz_injector.user.js", - "http://www.k21p.com/example.user.js" // add http protocol - ] - if #available(macOS 14.0, iOS 17.0, *) { - urls += [ - "https://☁️.com/", // IDN / Non-Punycode-encoded domain name - ] - } - for url in urls { - if getRemoteFileContents(url) == nil { - print(#function, url) - XCTAssert(false) - } - } - } - - func testFileRemoteUpdate() throws { - let urls = [ - "https://www.k21p.com/example.user.js", - "https://www.k21p.com/example.user.js?foo=bar", // query string - "http://www.k21p.com/example.user.js", // http protocol - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", // non latin chars - "https://www.k21p.com/example.user.jsx" // should fail - - ] - var result = [Int]() - for url in urls { - let content = """ - // ==UserScript== - // @name test - // @match *://*/* - // @version 0.1 - // @updateURL http://www.k21p.com/example.user.js - // @downloadURL \(url) - // ==/UserScript== - """; - let response = getFileRemoteUpdate(content) - if !response.keys.contains("error") { - result.append(1) - } - } - XCTAssert(result.count == (urls.count - 1)) - } - - func testMatching() throws { - let patternDict = [ - "*://*/*": [ - "https://www.bing.com/", - "https://example.org/foo/bar.html", - "https://a.org/some/path/", - "https://☁️.com/" - ], - "*://*.mozilla.org/*": [ - "http://mozilla.org/", - "https://mozilla.org/", - "https://b.mozilla.org/path/" - ], - "*://www.google.com/*": [ - "https://www.google.com/://aa", - "https://www.google.com/preferences?prev=https://www.google.com/", - "https://www.google.com/preferences?prev=", - "https://www.google.com/" - ], - "*://localhost/*": [ - "http://localhost:8000/", - "https://localhost:3000/foo.html" - ], - "http://127.0.0.1/*": [ - "http://127.0.0.1/", - "http://127.0.0.1/foo/bar.html" - ], - "*://*.example.com/*?a=1*": [ - "http://example.com/?a=1", - "https://www.example.com/index?a=1&b=2" - ] - ] - let patternDictFails = [ - "https://www.example.com/*": [ - "file://www.example.com/", - "ftp://www.example.com/", - "ws://www.example.com/", - "http://www.example.com/" - ], - "http://www.example.com/index.html": [ - "http://www.example.com/", - "https://www.example.com/index.html" - ], - "*://localhost/*": [ - "https://localhost.com/", - "ftp://localhost:8080/" - ], - "https://www.example*/*": [ - "https://www.example.com/" - ], - "*://*.example.com/*?a=1*": [ - "http://example.com/", - "https://www.example.com/?a=2" - ] - ] - for (pattern, urls) in patternDict { - for url in urls { - if !match(url, pattern) { - print(#function, "patternDict", url, pattern) - XCTAssert(false) - } - } - } - for (pattern, urls) in patternDictFails { - for url in urls { - if match(url, pattern) { - print(#function, "patternDictFails", url, pattern) - XCTAssert(false) - } - } - } - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. - } - } + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testStringSanitization() throws { + // given + let strs = [ + "String", + "https://something.com/?foo=12", + "I have backslashes \\\\", + ".....ok", + ":Akneh.,><>dkie:lm", + "..解锁B站大会员番剧、", + "解锁B站大会员番剧、B站视频解析下载;全网VIP视频免费破解去广告;全网音乐直接下载;油管、Facebook等国外视频解析下载;网盘搜索引擎破解无限下载等", + "5CLksm3AAbb2F2F2f----___--+87363&^#%o%3O3", + "Example Userscript Name" + ] + + // when + var result = [String]() + for str in strs { + let sanitizedString = sanitize(str) + let unsanitizedString = unsanitize(sanitizedString) + result.append(unsanitizedString) + } + + // then + XCTAssert(result.elementsEqual(strs)) + } + + func testEncodedCheck() throws { + let urls = [ + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", + "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", + "https://cdn.frankerfacez.com/static/ffz_injector.user.js", + "http://www.k21p.com/example.user.js", // add http protocol + "https://example.test/%%%test.user.js", // removingPercentEncoding -> nil + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js" + ] + var result = [String]() + for url in urls { + if isEncoded(url) { + result.append(url) + } + } + // 2 urls already percent encoded + XCTAssert(result.count == 2) + } + + func testGetRemoteFileContents() throws { + var urls:[String] = [ + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", + "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", + "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", + "https://cdn.frankerfacez.com/static/ffz_injector.user.js", + "http://www.k21p.com/example.user.js" // add http protocol + ] + if #available(macOS 14.0, iOS 17.0, *) { + urls += [ + "https://☁️.com/", // IDN / Non-Punycode-encoded domain name + ] + } + for url in urls { + if getRemoteFileContents(url) == nil { + print(#function, url) + XCTAssert(false) + } + } + } + + func testFileRemoteUpdate() throws { + let urls = [ + "https://www.k21p.com/example.user.js", + "https://www.k21p.com/example.user.js?foo=bar", // query string + "http://www.k21p.com/example.user.js", // http protocol + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", // non latin chars + "https://www.k21p.com/example.user.jsx" // should fail + + ] + var result = [Int]() + for url in urls { + let content = """ + // ==UserScript== + // @name test + // @match *://*/* + // @version 0.1 + // @updateURL http://www.k21p.com/example.user.js + // @downloadURL \(url) + // ==/UserScript== + """; + let response = getFileRemoteUpdate(content) + if !response.keys.contains("error") { + result.append(1) + } + } + XCTAssert(result.count == (urls.count - 1)) + } + + func testMatching() throws { + let patternDict = [ + "*://*/*": [ + "https://www.bing.com/", + "https://example.org/foo/bar.html", + "https://a.org/some/path/", + "https://☁️.com/" + ], + "*://*.mozilla.org/*": [ + "http://mozilla.org/", + "https://mozilla.org/", + "https://b.mozilla.org/path/" + ], + "*://www.google.com/*": [ + "https://www.google.com/://aa", + "https://www.google.com/preferences?prev=https://www.google.com/", + "https://www.google.com/preferences?prev=", + "https://www.google.com/" + ], + "*://localhost/*": [ + "http://localhost:8000/", + "https://localhost:3000/foo.html" + ], + "http://127.0.0.1/*": [ + "http://127.0.0.1/", + "http://127.0.0.1/foo/bar.html" + ], + "*://*.example.com/*?a=1*": [ + "http://example.com/?a=1", + "https://www.example.com/index?a=1&b=2" + ] + ] + let patternDictFails = [ + "https://www.example.com/*": [ + "file://www.example.com/", + "ftp://www.example.com/", + "ws://www.example.com/", + "http://www.example.com/" + ], + "http://www.example.com/index.html": [ + "http://www.example.com/", + "https://www.example.com/index.html" + ], + "*://localhost/*": [ + "https://localhost.com/", + "ftp://localhost:8080/" + ], + "https://www.example*/*": [ + "https://www.example.com/" + ], + "*://*.example.com/*?a=1*": [ + "http://example.com/", + "https://www.example.com/?a=2" + ] + ] + for (pattern, urls) in patternDict { + for url in urls { + if !match(url, pattern) { + print(#function, "patternDict", url, pattern) + XCTAssert(false) + } + } + } + for (pattern, urls) in patternDictFails { + for url in urls { + if match(url, pattern) { + print(#function, "patternDictFails", url, pattern) + XCTAssert(false) + } + } + } + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } } diff --git a/xcode/Userscripts.xcodeproj/project.pbxproj b/xcode/Userscripts.xcodeproj/project.pbxproj index 5ddbcdad..f5944419 100644 --- a/xcode/Userscripts.xcodeproj/project.pbxproj +++ b/xcode/Userscripts.xcodeproj/project.pbxproj @@ -248,6 +248,7 @@ 4A57B9ED227235CD008A9763 /* Products */, ); sourceTree = ""; + usesTabs = 1; }; 4A57B9ED227235CD008A9763 /* Products */ = { isa = PBXGroup; From 2f950e0d7fd74aeb79270cc3c7f1c0a7d597a07b Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sun, 3 Dec 2023 07:53:53 +0800 Subject: [PATCH 012/259] chore: add ignore to `.git-blame-ignore-revs` --- .git-blame-ignore-revs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 5e1b7211..cc156834 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -4,3 +4,6 @@ # Prettier 3.0.3 acc35fbec38d72968b735826c6807793a2054aed + +# Xcode swift indent replace spaces to tabs +962c4d81879e85362a5f878889d3aa869f4e6961 From d581e67a5df2b12965e4ef1571ddd8e5617402b8 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 11 Dec 2023 03:27:03 +0800 Subject: [PATCH 013/259] fix(style): cancel the use of global styles --- src/ext/extension-page/Components/Editor/Editor.svelte | 1 + .../extension-page/Components/Editor/EditorSearch.svelte | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/ext/extension-page/Components/Editor/Editor.svelte b/src/ext/extension-page/Components/Editor/Editor.svelte index 5d831d39..362d7ed9 100644 --- a/src/ext/extension-page/Components/Editor/Editor.svelte +++ b/src/ext/extension-page/Components/Editor/Editor.svelte @@ -307,6 +307,7 @@ .editor__header__buttons { display: flex; + margin-right: 1rem; } :global(.editor__header__buttons > button:nth-of-type(2)) { diff --git a/src/ext/extension-page/Components/Editor/EditorSearch.svelte b/src/ext/extension-page/Components/Editor/EditorSearch.svelte index cc1f70db..ad132f0e 100644 --- a/src/ext/extension-page/Components/Editor/EditorSearch.svelte +++ b/src/ext/extension-page/Components/Editor/EditorSearch.svelte @@ -168,9 +168,9 @@ border-radius: var(--border-radius); box-shadow: var(--box-shadow); display: flex; - padding: 0.25rem 0; + padding: 0.25rem; position: absolute; - right: 0.5rem; + right: 1.5rem; top: 0; z-index: 4; } @@ -197,10 +197,6 @@ flex-shrink: 0; } - :global(button:nth-of-type(3)) { - margin-right: 0.25rem; - } - :global(div.editor__search button svg) { width: 45%; } From d9f52a714f1e319adf98b8c65f77d7feb79f1708 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Fri, 15 Dec 2023 12:36:05 +0800 Subject: [PATCH 014/259] fix: avoid code scroll bouncing --- .../Components/Editor/CodeMirror.svelte | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ext/extension-page/Components/Editor/CodeMirror.svelte b/src/ext/extension-page/Components/Editor/CodeMirror.svelte index e0c39081..f2d2d13b 100644 --- a/src/ext/extension-page/Components/Editor/CodeMirror.svelte +++ b/src/ext/extension-page/Components/Editor/CodeMirror.svelte @@ -406,10 +406,10 @@ {#if instance} {/if} + + From 63ccaa72cb97a47e1a4e19fa5383f642ee85a754 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:10:32 +0800 Subject: [PATCH 015/259] refactor: using css instead of window listener --- src/ext/extension-page/App.svelte | 19 ++++++------------- src/ext/extension-page/app.css | 1 + 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/ext/extension-page/App.svelte b/src/ext/extension-page/App.svelte index a457e035..d802643a 100644 --- a/src/ext/extension-page/App.svelte +++ b/src/ext/extension-page/App.svelte @@ -1,5 +1,5 @@ - + {#if $state.includes("init")}
diff --git a/src/ext/extension-page/app.css b/src/ext/extension-page/app.css index 425bcaa4..f34a9b9a 100644 --- a/src/ext/extension-page/app.css +++ b/src/ext/extension-page/app.css @@ -1,6 +1,7 @@ html { font-size: 100%; height: 100vh; + height: 100svh; /* safari 15.4 */ overflow: hidden; } From d8f01d3c7390bfcf58a1f74406aae422774bdbdb Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sat, 30 Dec 2023 18:51:55 +0800 Subject: [PATCH 016/259] fix: replace broken app launch process It seems that due to macOS upgrades, the order in which the launch delegate is called has changed, now `application(_:open:)` is called after `applicationDidFinishLaunching(_:)` (their order itself is also unclear). Replaced with using custom event handler to ensure proper launch flow. --- xcode/App-Mac/AppDelegate.swift | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/xcode/App-Mac/AppDelegate.swift b/xcode/App-Mac/AppDelegate.swift index ffdf0758..cb1a3837 100644 --- a/xcode/App-Mac/AppDelegate.swift +++ b/xcode/App-Mac/AppDelegate.swift @@ -6,16 +6,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var window: NSWindow! private var windowForego = false private var windowLoaded = false + private let logger = USLogger(#fileID) @IBOutlet weak var enbaleNativeLogger: NSMenuItem! - func application(_ application: NSApplication, open urls: [URL]) { + @objc func handleGetURL(event: NSAppleEventDescriptor, replyEvent: NSAppleEventDescriptor) { // if open panel is already open, stop processing the URL scheme if NSApplication.shared.keyWindow?.accessibilityIdentifier() == "open-panel" { return } - for url in urls { + // handle URL scheme + if let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue, + let url = URL(string: urlString) { + logger?.info("\(#function, privacy: .public) - \(urlString, privacy: .public)") if url.host == "changesavelocation" { // avoid opening the panel repeatedly and playing unnecessary warning sounds - if NSApplication.shared.keyWindow?.identifier?.rawValue == "changeSaveLocation" { continue } + if NSApplication.shared.keyWindow?.identifier?.rawValue == "changeSaveLocation" { return } if windowLoaded { let viewController = window.contentViewController as? ViewController viewController?.changeSaveLocation(nil) @@ -27,9 +31,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - func applicationDidFinishLaunching(_ aNotification: Notification) { + // https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428623-applicationwillfinishlaunching/ + func applicationWillFinishLaunching(_ notification: Notification) { + // https://developer.apple.com/documentation/foundation/nsappleeventmanager/1416131-seteventhandler + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleGetURL(event:replyEvent:)), + forEventClass: AEEventClass(kInternetEventClass), + andEventID: AEEventID(kAEGetURL) + ) + } + + // https://developer.apple.com/documentation/appkit/nsapplicationdelegate/1428385-applicationdidfinishlaunching + func applicationDidFinishLaunching(_ notification: Notification) { // Initialize menu items enbaleNativeLogger.state = Preferences.enableLogger ? .on : .off + // Whether to initialize the main view + logger?.debug("\(#function, privacy: .public) - windowForego: \(self.windowForego, privacy: .public)") if windowForego { return } let storyboard = NSStoryboard(name: "View", bundle: Bundle.main) let windowController = storyboard.instantiateInitialController() as! NSWindowController @@ -43,7 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { return true } - func applicationWillTerminate(_ aNotification: Notification) { + func applicationWillTerminate(_ notification: Notification) { // Insert code here to tear down your application } From 10abba429955d8594591d26c53841c025cec9be1 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sun, 7 Jan 2024 17:47:47 +0800 Subject: [PATCH 017/259] chore: increase the priority of prettier --- .vscode/settings.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index f86b8368..24436b8d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,15 @@ }, "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", + "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[svelte]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, // https://github.com/microsoft/vscode-eslint#settings-options "eslint.validate": ["javascript", "svelte"], "eslint.experimental.useFlatConfig": true, From 9dbf6ab51ee199319a47fa0d15013bec8b069942 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:19:29 +0800 Subject: [PATCH 018/259] refactor: adjust settings and better jsdoc Revision settings define and remove language content. Improve JSDoc for better IntelliSense in vscode. --- scripts/build-ext-safari-15.js | 5 +- scripts/build-ext-safari-16.4.js | 5 +- scripts/dev-ext-safari.js | 5 +- src/ext/shared/native.js | 2 +- src/ext/shared/settings.js | 647 +++++++++++++------------------ 5 files changed, 284 insertions(+), 380 deletions(-) diff --git a/scripts/build-ext-safari-15.js b/scripts/build-ext-safari-15.js index 3242f700..50a5862e 100644 --- a/scripts/build-ext-safari-15.js +++ b/scripts/build-ext-safari-15.js @@ -31,8 +31,11 @@ const defineConfig = { root: await rootDir(), base: "./", define: { - "import.meta.env.BROWSER": JSON.stringify("safari"), + "import.meta.env.BROWSER": JSON.stringify("Safari"), "import.meta.env.NATIVE_APP": JSON.stringify("app"), + "import.meta.env.SAFARI_PLATFORM": JSON.stringify( + process.env.SAFARI_PLATFORM, + ), }, }; diff --git a/scripts/build-ext-safari-16.4.js b/scripts/build-ext-safari-16.4.js index 12a67481..1c635833 100644 --- a/scripts/build-ext-safari-16.4.js +++ b/scripts/build-ext-safari-16.4.js @@ -30,8 +30,11 @@ const defineConfig = { root: await rootDir(), base: "./", define: { - "import.meta.env.BROWSER": JSON.stringify("safari"), + "import.meta.env.BROWSER": JSON.stringify("Safari"), "import.meta.env.NATIVE_APP": JSON.stringify("app"), + "import.meta.env.SAFARI_PLATFORM": JSON.stringify( + process.env.SAFARI_PLATFORM, + ), }, }; diff --git a/scripts/dev-ext-safari.js b/scripts/dev-ext-safari.js index 2b4e7c25..ecd00126 100644 --- a/scripts/dev-ext-safari.js +++ b/scripts/dev-ext-safari.js @@ -31,8 +31,11 @@ const defineConfig = { root: await rootDir(), base: "./", define: { - "import.meta.env.BROWSER": JSON.stringify("safari"), + "import.meta.env.BROWSER": JSON.stringify("Safari"), "import.meta.env.NATIVE_APP": JSON.stringify("app"), + "import.meta.env.SAFARI_PLATFORM": JSON.stringify( + process.env.SAFARI_PLATFORM, + ), }, }; diff --git a/src/ext/shared/native.js b/src/ext/shared/native.js index 524f3613..82566825 100644 --- a/src/ext/shared/native.js +++ b/src/ext/shared/native.js @@ -12,7 +12,7 @@ const application = () => import.meta.env.NATIVE_APP ?? "application"; /** * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/connectNative} - * @returns {object} A `runtime.Port` object + * @returns {import("webextension-polyfill").Runtime.Port} A `runtime.Port` object */ export function connectNative() { return browser.runtime.connectNative(application()); diff --git a/src/ext/shared/settings.js b/src/ext/shared/settings.js index 92ed61bf..045cbc30 100644 --- a/src/ext/shared/settings.js +++ b/src/ext/shared/settings.js @@ -8,14 +8,15 @@ const storagePrefix = "US_"; /** * Convert name to storage key * @param {string} name - * @returns {string} prefixed storage key + * @returns prefixed storage key */ const storageKey = (name) => storagePrefix + name.toUpperCase(); /** - * Dynamic storage reference - * @param {"sync"|"local"|undefined} area - * @returns {Promise} + * @typedef {"sync"|"local"|"managed"|"session"} Areas + * + * @param {"sync"|"local"=} area - storage area + * @returns Dynamic storage reference */ const storageRef = async (area) => { const storages = { @@ -30,7 +31,7 @@ const storageRef = async (area) => { }; // https://developer.apple.com/documentation/safariservices/safari_web_extensions/assessing_your_safari_web_extension_s_browser_compatibility#3584139 // since storage sync is not implemented in Safari, currently only returns using local storage - if (import.meta.env.BROWSER === "safari") { + if (import.meta.env.BROWSER === "Safari") { return storages.local; } if (area in storages) { @@ -44,344 +45,236 @@ const storageRef = async (area) => { } }; +/** + * @typedef {Object} Platforms platform availability + * @property {any=} macos - overriding defaults + * @property {any=} ipados - overriding defaults + * @property {any=} ios - overriding defaults + * + * @typedef {Object} Setting The setting item + * @property {string} name - setting's name + * @property {"string"|"number"|"boolean"|"object"|"array"} type - setting's value type + * @property {boolean=} local - local settings will not be synced + * @property {Array=} values - setting's values list + * @property {any} default - setting's default value + * @property {boolean=} disable - disabled settings not be displayed + * @property {boolean=} protect - protected settings cannot be reset + * @property {boolean=} confirm - double confirmation is required when change + * @property {Platforms=} platforms - platform availability and overriding defaults + * @property {"INTERNAL"|"general"|"editor"} group - setting's group name + * @property {string=} legacy - setting's legacy name + * @property {"Toggle"|"select"|"textarea"=} nodeType - setting's node type + * @property {Object=} nodeClass - node class name with setting's value + * + * @typedef {Setting & {key: string}} SettingWithKey The setting item with storage key + */ + +/** @type {Readonly} - Read-only setting template and fallback defaults */ const settingDefault = deepFreeze({ name: "setting_default", type: undefined, local: false, values: [], default: undefined, + disable: false, protect: false, confirm: false, - platforms: ["macos", "ipados", "ios"], - langLabel: {}, - langTitle: {}, - group: "", + platforms: { macos: undefined, ipados: undefined, ios: undefined }, + group: "INTERNAL", legacy: "", - nodeType: "", + nodeType: undefined, nodeClass: {}, }); -export const settingsDefine = deepFreeze( - [ - { - name: "error_native", - type: "object", - local: true, - default: { error: undefined }, - platforms: ["macos", "ipados", "ios"], - group: "Internal", - }, - { - name: "legacy_imported", - type: "number", - local: true, - default: 0, - protect: true, - platforms: ["macos"], - group: "Internal", - }, - { - name: "language_code", - type: "string", - default: "en", - platforms: ["macos", "ipados", "ios"], - group: "Internal", - legacy: "languageCode", - }, - { - name: "scripts_settings", - type: "object", - default: {}, - platforms: ["macos", "ipados", "ios"], - langLabel: { - en: "Scripts update check active", - zh_hans: "脚本更新检查激活", - }, - langTitle: { - en: "Whether to enable each single script update check", - zh_hans: "是否开启单个脚本更新检查", - }, - group: "Internal", - nodeType: "Subpage", - }, - // { - // name: "settings_sync", - // type: "boolean", - // local: true, - // default: false, - // protect: true, - // platforms: ["macos", "ipados", "ios"], - // langLabel: { - // en: "Sync settings", - // zh_hans: "同步设置" - // }, - // langTitle: { - // en: "Sync settings across devices", - // zh_hans: "跨设备同步设置" - // }, - // group: "General", - // nodeType: "Toggle" - // }, - { - name: "toolbar_badge_count", - type: "boolean", - default: true, - platforms: ["macos", "ipados"], - langLabel: { - en: "Show Toolbar Count", - zh_hans: "工具栏图标显示计数徽章", - }, - langTitle: { - en: "displays a badge on the toolbar icon with a number that represents how many enabled scripts match the url for the page you are on", - zh_hans: "简体中文描述", - }, - group: "General", - legacy: "showCount", - nodeType: "Toggle", - }, - { - name: "global_active", - type: "boolean", - local: true, - default: true, - platforms: ["macos"], - langLabel: { - en: "Enable Injection", - zh_hans: "启用注入", - }, - langTitle: { - en: "toggle on/off script injection for the pages you visit", - zh_hans: "简体中文描述", - }, - group: "General", - legacy: "active", - nodeType: "Toggle", - nodeClass: { red: false }, - }, - { - name: "global_scripts_update_check", - type: "boolean", - default: true, - platforms: ["macos", "ipados", "ios"], - langLabel: { - en: "Global scripts update check", - zh_hans: "全局脚本更新检查", - }, - langTitle: { - en: "Whether to enable global periodic script update check", - zh_hans: "是否开启全局定期脚本更新检查", - }, - group: "General", - nodeType: "Toggle", - }, - { - name: "scripts_update_check_interval", - type: "number", - default: 86400000, - platforms: ["macos", "ipados", "ios"], - langLabel: { - en: "Scripts update check interval", - zh_hans: "脚本更新检查间隔", - }, - langTitle: { - en: "The interval for script update check in background", - zh_hans: "脚本更新检查的间隔时间", - }, - group: "General", - nodeType: "Toggle", - }, - { - name: "scripts_update_check_lasttime", - type: "number", - default: 0, - platforms: ["macos", "ipados", "ios"], - langLabel: { - en: "Scripts update check lasttime", - zh_hans: "脚本更新上次检查时间", - }, - langTitle: { - en: "The lasttime for script update check in background", - zh_hans: "后台脚本更新上次检查时间", - }, - group: "Internal", - }, - { - name: "scripts_auto_update", - type: "boolean", - default: false, - confirm: true, - platforms: ["macos", "ipados", "ios"], - langLabel: { - en: "Scripts silent auto update", - zh_hans: "脚本后台静默自动更新", - }, - langTitle: { - en: "Script silently auto-updates in the background, which is dangerous and may introduce unconfirmed malicious code", - zh_hans: - "脚本在后台静默自动更新,这是危险的,可能引入未经确认的恶意代码", - }, - group: "General", - nodeType: "Toggle", - nodeClass: { warn: true }, - }, - { - name: "global_exclude_match", - type: "object", - default: [], - platforms: ["macos", "ipados", "ios"], - langLabel: { - en: "Global exclude match patterns", - zh_hans: "全局排除匹配模式列表", - }, - langTitle: { - en: "this input accepts a comma separated list of @match patterns, a page url that matches against a pattern in this list will be ignored for script injection", - zh_hans: "简体中文描述", - }, - group: "General", - legacy: "blacklist", - nodeType: "textarea", - nodeClass: { red: "blacklistError" }, - }, - { - name: "editor_close_brackets", - type: "boolean", - default: true, - platforms: ["macos"], - langLabel: { - en: "Auto Close Brackets", - zh_hans: "自动关闭括号", - }, - langTitle: { - en: "toggles on/off auto closing of brackets in the editor, this affects the following characters: () [] {} \"\" ''", - zh_hans: "简体中文描述", - }, - group: "Editor", - legacy: "autoCloseBrackets", - nodeType: "Toggle", - }, - { - name: "editor_auto_hint", - type: "boolean", - default: true, - platforms: ["macos"], - langLabel: { - en: "Auto Hint", - zh_hans: "自动提示(Hint)", - }, - langTitle: { - en: "automatically shows completion hints while editing", - zh_hans: "简体中文描述", - }, - group: "Editor", - legacy: "autoHint", - nodeType: "Toggle", - }, - { - name: "editor_list_sort", - type: "string", - values: ["nameAsc", "nameDesc", "lastModifiedAsc", "lastModifiedDesc"], - default: "lastModifiedDesc", - platforms: ["macos"], - langLabel: { - en: "Sort order", - zh_hans: "排序顺序", - }, - langTitle: { - en: "Display order of items in sidebar", - zh_hans: "侧栏中项目的显示顺序", - }, - group: "Editor", - legacy: "sortOrder", - nodeType: "Dropdown", - }, - { - name: "editor_list_descriptions", - type: "boolean", - default: true, - platforms: ["macos"], - langLabel: { - en: "Show List Descriptions", - zh_hans: "显示列表项目描述", - }, - langTitle: { - en: "show or hides the item descriptions in the sidebar", - zh_hans: "简体中文描述", - }, - group: "Editor", - legacy: "descriptions", - nodeType: "Toggle", - }, - { - name: "editor_javascript_lint", - type: "boolean", - default: false, - platforms: ["macos"], - langLabel: { - en: "Javascript Linter", - zh_hans: "Javascript Linter", - }, - langTitle: { - en: "toggles basic Javascript linting within the editor", - zh_hans: "简体中文描述", - }, - group: "Editor", - legacy: "lint", - nodeType: "Toggle", - }, - { - name: "editor_show_whitespace", - type: "boolean", - default: true, - platforms: ["macos"], - langLabel: { - en: "Show whitespace characters", - zh_hans: "显示空白字符", - }, - langTitle: { - en: "toggles the display of invisible characters in the editor", - zh_hans: "简体中文描述", - }, - group: "Editor", - legacy: "showInvisibles", - nodeType: "Toggle", - }, - { - name: "editor_tab_size", - type: "number", - values: [2, 4], - default: 4, - platforms: ["macos"], - langLabel: { - en: "Tab Size", - zh_hans: "制表符大小", - }, - langTitle: { - en: "the number of spaces a tab is equal to while editing", - zh_hans: "简体中文描述", - }, - group: "Editor", - legacy: "tabSize", - nodeType: "select", - }, - ].reduce(settingsDefineReduceCallback, {}), +/** @type {Readonly} - Read-only settings definition */ +const settingsDefinition = [ + { + name: "error_native", + type: "object", + local: true, + default: { error: undefined }, + group: "INTERNAL", + }, + { + name: "legacy_imported", + type: "number", + local: true, + default: 0, + protect: true, + platforms: { macos: undefined }, + group: "INTERNAL", + }, + { + name: "language_code", + type: "string", + default: "en", + group: "INTERNAL", + legacy: "languageCode", + }, + { + name: "settings_sync", + type: "boolean", + local: true, + default: false, + disable: true, + protect: true, + group: "general", + nodeType: "Toggle", + }, + { + name: "toolbar_badge_count", + type: "boolean", + default: true, + platforms: { macos: true, ipados: true, ios: false }, + group: "general", + legacy: "showCount", + nodeType: "Toggle", + }, + { + name: "global_active", + type: "boolean", + local: true, + default: true, + group: "general", + legacy: "active", + nodeType: "Toggle", + nodeClass: { warn: false }, + }, + { + name: "global_scripts_update_check", + type: "boolean", + default: true, + group: "general", + nodeType: "Toggle", + }, + { + name: "scripts_settings", + type: "object", + default: {}, + disable: true, + group: "INTERNAL", + }, + { + name: "scripts_update_check_interval", + type: "number", + values: [0, 1, 3, 7, 15, 30], + default: 0, + group: "general", + nodeType: "select", + }, + { + name: "scripts_update_check_lasttime", + type: "number", + default: 0, + group: "INTERNAL", + }, + { + name: "scripts_update_automation", + type: "boolean", + default: false, + disable: true, + confirm: true, + group: "general", + nodeType: "Toggle", + nodeClass: { warn: true }, + }, + { + name: "global_exclude_match", + type: "object", + default: [], + group: "general", + legacy: "blacklist", + nodeType: "textarea", + }, + { + name: "editor_close_brackets", + type: "boolean", + default: true, + platforms: { macos: undefined }, + group: "editor", + legacy: "autoCloseBrackets", + nodeType: "Toggle", + }, + { + name: "editor_auto_hint", + type: "boolean", + default: true, + platforms: { macos: undefined }, + group: "editor", + legacy: "autoHint", + nodeType: "Toggle", + }, + { + name: "editor_list_sort", + type: "string", + values: ["nameAsc", "nameDesc", "lastModifiedAsc", "lastModifiedDesc"], + default: "lastModifiedDesc", + platforms: { macos: undefined }, + group: "editor", + legacy: "sortOrder", + nodeType: "select", + }, + { + name: "editor_list_descriptions", + type: "boolean", + default: true, + platforms: { macos: undefined }, + group: "editor", + legacy: "descriptions", + nodeType: "Toggle", + }, + { + name: "editor_javascript_lint", + type: "boolean", + default: false, + platforms: { macos: undefined }, + group: "editor", + legacy: "lint", + nodeType: "Toggle", + }, + { + name: "editor_show_whitespace", + type: "boolean", + default: true, + platforms: { macos: undefined }, + group: "editor", + legacy: "showInvisibles", + nodeType: "Toggle", + }, + { + name: "editor_tab_size", + type: "number", + values: [1, 2, 3, 4, 5, 6, 8, 10, 12], + default: 4, + platforms: { macos: undefined }, + group: "editor", + legacy: "tabSize", + nodeType: "select", + }, +]; + +/** @type {Readonly<{[key: string]: SettingWithKey}>} - Read-only settings dictionary */ +export const settingsDictionary = deepFreeze( + settingsDefinition.reduce(settingsDefinitionReduceCallbackFn, {}), ); /** - * populate the settingsDefine with settingDefault - * and convert settingsDefine to storageKey object - * @param {object} settings new settings object - * @param {object} setting each setting define - * @returns {object} {US_GLOBAL_ACTIVE: {key: US_GLOBAL_ACTIVE, name: global_active, ... }, ...} + * populate the settings-define with setting-default + * and convert settings-define to storage-key object + * @param {{[key: string]: SettingWithKey}} settings settings dictionary + * @param {Setting} setting each setting define + * @returns // {US_GLOBAL_ACTIVE: {key: US_GLOBAL_ACTIVE, name: global_active, ... }, ...} */ -function settingsDefineReduceCallback(settings, setting) { - setting.key = storageKey(setting.name); - settings[setting.key] = { ...settingDefault, ...setting }; +function settingsDefinitionReduceCallbackFn(settings, setting) { + const key = storageKey(setting.name); + settings[key] = { ...settingDefault, ...setting, key }; return settings; } /** * prevent settings define from being modified in any case * otherwise user settings may be lost in the worst case + * @type {(o: T) => Readonly} * @param {object} object any object * @returns {object} deep frozen object */ @@ -410,20 +303,21 @@ if (Object.hasOwn === undefined) { /** * settings.get - * @param {string|Array} keys key | array of keys | undefined for all + * @param {string|string[]} keys key | array of keys | undefined for all * @param {"local"|"sync"} area - * @returns {Promise} settings object + * @returns settings object */ export async function get(keys = undefined, area = undefined) { if (![undefined, "local", "sync"].includes(area)) { return console.error("Unexpected storage area:", area); } // validate setting value and fix surprises to default + /** @param {string} key @param {any} val */ const valueFix = (key, val) => { - if (!key || !Object.hasOwn(settingsDefine, key)) return; - const def = settingsDefine[key].default; - // check if value type conforms to settingsDefine - const type = settingsDefine[key].type; + if (!key || !Object.hasOwn(settingsDictionary, key)) return; + const def = settingsDictionary[key].default; + // check if value type conforms to settings-dictionary + const type = settingsDictionary[key].type; // eslint-disable-next-line valid-typeof -- type known to be valid string literal if (typeof val != type) { console.warn( @@ -431,8 +325,8 @@ export async function get(keys = undefined, area = undefined) { ); return def; } - // check if value conforms to settingsDefine - const values = settingsDefine[key].values; + // check if value conforms to settings-dictionary + const values = settingsDictionary[key].values; if (values.length && !values.includes(val)) { console.warn( `Unexpected ${key} value '${val}' should one of '${values}', fix to default`, @@ -445,17 +339,17 @@ export async function get(keys = undefined, area = undefined) { // [single setting] if (typeof keys == "string") { const key = storageKey(keys); - // check if key exist in settingsDefine - if (!Object.hasOwn(settingsDefine, key)) { + // check if key exist in settings-dictionary + if (!Object.hasOwn(settingsDictionary, key)) { return console.error("unexpected settings key:", key); } // check if only locally stored setting // eslint-disable-next-line no-param-reassign -- change the area is expected - settingsDefine[key].local === true && (area = "local"); + settingsDictionary[key].local === true && (area = "local"); const storage = await storageRef(area); const result = await storage.ref.get(key); if (Object.hasOwn(result, key)) return valueFix(key, result[key]); - return settingsDefine[key].default; + return settingsDictionary[key].default; } const complexGet = async (settingsDefault, areaKeys) => { const storage = await storageRef(area); @@ -474,7 +368,7 @@ export async function get(keys = undefined, area = undefined) { const result = Object.assign(settingsDefault, local, sync); // revert settings object property name return Object.entries(result).reduce((p, c) => { - p[settingsDefine[c[0]].name] = valueFix(...c); + p[settingsDictionary[c[0]].name] = valueFix(...c); return p; }, {}); }; @@ -487,13 +381,13 @@ export async function get(keys = undefined, area = undefined) { const areaKeys = { local: [], sync: [], all: [] }; for (const k of keys) { const key = storageKey(k); - // check if key exist in settingsDefine - if (!Object.hasOwn(settingsDefine, key)) { + // check if key exist in settings-dictionary + if (!Object.hasOwn(settingsDictionary, key)) { return console.error("unexpected settings key:", key); } - settingsDefault[key] = settingsDefine[key].default; + settingsDefault[key] = settingsDictionary[key].default; // detach only locally stored settings - settingsDefine[key].local === true + settingsDictionary[key].local === true ? areaKeys.local.push(key) : areaKeys.sync.push(key); // record all keys in case sync storage is not enabled @@ -505,10 +399,10 @@ export async function get(keys = undefined, area = undefined) { if (typeof keys == "undefined" || keys === null) { const settingsDefault = {}; const areaKeys = { local: [], sync: [], all: [] }; - for (const key of Object.keys(settingsDefine)) { - settingsDefault[key] = settingsDefine[key].default; + for (const key of Object.keys(settingsDictionary)) { + settingsDefault[key] = settingsDictionary[key].default; // detach only locally stored settings - settingsDefine[key].local === true + settingsDictionary[key].local === true ? areaKeys.local.push(key) : areaKeys.sync.push(key); // record all keys in case sync storage is not enabled @@ -523,7 +417,6 @@ export async function get(keys = undefined, area = undefined) { * settings.set * @param {object} keys settings object * @param {"local"|"sync"} area - * @returns {Promise} */ export async function set(keys, area = undefined) { if (![undefined, "local", "sync"].includes(area)) { @@ -538,12 +431,12 @@ export async function set(keys, area = undefined) { const areaKeys = { local: {}, sync: {}, all: {} }; for (const k of Object.keys(keys)) { const key = storageKey(k); - // check if key exist in settingsDefine - if (!Object.hasOwn(settingsDefine, key)) { + // check if key exist in settings-dictionary + if (!Object.hasOwn(settingsDictionary, key)) { return console.error("Unexpected settings keys:", key); } - // check if value type conforms to settingsDefine - const type = settingsDefine[key].type; + // check if value type conforms to settings-dictionary + const type = settingsDictionary[key].type; // eslint-disable-next-line valid-typeof -- type known to be valid string literal if (typeof keys[k] != type) { if (type === "number" && !Number.isNaN(Number(keys[k]))) { @@ -555,15 +448,15 @@ export async function set(keys, area = undefined) { ); } } - // check if value conforms to settingsDefine - const values = settingsDefine[key].values; + // check if value conforms to settings-dictionary + const values = settingsDictionary[key].values; if (values.length && !values.includes(keys[k])) { return console.error( `Unexpected ${k} value '${keys[k]}' should one of '${values}'`, ); } // detach only locally stored settings - settingsDefine[key].local === true + settingsDictionary[key].local === true ? (areaKeys.local[key] = keys[k]) : (areaKeys.sync[key] = keys[k]); // record all keys in case sync storage is not enabled @@ -591,9 +484,8 @@ export async function set(keys, area = undefined) { /** * settings.reset * reset to default - * @param {string|Array} keys key | array of keys | undefined for all + * @param {string|string[]} keys key | array of keys | undefined for all * @param {"local"|"sync"} area - * @returns {Promise} */ export async function reset(keys = undefined, area = undefined) { if (![undefined, "local", "sync"].includes(area)) { @@ -602,16 +494,16 @@ export async function reset(keys = undefined, area = undefined) { // [single setting] if (typeof keys == "string") { const key = storageKey(keys); - // check if key exist in settingsDefine - if (!Object.hasOwn(settingsDefine, key)) { + // check if key exist in settings-dictionary + if (!Object.hasOwn(settingsDictionary, key)) { return console.error("unexpected settings key:", key); } // check if key is protected - if (settingsDefine[key].protect === true) { + if (settingsDictionary[key].protect === true) { return console.error("protected settings key:", key); } // eslint-disable-next-line no-param-reassign -- change the area is expected - settingsDefine[key].local === true && (area = "local"); + settingsDictionary[key].local === true && (area = "local"); const storage = await storageRef(area); return storage.ref.remove(key); } @@ -641,16 +533,16 @@ export async function reset(keys = undefined, area = undefined) { const areaKeys = { local: [], sync: [], all: [] }; for (const k of keys) { const key = storageKey(k); - // check if key exist in settingsDefine - if (!Object.hasOwn(settingsDefine, key)) { + // check if key exist in settings-dictionary + if (!Object.hasOwn(settingsDictionary, key)) { return console.error("unexpected settings key:", key); } // check if key is protected - if (settingsDefine[key].protect === true) { + if (settingsDictionary[key].protect === true) { return console.error("protected settings key:", key); } // detach only locally stored settings - settingsDefine[key].local === true + settingsDictionary[key].local === true ? areaKeys.local.push(key) : areaKeys.sync.push(key); // record all keys in case sync storage is not enabled @@ -661,11 +553,11 @@ export async function reset(keys = undefined, area = undefined) { // [all settings] if (typeof keys == "undefined" || keys === null) { const areaKeys = { local: [], sync: [], all: [] }; - for (const key in settingsDefine) { + for (const key in settingsDictionary) { // skip protected keys - if (settingsDefine[key].protect === true) continue; + if (settingsDictionary[key].protect === true) continue; // detach only locally stored settings - settingsDefine[key].local === true + settingsDictionary[key].local === true ? areaKeys.local.push(key) : areaKeys.sync.push(key); // record all keys in case sync storage is not enabled @@ -679,8 +571,11 @@ export async function reset(keys = undefined, area = undefined) { /** * complex onChanged * this function is convenient for the svelte store to update the state - * @param {Function} callback + * @callback onChangedSettingsCallback + * @param {{[key: string]: any}} settings - changed settings + * @param {Areas} area - storage area * @returns {void} + * @param {onChangedSettingsCallback} callback */ export function onChangedSettings(callback) { if (typeof callback != "function") { @@ -690,14 +585,14 @@ export function onChangedSettings(callback) { /** * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/onChanged#listener} * @param {object} changes - * @param {"sync"|"local"} area + * @param {Areas} area */ const listener = (changes, area) => { // console.log(`storage.${area}.onChanged`, changes); // DEBUG const settings = {}; for (const key in changes) { - if (!Object.hasOwn(settingsDefine, key)) continue; - settings[settingsDefine[key].name] = changes[key].newValue; + if (!Object.hasOwn(settingsDictionary, key)) continue; + settings[settingsDictionary[key].name] = changes[key].newValue; } try { callback(settings, area); @@ -713,14 +608,14 @@ export function onChangedSettings(callback) { /** * settings.legacyGet - * @param {string|Array.} keys - * @returns {Promise} settings object with legacy keys + * @param {string|string[]} keys + * @returns settings object with legacy keys */ export async function legacyGet(keys = undefined) { const result = await get(keys); // console.log("legacy_get", keys, result); for (const key of Object.keys(result)) { - const legacy = settingsDefine[storageKey(key)]?.legacy; + const legacy = settingsDictionary[storageKey(key)]?.legacy; if (legacy) result[legacy] = result[key]; } return result; @@ -729,7 +624,6 @@ export async function legacyGet(keys = undefined) { /** * settings.legacySet * @param {object} keys legacy keys - * @returns {Promise} */ export async function legacySet(keys) { if (typeof keys != "object") { @@ -739,8 +633,8 @@ export async function legacySet(keys) { return console.error("Settings object empty:", keys); } const settings = {}; - for (const key of Object.keys(settingsDefine)) { - const setting = settingsDefine[key]; + for (const key of Object.keys(settingsDictionary)) { + const setting = settingsDictionary[key]; if (!setting.legacy) continue; if (setting.legacy in keys) { settings[setting.name] = keys[setting.legacy]; @@ -758,14 +652,15 @@ export async function legacyImport() { const result = await browser.runtime.sendNativeMessage("app", { name: "PAGE_LEGACY_IMPORT", }); + if (!result) return console.error("PAGE_LEGACY_IMPORT not response"); if (result.error) return console.error(result.error); console.info("Import settings data from legacy manifest file"); const settings = {}; - for (const key of Object.keys(settingsDefine)) { - const legacy = settingsDefine[key].legacy; + for (const key of Object.keys(settingsDictionary)) { + const legacy = settingsDictionary[key].legacy; if (legacy in result) { let value = result[legacy]; - switch (settingsDefine[key].type) { + switch (settingsDictionary[key].type) { case "boolean": value = JSON.parse(value); break; @@ -774,7 +669,7 @@ export async function legacyImport() { break; } console.info(`Importing legacy setting: ${legacy}`, value); - settings[settingsDefine[key].name] = value; + settings[settingsDictionary[key].name] = value; } } // import complete tag, to ensure will only be import once From ba7afea9e4f96338bc64cb982abdd777fc3b6822 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:26:16 +0800 Subject: [PATCH 019/259] feat: create new extension page app for ios --- src/ext/extension-page/Appios.svelte | 92 +++++++++++++++++++ src/ext/extension-page/app.css | 9 ++ src/ext/extension-page/main.js | 25 ++++- src/ext/extension-page/store.js | 38 ++++++-- .../SafariWebExtensionHandler.swift | 12 +-- 5 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 src/ext/extension-page/Appios.svelte diff --git a/src/ext/extension-page/Appios.svelte b/src/ext/extension-page/Appios.svelte new file mode 100644 index 00000000..ed66fb5c --- /dev/null +++ b/src/ext/extension-page/Appios.svelte @@ -0,0 +1,92 @@ + + +{#if $state.includes("init")} +
+ + {@html logo} + {#if $state.includes("init-error")} + Failed to initialize app, check the browser console + {:else} + Initializing app... + {/if} +
+{/if} +
    + {#each $notifications as item (item.id)} + notifications.remove(item.id)} {item} /> + {/each} +
+{#if $state.includes("settings")} + +{/if} + + diff --git a/src/ext/extension-page/app.css b/src/ext/extension-page/app.css index f34a9b9a..61b9195a 100644 --- a/src/ext/extension-page/app.css +++ b/src/ext/extension-page/app.css @@ -3,6 +3,15 @@ html { height: 100vh; height: 100svh; /* safari 15.4 */ overflow: hidden; + overscroll-behavior: none; +} + +/* ios */ +@supports (-webkit-touch-callout: none) { + html { + height: auto; + overflow: visible; + } } body { diff --git a/src/ext/extension-page/main.js b/src/ext/extension-page/main.js index ec2580e9..7c45ed52 100644 --- a/src/ext/extension-page/main.js +++ b/src/ext/extension-page/main.js @@ -2,6 +2,7 @@ import "../shared/reset.css"; import "../shared/variables.css"; import "./app.css"; import App from "./App.svelte"; +import Appios from "./Appios.svelte"; // vite feat that only import in dev mode if (import.meta.env.MODE === "development") { @@ -14,8 +15,26 @@ if (import.meta.env.MODE === "development") { } } -const app = new App({ - target: document.getElementById("app"), -}); +let app; +const target = document.getElementById("app"); +if (import.meta.env.MODE === "development") { + const platform = await browser.runtime.getPlatformInfo(); + // @ts-ignore -- incomplete polyfill types + if (platform.os === "ios") { + app = new Appios({ target }); + } else { + app = new App({ target }); + } +} else { + if (import.meta.env.SAFARI_PLATFORM === "ios") { + app = new Appios({ target }); + } else { + app = new App({ target }); + } +} + +// const app = new App({ +// target: document.getElementById("app"), +// }); export default app; diff --git a/src/ext/extension-page/store.js b/src/ext/extension-page/store.js index dc9b0234..22550c7c 100644 --- a/src/ext/extension-page/store.js +++ b/src/ext/extension-page/store.js @@ -37,7 +37,7 @@ function stateStore() { // store oldState to see how state transitioned // ex. if (newState === foo && oldState === bar) baz(); let oldState = []; - const add = (stateModifier) => + const add = (stateModifier) => { update((state) => { // list of acceptable states, mostly for state definition tracking const states = [ @@ -68,7 +68,14 @@ function stateStore() { ); return state; }); - const remove = (stateModifier) => + // URL hash handle + const params = new URLSearchParams(location.hash.slice(1)); + if (["settings"].includes(stateModifier)) { + params.set("state", stateModifier); + location.hash = params.toString(); + } + }; + const remove = (stateModifier) => { update((state) => { // save pre-changed state to oldState var oldState = [...state]; @@ -84,20 +91,38 @@ function stateStore() { ); return state; }); + // URL hash handle + const params = new URLSearchParams(location.hash.slice(1)); + const state = params.get("state"); + if (state === stateModifier) { + params.delete("state"); + location.hash = params.toString(); + } + }; const getOldState = () => oldState; - return { subscribe, add, getOldState, remove }; + // URL hash handle + const loadUrlState = () => { + const params = new URLSearchParams(location.hash.slice(1)); + const state = params.get("state"); + state && add(state); + }; + return { subscribe, add, getOldState, remove, loadUrlState }; } export const state = stateStore(); function settingsStore() { const { subscribe, update, set } = writable({}); const init = async (initData) => { - // import legacy settings data just one-time - await settingsStorage.legacyImport(); + if (import.meta.env.SAFARI_PLATFORM === "mac") { + // import legacy settings data just one-time + await settingsStorage.legacyImport(); + } // for compatibility with legacy getting names only // once all new name is used, use settingsStorage.get() const settings = await settingsStorage.legacyGet(); - console.info("store.js settingsStore init", initData, settings); + if (import.meta.env.MODE === "development") { + console.info("store.js settingsStore init", initData, settings); + } set({ ...initData, ...settings }); // sync popup, backgound, etc... settings changes settingsStorage.onChangedSettings((sets, area) => { @@ -135,6 +160,7 @@ function settingsStore() { // for compatibility with legacy setting names only // once all new name is used, use settingsStorage.set() settingsStorage.legacySet({ [key]: value }); // Durable Storage + settingsStorage.set({ [key]: value }); // Durable Storage // temporarily keep the old storage method until it is confirmed that all dependencies are removed updateSingleSettingOld(key, value); }; diff --git a/xcode/Ext-Safari/SafariWebExtensionHandler.swift b/xcode/Ext-Safari/SafariWebExtensionHandler.swift index ac5cec13..b296be2a 100644 --- a/xcode/Ext-Safari/SafariWebExtensionHandler.swift +++ b/xcode/Ext-Safari/SafariWebExtensionHandler.swift @@ -186,13 +186,11 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { } } else if name == "PAGE_INIT_DATA" { - #if os(macOS) - if let initData = getInitData(), checkDefaultDirectories() { - response.userInfo = [SFExtensionMessageKey: initData] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]] - } - #endif + if let initData = getInitData(), checkDefaultDirectories() { + response.userInfo = [SFExtensionMessageKey: initData] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]] + } } else if name == "PAGE_LEGACY_IMPORT" { #if os(macOS) From 1f79d588f840e69b0806568c27d946916977445c Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:28:18 +0800 Subject: [PATCH 020/259] refactor: extract generic modal wrapper for macos app --- src/ext/extension-page/App.svelte | 16 +++- .../Components/ModalWrapper.svelte | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/ext/extension-page/Components/ModalWrapper.svelte diff --git a/src/ext/extension-page/App.svelte b/src/ext/extension-page/App.svelte index d802643a..2d0dd096 100644 --- a/src/ext/extension-page/App.svelte +++ b/src/ext/extension-page/App.svelte @@ -5,6 +5,7 @@ import Sidebar from "./Components/Sidebar/Sidebar.svelte"; import Editor from "./Components/Editor/Editor.svelte"; import Settings from "./Components/Settings.svelte"; + import ModalWrapper from "./Components/ModalWrapper.svelte"; import Notification from "./Components/Notification.svelte"; import logo from "../shared/img/logo.svg?raw"; import { connectNative, sendNativeMessage } from "../shared/native.js"; @@ -47,16 +48,19 @@ if (files.error) return console.error(files.error); items.set(files); state.remove("items-loading"); + state.loadUrlState(); }); // handle native app messages - const port = connectNative(); - port.onMessage.addListener((message) => { + const nativePort = connectNative(); + nativePort.onMessage.addListener((message) => { // console.info(message); // DEBUG if (message.name === "SAVE_LOCATION_CHANGED") { window.location.reload(); } }); + + const settingsProps = { nativePort, platform: "macos" }; @@ -81,7 +85,13 @@ notifications.remove(item.id)} {item} /> {/each} -{#if $state.includes("settings")}{/if} +{#if $state.includes("settings")} + state.remove("settings")} + /> +{/if} From e0a01052e805e0566e578e8e21343b1f4bbd3816 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:39:50 +0800 Subject: [PATCH 021/259] feat: setup extension options page entrance --- public/ext/safari-15/manifest.json | 3 +++ public/ext/safari-16.4/manifest.json | 3 +++ public/ext/safari-dev/manifest-ios.json | 2 +- public/ext/safari-dev/manifest-mac.json | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/public/ext/safari-15/manifest.json b/public/ext/safari-15/manifest.json index 99c503ee..1b4e52db 100644 --- a/public/ext/safari-15/manifest.json +++ b/public/ext/safari-15/manifest.json @@ -22,6 +22,9 @@ "32": "images/toolbar-icon-32.png" } }, + "options_ui": { + "page": "dist/entry-ext-extension-page.html#state=settings" + }, "content_scripts": [ { "js": ["dist/content-scripts/userscripts.js"], diff --git a/public/ext/safari-16.4/manifest.json b/public/ext/safari-16.4/manifest.json index 7d2c00da..ab445895 100644 --- a/public/ext/safari-16.4/manifest.json +++ b/public/ext/safari-16.4/manifest.json @@ -19,6 +19,9 @@ "default_popup": "dist/entry-ext-action-popup.html", "default_icon": "images/action.svg" }, + "options_ui": { + "page": "dist/entry-ext-extension-page.html#state=settings" + }, "content_scripts": [ { "js": ["dist/content-scripts/userscripts.js"], diff --git a/public/ext/safari-dev/manifest-ios.json b/public/ext/safari-dev/manifest-ios.json index 2cb0308a..e1cac050 100644 --- a/public/ext/safari-dev/manifest-ios.json +++ b/public/ext/safari-dev/manifest-ios.json @@ -20,7 +20,7 @@ "default_icon": "images/action.svg" }, "options_ui": { - "page": "dist/entry-ext-extension-page.html#settings" + "page": "dist/entry-ext-extension-page.html#state=settings" }, "content_scripts": [ { diff --git a/public/ext/safari-dev/manifest-mac.json b/public/ext/safari-dev/manifest-mac.json index 046f8239..e469aa66 100644 --- a/public/ext/safari-dev/manifest-mac.json +++ b/public/ext/safari-dev/manifest-mac.json @@ -20,7 +20,7 @@ "default_icon": "images/action.svg" }, "options_ui": { - "page": "dist/entry-ext-extension-page.html#settings" + "page": "dist/entry-ext-extension-page.html#state=settings" }, "content_scripts": [ { From a6f9d9b961c365e6c3470e558fcbbea306d3c24a Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:40:30 +0800 Subject: [PATCH 022/259] refactor: introducing new match patterns parser and i18n --- public/ext/shared/_locales/en/messages.json | 201 +++++++++++++++++++ public/ext/shared/_locales/zh/messages.json | 207 ++++++++++++++++++++ src/ext/shared/utils.js | 178 +++++++++++++++++ 3 files changed, 586 insertions(+) create mode 100644 public/ext/shared/_locales/zh/messages.json diff --git a/public/ext/shared/_locales/en/messages.json b/public/ext/shared/_locales/en/messages.json index e840a3a2..9b035e5c 100644 --- a/public/ext/shared/_locales/en/messages.json +++ b/public/ext/shared/_locales/en/messages.json @@ -6,5 +6,206 @@ "extension_description": { "message": "Save and run javascript for the web pages you visit", "description": "Description of what the extension does." + }, + "settings": { + "message": "Settings" + }, + "settings_section_editor": { + "message": "Editor Settings" + }, + "settings_section_general": { + "message": "General Settings" + }, + "settings_section_native": { + "message": "Native Settings" + }, + "settings_section_about": { + "message": "About" + }, + "settings_editor_auto_hint": { + "message": "Auto Hint" + }, + "settings_editor_auto_hint_desc": { + "message": "Automatically shows completion hints while editing" + }, + "settings_editor_close_brackets": { + "message": "Auto Close Brackets" + }, + "settings_editor_close_brackets_desc": { + "message": "Toggles on/off auto closing of brackets in the editor, this affects the following characters: () [] {} \"\" ''" + }, + "settings_editor_javascript_lint": { + "message": "Javascript Linter" + }, + "settings_editor_javascript_lint_desc": { + "message": "Toggles basic Javascript linting within the editor" + }, + "settings_editor_list_descriptions": { + "message": "Show List Descriptions" + }, + "settings_editor_list_descriptions_desc": { + "message": "Show or hides the item descriptions in the sidebar" + }, + "settings_editor_list_sort": { + "message": "Sort order" + }, + "settings_editor_list_sort_nameAsc": { + "message": "Scripts Name: Asc" + }, + "settings_editor_list_sort_nameDesc": { + "message": "Scripts Name: Desc" + }, + "settings_editor_list_sort_lastModifiedAsc": { + "message": "Last Modified: Asc" + }, + "settings_editor_list_sort_lastModifiedDesc": { + "message": "Last Modified: Desc" + }, + "settings_editor_list_sort_desc": { + "message": "Display order of items in sidebar" + }, + "settings_editor_show_whitespace": { + "message": "Show whitespace characters" + }, + "settings_editor_show_whitespace_desc": { + "message": "Toggles the display of invisible characters in the editor" + }, + "settings_editor_tab_size": { + "message": "Tab Size" + }, + "settings_editor_tab_size_desc": { + "message": "Choose the number of spaces a tab is equal to when rendering code" + }, + "settings_global_active": { + "message": "Enable Injection" + }, + "settings_global_active_desc": { + "message": "Toggle on/off script injection for the pages you visit" + }, + "settings_global_exclude_match": { + "message": "Global exclude match patterns" + }, + "settings_global_exclude_match_desc": { + "message": "This input accepts a whitespace (spaces, newlines etc.) separated list of @match patterns, a page url that matches against a pattern in this list will be ignored for script injection" + }, + "settings_global_exclude_match_saving": { + "message": "Saving..." + }, + "settings_global_exclude_match_placeholder": { + "message": "list of @match patterns, for example: \n*://*/*foo.bar\n*://*/*foo.bar?*\n*://*.example.net/*\nhttps://example.net/*/foo/*/\nhttps://*.example.net/a/b/c/?foo=/" + }, + "settings_global_exclude_match_refer": { + "message": "Please refer to:" + }, + "settings_global_scripts_update_check": { + "message": "Global scripts update check" + }, + "settings_global_scripts_update_check_desc": { + "message": "Whether to enable global periodic script update check" + }, + "settings_scripts_settings": { + "message": "Scripts update check active" + }, + "settings_scripts_settings_desc": { + "message": "Whether to enable each single script update check" + }, + "settings_scripts_update_automation": { + "message": "Scripts updates automatically" + }, + "settings_scripts_update_automation_desc": { + "message": "Script silently auto-updates in the background, which is dangerous and may introduce unconfirmed malicious code" + }, + "settings_scripts_update_check_interval": { + "message": "Scripts update check interval" + }, + "settings_scripts_update_check_interval_desc": { + "message": "The interval for script update check in background (days)" + }, + "settings_scripts_update_check_interval_0": { + "message": "Never" + }, + "settings_scripts_update_check_lasttime": { + "message": "Scripts update check lasttime" + }, + "settings_scripts_update_check_lasttime_desc": { + "message": "The lasttime for script update check in background" + }, + "settings_settings_sync": { + "message": "Sync settings" + }, + "settings_settings_sync_desc": { + "message": "Sync settings across devices" + }, + "settings_toolbar_badge_count": { + "message": "Show Toolbar Count" + }, + "settings_toolbar_badge_count_desc": { + "message": "Displays a badge on the toolbar icon with a number that represents how many enabled scripts match the url for the page you are on" + }, + "settings_scripts_directory": { + "message": "Save Location" + }, + "settings_scripts_directory_desc": { + "message": "Path to the folder where user scripts are stored" + }, + "settings_set_scripts_directory": { + "message": "Change save location" + }, + "settings_about_text1": { + "message": "Get more information about this extension by visiting the open source project:" + }, + "settings_about_text2": { + "message": "If you enjoy using this extension, please consider leaving a review on the App Store or sign up to beta test new versions:" + }, + "settings_about_button_repo": { + "message": "Code repository" + }, + "settings_about_button_docs": { + "message": "Documentation" + }, + "settings_about_button_issues": { + "message": "Report bugs" + }, + "settings_about_button_store": { + "message": "Open in the App Store" + }, + "settings_about_button_beta": { + "message": "Sign up for beta testing" + }, + "utils_check_match_patterns_1": { + "message": "The scheme component should one of *, https, http" + }, + "utils_check_match_patterns_2": { + "message": "The scheme and host should separated by `://`" + }, + "utils_check_match_patterns_3": { + "message": "The match pattern has no path component" + }, + "utils_check_match_patterns_4": { + "message": "The `*.` should followed by part of the hostname" + }, + "utils_check_match_patterns_5": { + "message": "The host component length should be 1-255" + }, + "utils_check_match_patterns_6": { + "message": "The `*` should be independent or `*.` at the start" + }, + "utils_check_match_patterns_7": { + "message": "The host component contains empty label(s)" + }, + "utils_check_match_patterns_8": { + "message": "The hostname label cannot start or end with `-` character" + }, + "utils_check_match_patterns_9": { + "message": "The maximum length of the hostname label cannot exceed 63" + }, + "utils_check_match_patterns_10": { + "message": "The host component contains invalid character(s): $1" + }, + "utils_check_match_patterns_11": { + "message": "The path component contains invalid character(s): $1" + }, + "msg_invalid_match_pattern": { + "message": "Invalid match pattern" } } diff --git a/public/ext/shared/_locales/zh/messages.json b/public/ext/shared/_locales/zh/messages.json new file mode 100644 index 00000000..610008bd --- /dev/null +++ b/public/ext/shared/_locales/zh/messages.json @@ -0,0 +1,207 @@ +{ + "extension_description": { + "message": "用户脚本和样式管理器", + "description": "Description of what the extension does." + }, + "settings": { + "message": "设置" + }, + "settings_section_editor": { + "message": "编辑器设置" + }, + "settings_section_general": { + "message": "通用设置" + }, + "settings_section_native": { + "message": "本地设置" + }, + "settings_section_about": { + "message": "关于" + }, + "settings_editor_auto_hint": { + "message": "自动提示(Hint)" + }, + "settings_editor_auto_hint_desc": { + "message": "编辑时自动显示完成提示" + }, + "settings_editor_close_brackets": { + "message": "自动关闭括号" + }, + "settings_editor_close_brackets_desc": { + "message": "在编辑器中启用自动关闭括号,这会影响以下字符:() [] {} \"\" ''" + }, + "settings_editor_javascript_lint": { + "message": "Javascript Linter" + }, + "settings_editor_javascript_lint_desc": { + "message": "在编辑器中启用基本的 Javascript linting 检查" + }, + "settings_editor_list_descriptions": { + "message": "显示列表项目描述" + }, + "settings_editor_list_descriptions_desc": { + "message": "显示或隐藏侧边栏中的用户脚本项目描述" + }, + "settings_editor_list_sort": { + "message": "项目排序顺序" + }, + "settings_editor_list_sort_desc": { + "message": "侧栏中项目的显示顺序" + }, + "settings_editor_list_sort_nameAsc": { + "message": "项目名称: 升序" + }, + "settings_editor_list_sort_nameDesc": { + "message": "项目名称: 降序" + }, + "settings_editor_list_sort_lastModifiedAsc": { + "message": "最后修改: 升序" + }, + "settings_editor_list_sort_lastModifiedDesc": { + "message": "最后修改: 降序" + }, + "settings_editor_show_whitespace": { + "message": "显示空白字符" + }, + "settings_editor_show_whitespace_desc": { + "message": "切换编辑器中不可见字符的显示" + }, + "settings_editor_tab_size": { + "message": "制表符大小" + }, + "settings_editor_tab_size_desc": { + "message": "选择渲染代码时制表符等于的空格数" + }, + "settings_global_active": { + "message": "启用注入" + }, + "settings_global_active_desc": { + "message": "全局脚本注入的开启或关闭" + }, + "settings_global_exclude_match": { + "message": "全局排除匹配模式列表" + }, + "settings_global_exclude_match_desc": { + "message": "此输入接受以空白符(空格、换行等)分隔的 @match 模式列表,与此列表中的模式匹配的页面 URL 将在脚本注入时被忽略" + }, + "settings_global_exclude_match_saving": { + "message": "保存中..." + }, + "settings_global_exclude_match_placeholder": { + "message": "@match 模式列表,例如:\n*://*/*foo.bar\n*://*/*foo.bar?*\n*://*.example.net/*\nhttps://example.net/*/foo/*/\nhttps://*.example.net/a/b/c/?foo=/" + }, + "settings_global_exclude_match_refer": { + "message": "匹配模式结构请参考:" + }, + "settings_global_scripts_update_check": { + "message": "全局脚本更新检查" + }, + "settings_global_scripts_update_check_desc": { + "message": "是否开启全局定期脚本更新检查" + }, + "settings_scripts_settings": { + "message": "脚本更新检查激活" + }, + "settings_scripts_settings_desc": { + "message": "是否开启单个脚本更新检查" + }, + "settings_scripts_update_automation": { + "message": "自动更新脚本" + }, + "settings_scripts_update_automation_desc": { + "message": "脚本在后台静默自动更新,这是危险的,可能引入未经确认的恶意代码" + }, + "settings_scripts_update_check_interval": { + "message": "脚本更新检查间隔" + }, + "settings_scripts_update_check_interval_desc": { + "message": "脚本在后台检查更新的间隔时间(天)" + }, + "settings_scripts_update_check_interval_0": { + "message": "从不" + }, + "settings_scripts_update_check_lasttime": { + "message": "脚本更新上次检查时间" + }, + "settings_scripts_update_check_lasttime_desc": { + "message": "后台脚本更新上次检查时间" + }, + "settings_settings_sync": { + "message": "同步设置" + }, + "settings_settings_sync_desc": { + "message": "跨设备同步设置" + }, + "settings_toolbar_badge_count": { + "message": "工具栏图标显示计数徽章" + }, + "settings_toolbar_badge_count_desc": { + "message": "在工具栏图标上显示一个徽章,其中的数字代表有多少个已启用的脚本与您所在页面的 URL 匹配" + }, + "settings_scripts_directory": { + "message": "保存位置" + }, + "settings_scripts_directory_desc": { + "message": "存储用户脚本的文件夹路径" + }, + "settings_set_scripts_directory": { + "message": "更改保存位置" + }, + "settings_about_text1": { + "message": "获取有关此扩展的更多信息,请访问本开源项目:" + }, + "settings_about_text2": { + "message": "如果您喜欢使用此扩展,请考虑在 App Store 上留下您的评论或注册 Beta 测试新版本:" + }, + "settings_about_button_repo": { + "message": "代码库" + }, + "settings_about_button_docs": { + "message": "文档" + }, + "settings_about_button_issues": { + "message": "报告错误" + }, + "settings_about_button_store": { + "message": "在 App Store 中打开" + }, + "settings_about_button_beta": { + "message": "注册 Beta 测试版" + }, + "utils_check_match_patterns_1": { + "message": "这 scheme 部分应当为 *、https、http 之一" + }, + "utils_check_match_patterns_2": { + "message": "这 scheme 和 host 部分应当用 `://` 分隔" + }, + "utils_check_match_patterns_3": { + "message": "匹配模式缺少 path 部分(至少应当有`/`)" + }, + "utils_check_match_patterns_4": { + "message": "这 `*.` 后面应当跟随主机名的一部分" + }, + "utils_check_match_patterns_5": { + "message": "这 host 部分长度应当为 1-255" + }, + "utils_check_match_patterns_6": { + "message": "这 `*` 应该是独立的或者为 `*.` 在开头" + }, + "utils_check_match_patterns_7": { + "message": "这 host 部分包含一个或多个空标签" + }, + "utils_check_match_patterns_8": { + "message": "主机名标签不能以 `-` 字符开头或结尾" + }, + "utils_check_match_patterns_9": { + "message": "主机名标签最大长度不能超过 63" + }, + "utils_check_match_patterns_10": { + "message": "这 host 部分包含无效字符:$1" + }, + "utils_check_match_patterns_11": { + "message": "这 path 部分包含无效字符:$1" + }, + "msg_invalid_match_pattern": { + "message": "无效的匹配模式" + } +} diff --git a/src/ext/shared/utils.js b/src/ext/shared/utils.js index fae3e29e..1f401f7a 100644 --- a/src/ext/shared/utils.js +++ b/src/ext/shared/utils.js @@ -147,6 +147,184 @@ export function parseMetadata(text) { return metadata; } +/** + * @param {string} input a match pattern + * @typedef {string} value - the match pattern + * @typedef {Object} parsedItem - parsed result + * @property {string} start - leading whitespace + * @property {string} separ - separator whitespace + * @property {value} value - the match pattern + * @property {boolean} error - the match pattern valid or not + * @property {string} point - invalid point or error message + * @returns {{error: boolean, items: parsedItem[], values: value[]}} + */ +export function parseMatchPatterns(input) { + if (typeof input !== "string") return; + const result = { + error: false, + items: [], + values: [], + }; + // match the separated values from input string + const matches = input.matchAll( + /(?^\s*|)(?\S+?)(?\s+|$)/g, + ); + for (const match of matches) { + const item = checkMatchPatterns(match.groups.value); + // setting the global error indicator + if (item.error === true) { + result.error = true; + } + result.items.push({ ...match.groups, ...item }); + result.values.push(item.value.toLowerCase()); + } + return result; +} + +/** + * @param {string} input whitespace separated list of match patterns + * @typedef {Object} checkedItem - checked result + * @property {string} value - the match pattern with fixes + * @property {boolean} error - the match pattern valid or not + * @property {string} point - invalid point or error message + * @returns {checkedItem} + */ +export function checkMatchPatterns(input) { + if (typeof input !== "string") return; + const result = { + value: input, + error: true, + point: "", + }; + if (input === "") { + result.error = false; + result.value = "*://*/*"; + return result; + } + let scheme, host, path; + /** check scheme component */ + if (input.slice(0, 5).toLowerCase() === "https") { + scheme = input.slice(0, 5); + } else if (input.slice(0, 4).toLowerCase() === "http") { + scheme = input.slice(0, 4); + } else if (input.startsWith("*")) { + scheme = "*"; + } else { + // The scheme component should one of *, https, http + result.point = gl("utils_check_match_patterns_1"); + return result; + } + /** check :// separator */ + if (input.slice(scheme.length, scheme.length + 3) !== "://") { + // The scheme and host should separated by `://` + result.point = gl("utils_check_match_patterns_2"); + return result; + } + // separate host and path + const array = input.slice(scheme.length + 3).split("/"); + if (array.length < 2) { + // The match pattern has no path component + result.point = gl("utils_check_match_patterns_3"); + return result; + } + host = array[0]; + path = "/" + array.slice(1).join("/"); + /** check host component */ + if (host === "*.") { + // The `*.` should followed by part of the hostname + result.point = gl("utils_check_match_patterns_4"); + return result; + } + // allow fully qualified domain name (FQDN) + if (host.at(-1) === ".") host = host.slice(0, -1); + if (host.length < 1 || 255 - 1 < host.length) { + // The host component length should be 1-255 + result.point = gl("utils_check_match_patterns_5"); + return result; + } + let labels = []; // domain labels + let hostPart = ""; // rest part that exclude wildcard + if (host.startsWith("*.")) { + hostPart = host.slice(2); + labels = hostPart.split("."); + } else if (host !== "*") { + hostPart = host; + labels = host.split("."); + } + if (hostPart.includes("*")) { + // The `*` should be independent or `*.` at the start + result.point = gl("utils_check_match_patterns_6"); + return result; + } + for (const label of labels) { + if (label.length === 0) { + // The host component contains empty label(s) + result.point = gl("utils_check_match_patterns_7"); + return result; + } + if (label.startsWith("-") || label.endsWith("-")) { + // The label cannot start or end with `-` character + result.point = gl("utils_check_match_patterns_8"); + return result; + } + if (label.length > 63) { + // The maximum length of the label cannot exceed 63 + result.point = gl("utils_check_match_patterns_9"); + return result; + } + } + // allowed character set of host component + const hostInvalidMatches = hostPart.match(/[^A-Za-z0-9.-]/g); + if (hostInvalidMatches) { + const characters = hostInvalidMatches.join(""); + // The host component contains invalid character(s): ${c} + result.point = gl("utils_check_match_patterns_10", characters); + return result; + } + /** check path component */ + // allowed character set of path component + const pathInvalidMatches = path.match( + /[^\w\]\\!$%&'()*+,./:;=?@[^`{|}~-]/g, // toolsGetValidPathCharacters + ); + if (pathInvalidMatches) { + const characters = pathInvalidMatches.join(""); + // The path component contains invalid character(s): ${c} + result.point = gl("utils_check_match_patterns_11", characters); + return result; + } + result.error = false; + return result; +} + +/** Generate valid path characters in browser js runtime */ +export function toolsGetValidPathCharacters() { + const set = new Set(); + for (let i = 32; i < 128; i++) set.add(String.fromCharCode(i)); + set.delete("?"); + set.delete("#"); + const p = [...set].join(""); + set.delete("="); + set.delete("&"); + const s = [...set].join(""); + const url = new URL(`https://host/${p}?1=${s}&${s}=2`); + const possibles = [...url.pathname, ...url.search]; + const characters = [...new Set(possibles)].sort().join(""); + const symbols = characters.match(/[^\w]/g).join(""); + return { + characters, + symbols, + restr: `/\\w${symbols.replace(/[\^[\]-]/g, "\\$&")}]/`, + }; +} + +/** + * get lang + * @param {string} n messageName + * @param {string | string[]} s substitutions + * const gl = browser.i18n.getMessage; // issue: safari return `undefined` + */ +export const gl = (n, s = undefined) => browser.i18n.getMessage(n, s); + export const validGrants = new Set([ "GM.info", "GM_info", From a388190638e2c0bd37212d631ff78c0b7617b7db Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Mon, 8 Jan 2024 18:55:00 +0800 Subject: [PATCH 023/259] refactor: introducing new settings component --- README.md | 8 +- .../extension-page/Components/Settings.svelte | 657 +++++++++++------- src/ext/shared/Components/Toggle.svelte | 11 +- xcode/App-Mac/AppDelegate.swift | 2 + 4 files changed, 413 insertions(+), 265 deletions(-) diff --git a/README.md b/README.md index 5864d9b8..a9051730 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ After installing Userscripts on macOS, you **do not** need to select a userscrip - **Show Toolbar Count** - displays a badge on the toolbar icon with a number that represents how many enabled scripts match the url for the page you are on - **Save Location** - where your file are currently located and being saved to (click the blue text to open location) - **Change Save Location (cogs icon)** - this button, located directly to the right of the save location, is a shortcut for opening the host app, which will allow you to change the save location -- **Global Blacklist** - this input accepts a comma separated list of [`@match` patterns](https://developer.chrome.com/docs/extensions/mv3/match_patterns/), a page url that matches against a pattern in this list will be ignored for script injection +- **Global Blacklist** - this input accepts a comma separated list of `@match` patterns ([Match pattern structure](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#match_pattern_structure)), a page url that matches against a pattern in this list will be ignored for script injection ### Popup: @@ -125,7 +125,7 @@ Userscripts Safari currently supports the following userscript metadata: - `@name` - This will be the name that displays in the sidebar and be used as the filename - you can _not_ use the same name for multiple files of the same type - `@description`- Use this to describe what your userscript does - this will be displayed in the sidebar - there is a setting to hide descriptions - `@icon` - This doesn't have a function with this userscript manager, but the **first value** provided in the metadata will be accessible in the `GM_/GM.info` object -- `@match` - Domain match patterns - you can use several instances of this field if you'd like multiple domain matches - view [this article for more information on constructing patterns](https://developer.chrome.com/extensions/match_patterns) +- `@match` - Domain match patterns - you can use several instances of this field if you'd like multiple domain matches - please refer to: [Match pattern structure](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#match_pattern_structure) - **Note:** this extension only supports `http/s` - `@exclude-match` - Domain patterns where you do _not_ want the script to run - `@include` - Used to match against urls for injection, globs and regular expressions are allowed, [read more here](https://wiki.greasespot.net/Include_and_exclude_rules) @@ -325,9 +325,7 @@ The quickest and easiest way to support the project is by [leaving a positive re The second best way to help out is to sign up to beta test new versions of the app. Since this extension values your privacy, and **does not collect any data from users**, it is difficult to gauge how the extension is being used. By signing up to be a beta tester it not only allows you to test upcoming features, but also gives me the opportunity to elicit direct feedback from real users. -**[iOS Beta Sign Up Form](https://forms.gle/QB46uYQHVyCxULue9)** - -**[macOS Beta Sign Up Form](https://forms.gle/cUDtKg1ip4Vc9Xhc7)** +**Please join and test the corresponding beta version in [releases](https://github.com/quoid/userscripts/releases) via the TestFlight public link.** ## Privacy Policy diff --git a/src/ext/extension-page/Components/Settings.svelte b/src/ext/extension-page/Components/Settings.svelte index 0d759d11..51cb25ea 100644 --- a/src/ext/extension-page/Components/Settings.svelte +++ b/src/ext/extension-page/Components/Settings.svelte @@ -1,341 +1,477 @@ -
- - -
state.remove("settings")}>
-