├── .nvmrc ├── .gitattributes ├── .github ├── FUNDING.yml ├── copilot-instructions.md └── workflows │ ├── ci.yml │ └── deploy-docs.yml ├── art ├── title.ttf ├── witchcraft.xcf ├── witchcraft-banner.png ├── witchcraft-banner.xcf ├── witchcraft-1280x800.png ├── witchcraft-440x280.png ├── witchcraft-440x280.xcf ├── witchcraft-920x680.png ├── witchcraft-920x680.xcf ├── witchcraft-128-with-borders.png ├── witchcraft-128-with-borders.xcf ├── README.md ├── witch-hat.svg ├── title.svg └── witch.svg ├── chrome-extension ├── content-script.js ├── witch-128.png ├── witch-16.png ├── witch-24.png ├── witch-32.png ├── witch-64.png ├── constants.js ├── analytics │ ├── index.js │ ├── events.js │ ├── metrics.js │ └── agent.js ├── script │ ├── include-context.js │ ├── script-context.js │ └── index.js ├── util │ ├── debouncer.js │ ├── embed-script.js │ ├── fetch-script.js │ └── index.js ├── badge.js ├── url.js ├── background.js ├── popup │ ├── popup.html │ ├── popup.css │ └── popup.js ├── manifest.json ├── storage │ ├── evict-stale.js │ └── index.js ├── path.js ├── icon.js ├── loader.js └── browser.js ├── test ├── integration │ ├── resources │ │ ├── test.css │ │ └── test.js │ ├── utils │ │ ├── resource-utils.js │ │ ├── dummy-web-server.js │ │ ├── dummy-script-server.js │ │ └── browser-test-utils.js │ └── sandbox.js ├── path │ ├── map-to-js-and-css.js │ ├── path-tuple-to-script-context.js │ ├── iterate-domain-levels.js │ ├── generate-potential-script-names.js │ └── iterate-path-segments.js ├── script │ ├── expand-include.js │ ├── process-include-directive.js │ └── find-include-directive.js ├── util │ ├── base-64-to-typed-array.js │ ├── splice-string.js │ ├── fetch-script.js │ └── embed-script.js ├── url │ ├── try-parse-url.js │ └── compose-url.js ├── storage │ └── frame-data.js ├── browser │ └── chrome-api │ │ ├── get-tab-url.js │ │ └── capture-runtime-error.js └── loader │ ├── load-includes.js │ ├── inject-script.js │ ├── load-scripts.js │ └── load-single-script.js ├── .gitignore ├── docs ├── src │ ├── assets │ │ ├── include.css │ │ ├── title.png │ │ ├── include-remote.js │ │ ├── screenshot.png │ │ ├── server-online.png │ │ ├── google.com.css │ │ ├── allow-user-scripts.png │ │ ├── manage-extension.png │ │ ├── ChromeWebStore_Badge_v2_340x96.png │ │ ├── google.com.js │ │ ├── include.js │ │ ├── witchcraft-server.sh │ │ └── witch-hat.svg │ ├── content │ │ └── docs │ │ │ ├── examples.mdx │ │ │ ├── cookbook │ │ │ ├── waiting-for-document-state.mdx │ │ │ ├── password.css │ │ │ └── password.mdx │ │ │ ├── new-in-v3.mdx │ │ │ ├── credits.mdx │ │ │ ├── index.mdx │ │ │ ├── wsl2-python-server.mdx │ │ │ ├── introduction.mdx │ │ │ ├── how-to-use.mdx │ │ │ ├── how-to-install.mdx │ │ │ ├── faq.mdx │ │ │ └── architecture.mdx │ ├── content.config.ts │ └── styles │ │ └── global.css ├── tsconfig.json ├── .gitignore ├── package.json ├── README.md └── astro.config.mjs ├── .dockerignore ├── README.md ├── Dockerfile ├── CHANGELOG.md ├── LICENSE ├── package.json └── development.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v24.8.0 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: luciopaiva 2 | -------------------------------------------------------------------------------- /art/title.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/title.ttf -------------------------------------------------------------------------------- /chrome-extension/content-script.js: -------------------------------------------------------------------------------- 1 | 2 | chrome.runtime.sendMessage(location); 3 | -------------------------------------------------------------------------------- /test/integration/resources/test.css: -------------------------------------------------------------------------------- 1 | 2 | h1 { 3 | color: rgb(0, 0, 255); 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | coverage/ 4 | credentials.json 5 | dist/ 6 | -------------------------------------------------------------------------------- /art/witchcraft.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft.xcf -------------------------------------------------------------------------------- /docs/src/assets/include.css: -------------------------------------------------------------------------------- 1 | .foo {} 2 | 3 | /* @include other.css */ 4 | 5 | .bar {} 6 | -------------------------------------------------------------------------------- /docs/src/content/docs/examples.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | Examples. 6 | -------------------------------------------------------------------------------- /art/witchcraft-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-banner.png -------------------------------------------------------------------------------- /art/witchcraft-banner.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-banner.xcf -------------------------------------------------------------------------------- /docs/src/assets/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/title.png -------------------------------------------------------------------------------- /art/witchcraft-1280x800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-1280x800.png -------------------------------------------------------------------------------- /art/witchcraft-440x280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-440x280.png -------------------------------------------------------------------------------- /art/witchcraft-440x280.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-440x280.xcf -------------------------------------------------------------------------------- /art/witchcraft-920x680.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-920x680.png -------------------------------------------------------------------------------- /art/witchcraft-920x680.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-920x680.xcf -------------------------------------------------------------------------------- /docs/src/assets/include-remote.js: -------------------------------------------------------------------------------- 1 | // @include https://raw.githubusercontent.com/luciopaiva/foo/master/bar.js 2 | -------------------------------------------------------------------------------- /chrome-extension/witch-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-128.png -------------------------------------------------------------------------------- /chrome-extension/witch-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-16.png -------------------------------------------------------------------------------- /chrome-extension/witch-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-24.png -------------------------------------------------------------------------------- /chrome-extension/witch-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-32.png -------------------------------------------------------------------------------- /chrome-extension/witch-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/chrome-extension/witch-64.png -------------------------------------------------------------------------------- /docs/src/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/screenshot.png -------------------------------------------------------------------------------- /docs/src/assets/server-online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/server-online.png -------------------------------------------------------------------------------- /art/witchcraft-128-with-borders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-128-with-borders.png -------------------------------------------------------------------------------- /art/witchcraft-128-with-borders.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/art/witchcraft-128-with-borders.xcf -------------------------------------------------------------------------------- /docs/src/assets/google.com.css: -------------------------------------------------------------------------------- 1 | img { 2 | filter: sepia(100%) saturate(2000%) brightness(70%) hue-rotate(90deg); 3 | } 4 | -------------------------------------------------------------------------------- /docs/src/assets/allow-user-scripts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/allow-user-scripts.png -------------------------------------------------------------------------------- /docs/src/assets/manage-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/manage-extension.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /chrome-extension/constants.js: -------------------------------------------------------------------------------- 1 | 2 | export const DEFAULT_SERVER_ADDRESS = "http://127.0.0.1:5743"; 3 | export const SERVER_PING_PERIOD_IN_MILLIS = 5000; 4 | -------------------------------------------------------------------------------- /docs/src/assets/ChromeWebStore_Badge_v2_340x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luciopaiva/witchcraft/HEAD/docs/src/assets/ChromeWebStore_Badge_v2_340x96.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | .git 5 | .gitignore 6 | README.md 7 | .env 8 | .nyc_output 9 | *.log 10 | .DS_Store 11 | 12 | -------------------------------------------------------------------------------- /test/integration/resources/test.js: -------------------------------------------------------------------------------- 1 | 2 | console.info("running"); 3 | const h1 = document.querySelector('h1'); 4 | if (h1) { 5 | h1.innerText = "Goodbye World"; 6 | } 7 | -------------------------------------------------------------------------------- /docs/src/assets/google.com.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load", () => { 2 | document.querySelectorAll("img") 3 | .forEach(img => img.style.transform = "scaleX(-1)"); 4 | }); 5 | -------------------------------------------------------------------------------- /docs/src/assets/include.js: -------------------------------------------------------------------------------- 1 | // @include my-script.js 2 | // @include "some other script.js" 3 | /* @include third-script.js */ 4 | 5 | setup(); // `setup` is defined in `my-script.js` 6 | -------------------------------------------------------------------------------- /docs/src/content/docs/cookbook/waiting-for-document-state.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Waiting for a specific document state 3 | --- 4 | 5 | import { Code } from '@astrojs/starlight/components'; 6 | 7 | -------------------------------------------------------------------------------- /art/README.md: -------------------------------------------------------------------------------- 1 | 2 | All art by Lucio Paiva done using GIMP, except `witch.svg` and `witch-hat.svg` which were kindly shared by [Freepik](https://www.flaticon.com/authors/freepik), downloaded from [Flaticon](https://www.flaticon.com). 3 | -------------------------------------------------------------------------------- /docs/src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsLoader } from '@astrojs/starlight/loaders'; 3 | import { docsSchema } from '@astrojs/starlight/schema'; 4 | 5 | export const collections = { 6 | docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /chrome-extension/analytics/index.js: -------------------------------------------------------------------------------- 1 | import Metrics from "./metrics.js"; 2 | import agent from "./agent.js"; 3 | import events from "./events.js"; 4 | 5 | function page(path, title) { 6 | analytics.agent.firePageViewEvent(path, title).then(); 7 | } 8 | 9 | export const analytics = { 10 | Metrics, 11 | agent, 12 | events, 13 | page, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | When running commands in the terminal, keep in mind that: 2 | 3 | - the terminal always opens in the root folder of the project, so there's no need to `cd` to it before running commands 4 | - the project is using `nvm` to manage Node versions, so run `nvm install` before running the first command that requires either `node` or one of its package managers 5 | -------------------------------------------------------------------------------- /chrome-extension/script/include-context.js: -------------------------------------------------------------------------------- 1 | 2 | export default class IncludeContext { 3 | /** @type {ScriptContext} */ 4 | script; 5 | startIndex = -1; 6 | endIndex = -1; 7 | 8 | constructor(script, startIndex, endIndex) { 9 | this.script = script; 10 | this.startIndex = startIndex; 11 | this.endIndex = endIndex; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/src/styles/global.css: -------------------------------------------------------------------------------- 1 | /* Global styles for Witchcraft documentation */ 2 | 3 | /* margin variables */ 4 | :root { 5 | --vertical-margin: 2rem; 6 | } 7 | 8 | .sl-markdown-content img { 9 | width: 80%; 10 | display: block; 11 | margin: var(--vertical-margin) auto; 12 | } 13 | 14 | .expressive-code { 15 | margin: var(--vertical-margin) auto; 16 | } 17 | -------------------------------------------------------------------------------- /chrome-extension/util/debouncer.js: -------------------------------------------------------------------------------- 1 | 2 | export default class Debouncer { 3 | 4 | constructor(waitInMillis) { 5 | this.waitInMillis = waitInMillis; 6 | this.timeout = undefined; 7 | } 8 | 9 | debounce(action) { 10 | clearTimeout(this.timeout); 11 | this.timeout = setTimeout(() => { 12 | action(); 13 | }, this.waitInMillis); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/src/content/docs/cookbook/password.css: -------------------------------------------------------------------------------- 1 | 2 | .password-container { 3 | display: flex; 4 | justify-content: center; 5 | width: 100%; 6 | } 7 | 8 | .password-field { 9 | width: 1.4em; 10 | text-align: center; 11 | margin: 0 .6em; 12 | } 13 | 14 | article.card { 15 | margin-top: 2rem; 16 | padding: 2rem; 17 | p.title { 18 | display: none; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/starlight": "^0.35.3", 14 | "astro": "^5.14.4", 15 | "sharp": "^0.34.2", 16 | "starlight-theme-rapide": "^0.5.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/src/content/docs/new-in-v3.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: New in v3 3 | --- 4 | 5 | Witchcraft has been completely revamped to work with [MV3](https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3). All outstanding bugs have been fixed and old users should be able to use it just fine now. 6 | 7 | For those that were using the old Web Server Chrome app, be aware that it now only works on ChromeOS. Please see the [installation page](/how-to-install) for alternatives. 8 | -------------------------------------------------------------------------------- /docs/src/content/docs/credits.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | --- 4 | 5 | Witchcraft is my rendition of [defunkt](https://github.com/defunkt)'s original extension, [dotjs](https://github.com/defunkt/dotjs). Although I never got to actually use dotjs (it only worked on macOS and the installation process was not easy), I really wanted something like that. defunkt now kindly [mentions](https://github.com/defunkt/dotjs/blob/master/README.markdown) Witchcraft as the go-to alternative to dotjs. 6 | 7 | Images in the logo were provided by [Freepik](https://www.flaticon.com/authors/freepik). 8 | -------------------------------------------------------------------------------- /test/path/map-to-js-and-css.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import path from "../../chrome-extension/path.js"; 5 | import sinon from "sinon"; 6 | 7 | const { mapToJsAndCss } = path; 8 | 9 | describe("Map to JS and CSS", function () { 10 | 11 | beforeEach(function () { 12 | sinon.restore(); 13 | }); 14 | 15 | it("simple path", function () { 16 | const result = mapToJsAndCss("foo.bar/fizz/buzz.html"); 17 | assert.deepStrictEqual(result, [ 18 | [ "foo.bar/fizz/buzz.html", "js" ], 19 | [ "foo.bar/fizz/buzz.html", "css" ] 20 | ]); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/integration/utils/resource-utils.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | export async function loadResource(resourcePath) { 9 | // load a file using fs from the test/integration/resources directory 10 | const fullPath = path.join(__dirname, '..', 'resources', resourcePath); 11 | try { 12 | const content = await fs.promises.readFile(fullPath, 'utf8'); 13 | return content; 14 | } catch (error) { 15 | throw new Error(`Failed to load resource '${resourcePath}': ${error.message}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /chrome-extension/script/script-context.js: -------------------------------------------------------------------------------- 1 | 2 | export default class ScriptContext { 3 | /** @type {string} */ 4 | path; 5 | /** @type {string} */ 6 | type; 7 | /** @type {string} */ 8 | url; 9 | /** @type {string} */ 10 | #contents; 11 | #hasContents = false; 12 | 13 | constructor(path, type) { 14 | this.path = path; 15 | this.type = type; 16 | } 17 | 18 | get contents() { 19 | return this.#contents; 20 | } 21 | 22 | set contents(contents) { 23 | this.#contents = contents; 24 | this.#hasContents = typeof contents === "string"; 25 | } 26 | 27 | get hasContents() { 28 | return this.#hasContents; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/path/path-tuple-to-script-context.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import path from "../../chrome-extension/path.js"; 5 | import ScriptContext from "../../chrome-extension/script/script-context.js"; 6 | import sinon from "sinon"; 7 | 8 | const { pathTupleToScriptContext } = path; 9 | 10 | describe("Path tuple to ScriptContext", function () { 11 | 12 | beforeEach(function () { 13 | sinon.restore(); 14 | }); 15 | 16 | it ("simple tuple", function () { 17 | const result = pathTupleToScriptContext(["foo.bar/fizz/buzz.html", "js"]); 18 | assert.deepStrictEqual(result, new ScriptContext("foo.bar/fizz/buzz.html.js", "js")); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /chrome-extension/badge.js: -------------------------------------------------------------------------------- 1 | 2 | import {storage} from "./storage/index.js"; 3 | import {browser} from "./browser.js"; 4 | 5 | async function clear(tabId) { 6 | await storage.removeTabScriptSet(tabId); 7 | await badge.setCount(tabId, 0); 8 | } 9 | 10 | async function registerScripts(tabId, frameId, scripts) { 11 | const totalCount = await storage.addToTabScriptSet(tabId, frameId, scripts); 12 | await badge.setCount(tabId, totalCount); 13 | } 14 | 15 | async function setCount(tabId, count) { 16 | const countStr = count > 999 ? "999+" : (count > 0 ? count.toString() : ""); 17 | await browser.setBadgeText(tabId, countStr); 18 | } 19 | 20 | export const badge = { 21 | clear, 22 | registerScripts, 23 | setCount, 24 | }; 25 | -------------------------------------------------------------------------------- /chrome-extension/util/embed-script.js: -------------------------------------------------------------------------------- 1 | 2 | // exported solely for testing 3 | export function injector(document) { 4 | const fnStr = (function fn() { /*INJECTION_POINT*/ }).toString(); 5 | const script = document.createElement("script"); 6 | script.text = `(${fnStr})()`; 7 | // when injecting at document_start, experimentation shows that doesn't exist and may not exist either 8 | // this is why here we are injecting the script tag directly into , which seems guaranteed to exist 9 | document.documentElement.appendChild(script); 10 | } 11 | 12 | const [INJECTOR_PREFIX, INJECTOR_SUFFIX] = injector.toString().split("/*INJECTION_POINT*/"); 13 | 14 | export function embedScript(contents) { 15 | return `(${INJECTOR_PREFIX}${contents}${INJECTOR_SUFFIX})(document)`; 16 | } 17 | -------------------------------------------------------------------------------- /chrome-extension/url.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @param {string} origin 4 | * @param {string} path 5 | */ 6 | export function composeUrl(origin, path) { 7 | const url = new URL(origin); 8 | const newUrl = new URL(path, origin); 9 | return `${newUrl.href}${url.search}${url.hash}`; 10 | } 11 | 12 | export function tryParseUrl(url) { 13 | try { 14 | const location = new URL(url); 15 | return { hostName: location.hostname, pathName: location.pathname }; 16 | } catch (_) { 17 | return { hostName: "", pathName: "" }; 18 | } 19 | } 20 | 21 | /* 22 | * Functions are exported like this instead of directly exposed so that they can be unit-tested. 23 | * See https://javascript.plainenglish.io/unit-testing-challenges-with-modular-javascript-patterns-22cc22397362 24 | */ 25 | export const url = { 26 | composeUrl, 27 | tryParseUrl, 28 | }; 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![Witchcraft](docs/src/assets/title.png) 3 | 4 | Witchcraft is a Google Chrome extension for loading custom JavaScript and CSS directly from a folder on your local machine, injecting them into web pages that match specified URL patterns. 5 | 6 | It works by matching every page domain against script file names available in the scripts folder. For instance, if one navigates to `google.com`, Witchcraft will try to load and run `google.com.js` and `google.com.css`. 7 | 8 | For more information on how to install and use it, head to Witchcraft's [home page](//luciopaiva.com/witchcraft). 9 | 10 | # Development 11 | 12 | See [here](./development.md). 13 | 14 | # Credits 15 | 16 | Witchcraft is my rendition of [defunkt](//github.com/defunkt)'s original extension, [dotjs](//github.com/defunkt/dotjs). 17 | 18 | Images in the logo were provided by [Freepik](//www.flaticon.com/authors/freepik). 19 | -------------------------------------------------------------------------------- /test/script/expand-include.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import sinon from "sinon"; 5 | import ScriptContext from "../../chrome-extension/script/script-context.js"; 6 | import IncludeContext from "../../chrome-extension/script/include-context.js"; 7 | import {util} from "../../chrome-extension/util/index.js"; 8 | import {script} from "../../chrome-extension/script/index.js"; 9 | 10 | describe("Expand include", function () { 11 | 12 | beforeEach(function () { 13 | sinon.restore(); 14 | }); 15 | 16 | it("simple expansion", function () { 17 | sinon.stub(util, "spliceString").returns("foo"); 18 | const baseScript = new ScriptContext(); 19 | const includeScript = new ScriptContext(); 20 | const include = new IncludeContext(includeScript); 21 | script.expandInclude(baseScript, include); 22 | 23 | assert.strictEqual(baseScript.contents, "foo"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24.7.0-slim AS app 2 | 3 | # We don't need the standalone Chromium 4 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 5 | 6 | # Install Google Chrome Stable and fonts 7 | # Note: this installs the necessary libs to make the browser work with Puppeteer. 8 | RUN apt-get update && apt-get install curl gnupg -y \ 9 | && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 10 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 11 | && apt-get update \ 12 | && apt-get install google-chrome-stable -y --no-install-recommends \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # Create app directory 16 | WORKDIR /app 17 | 18 | # Copy package files 19 | COPY package*.json ./ 20 | 21 | # Install dependencies 22 | RUN npm ci --only=production=false 23 | 24 | # Copy source code 25 | COPY . . 26 | 27 | # Default command to run integration tests 28 | CMD ["npm", "run", "test:integration"] 29 | -------------------------------------------------------------------------------- /chrome-extension/background.js: -------------------------------------------------------------------------------- 1 | import {loader} from "./loader.js"; 2 | import {storage} from "./storage/index.js"; 3 | import {browser} from "./browser.js"; 4 | import {icon} from "./icon.js"; 5 | import {analytics} from "./analytics/index.js"; 6 | 7 | analytics.events.backgroundLoaded(); 8 | browser.onInstalled(async () => { 9 | console.info("Extension installed."); 10 | analytics.events.installed(); 11 | }); 12 | browser.onSuspend(async () => { 13 | console.info("Extension suspended"); 14 | analytics.events.suspended(); 15 | }); 16 | 17 | browser.onMessage(async (message, sender) => { 18 | const url = message.href; 19 | const tabId = sender.tab?.id; 20 | const frameId = sender.frameId; 21 | 22 | if (typeof url === "string" && typeof tabId === "number" && typeof frameId === "number") { 23 | const metrics = await loader.loadScripts(url, tabId, frameId); 24 | metrics.hasData && analytics.events.scriptsLoaded(metrics); 25 | await storage.evictStale(); 26 | } 27 | }); 28 | 29 | icon.initialize().then(); 30 | -------------------------------------------------------------------------------- /test/util/base-64-to-typed-array.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { describe, it } from "mocha"; 3 | import {util} from "../../chrome-extension/util/index.js"; 4 | import sinon from "sinon"; 5 | 6 | describe("Base 64 / Typed Array conversion", function () { 7 | 8 | beforeEach(function () { 9 | sinon.restore(); 10 | }); 11 | 12 | it("typed array to base 64", async function () { 13 | const data = new Uint8ClampedArray([1, 2, 3, 255]); 14 | assert.strictEqual(util.typedArrayToBase64(data), "AQID/w=="); 15 | }); 16 | 17 | it("base 64 to Uint8ClampedArray", async function () { 18 | const base64 = "AQID/w=="; 19 | const data = util.base64ToTypedArray(base64, Uint8ClampedArray); 20 | assert.strictEqual(data.toString(), "1,2,3,255"); 21 | }); 22 | 23 | it("base 64 to Int8Array", async function () { 24 | const base64 = "AQID/w=="; 25 | const data = util.base64ToTypedArray(base64, Int8Array); 26 | assert.strictEqual(data.toString(), "1,2,3,-1"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # 3.3.0 3 | 4 | - use content scripts to inject scripts at `document_start` 5 | 6 | # 3.2.0 7 | 8 | - prefer `userScripts` over `scripting` (#71) 9 | 10 | # 3.1.0 11 | 12 | - try path combinations with all domain levels + `_global` 13 | 14 | # 3.0.0 15 | 16 | - migrate to Manifest V3 17 | - fix all outstanding bugs 18 | - major refactor 19 | - CI pipeline with unit + integration tests 20 | 21 | # 2.6.1 22 | 23 | - fix #25 24 | 25 | # 2.6.0 26 | 27 | - ability to include remote scripts 28 | - ability to change server address 29 | - analytics 30 | - several minor fixes and improvements 31 | 32 | # 2.5.0 33 | 34 | - handle path segments 35 | 36 | # 2.3.0 37 | 38 | - `_global.js/css` support 39 | - unit tests 40 | 41 | # 2.2.0 42 | 43 | - popup improvements 44 | 45 | # 2.1.0 46 | 47 | - load scripts from local web server 48 | - popup improvements 49 | 50 | # 2.0.1 51 | 52 | - `@include` directive 53 | 54 | # 1.0.3 55 | 56 | - fix #1 57 | 58 | # 1.0.2 59 | 60 | - add badge with number of scripts loaded 61 | 62 | # 1.0.0 63 | 64 | - first working version 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Lucio Paiva 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /chrome-extension/analytics/events.js: -------------------------------------------------------------------------------- 1 | 2 | import {analytics} from "./index.js"; 3 | 4 | function installed() { 5 | analytics.agent.fireEvent("installed").then(); 6 | } 7 | 8 | function backgroundLoaded() { 9 | analytics.agent.fireEvent("background_loaded").then(); 10 | } 11 | 12 | /** 13 | * @param {Metrics} metrics 14 | */ 15 | function scriptsLoaded(metrics) { 16 | const params = { 17 | js_hit_count: metrics.jsHitCount, 18 | js_include_hit_count: metrics.jsIncludesHitCount, 19 | js_include_miss_count: metrics.jsIncludesNotFoundCount, 20 | css_hit_count: metrics.cssHitCount, 21 | css_include_hit_count: metrics.cssIncludesHitCount, 22 | css_include_miss_count: metrics.cssIncludesNotFoundCount, 23 | server_error_count: metrics.errorCount, 24 | fetch_fail_count: metrics.failCount, 25 | }; 26 | analytics.agent.fireEvent("scripts_loaded", params).then(); 27 | } 28 | 29 | function suspended() { 30 | analytics.agent.fireEvent("suspended").then(); 31 | } 32 | 33 | const event = { 34 | installed, 35 | backgroundLoaded, 36 | scriptsLoaded, 37 | suspended, 38 | }; 39 | 40 | export default event; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "witchcraft", 3 | "version": "3.2.0", 4 | "description": "![Witchcraft](docs/title.png)", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "docs" 8 | }, 9 | "scripts": { 10 | "build": "node build.js", 11 | "test:unit": "mocha 'test/**/*.js' --ignore 'test/integration/**' --recursive", 12 | "test:coverage": "c8 -r lcov -r text mocha 'test/**/*.js' --ignore 'test/integration/**' --recursive", 13 | "test:integration": "npm run build && mocha test/integration/integration.js --timeout 30000", 14 | "test": "npm run test:unit && npm run test:integration" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/luciopaiva/witchcraft.git" 19 | }, 20 | "type": "module", 21 | "keywords": [], 22 | "author": "", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/luciopaiva/witchcraft/issues" 26 | }, 27 | "homepage": "https://github.com/luciopaiva/witchcraft#readme", 28 | "devDependencies": { 29 | "c8": "^10.1.3", 30 | "mocha": "^11.7.2", 31 | "nyc": "^17.1.0", 32 | "puppeteer": "^24.17.1", 33 | "sinon": "^15.1.0", 34 | "sinon-chrome": "^3.0.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/url/try-parse-url.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {tryParseUrl} from "../../chrome-extension/url.js"; 5 | import sinon from "sinon"; 6 | 7 | describe("Parse URL", function () { 8 | 9 | beforeEach(function () { 10 | sinon.restore(); 11 | }); 12 | 13 | it ("Only host name", function () { 14 | const result = tryParseUrl("https://www.google.com"); 15 | assert.deepStrictEqual(result, makeResponse("www.google.com", "/")); 16 | }); 17 | 18 | it ("Host name and path", function () { 19 | const result = tryParseUrl("https://luciopaiva.com/foo/bar/"); 20 | assert.deepStrictEqual(result, makeResponse("luciopaiva.com", "/foo/bar/")); 21 | }); 22 | 23 | it ("Empty host", function () { 24 | const result = tryParseUrl("https://"); 25 | assert.deepStrictEqual(result, makeResponse("", "")); 26 | }); 27 | 28 | it ("No protocol", function () { 29 | const result = tryParseUrl("foo.com/bar"); 30 | assert.deepStrictEqual(result, makeResponse("", "")); 31 | }); 32 | }); 33 | 34 | function makeResponse(hostName, pathName) { 35 | return { hostName, pathName }; 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | pull_request: 7 | branches: [ master, main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Read Node.js version from .nvmrc 18 | id: nvmrc 19 | run: echo "node-version=$(cat .nvmrc)" >> $GITHUB_OUTPUT 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ steps.nvmrc.outputs.node-version }} 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run unit tests 31 | run: npm run test:unit 32 | 33 | - name: Run integration tests 34 | run: npm run test:integration 35 | 36 | - name: Upload coverage reports 37 | uses: codecov/codecov-action@v4 38 | if: success() 39 | with: 40 | file: ./coverage/lcov.info 41 | flags: unittests 42 | name: codecov-umbrella 43 | fail_ci_if_error: false 44 | env: 45 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 46 | -------------------------------------------------------------------------------- /chrome-extension/util/fetch-script.js: -------------------------------------------------------------------------------- 1 | 2 | const FETCH_OPTIONS = { 3 | cache: "no-store", 4 | }; 5 | 6 | export const FETCH_RESPONSE_OUTCOME = { 7 | SUCCESS: 0, 8 | NOT_FOUND: 1, 9 | SERVER_FAILURE: 2, 10 | FETCH_FAILURE: 3, 11 | }; 12 | 13 | class FetchResponse { 14 | /** @type {string} */ 15 | contents; 16 | /** @type {number} */ 17 | outcome; 18 | } 19 | 20 | /** 21 | * @param {string} url 22 | * @param {Function} fetchFn 23 | * @returns {Promise} 24 | */ 25 | export async function fetchScript(url, fetchFn = fetch) { 26 | const result = new FetchResponse(); 27 | try { 28 | const response = await fetchFn(url, FETCH_OPTIONS); 29 | switch (response.status) { 30 | case 200: 31 | result.contents = await response.text(); 32 | result.outcome = FETCH_RESPONSE_OUTCOME.SUCCESS; 33 | break; 34 | case 404: 35 | result.outcome = FETCH_RESPONSE_OUTCOME.NOT_FOUND; 36 | break; 37 | default: 38 | result.outcome = FETCH_RESPONSE_OUTCOME.SERVER_FAILURE; 39 | } 40 | } catch (e) { 41 | result.outcome = FETCH_RESPONSE_OUTCOME.FETCH_FAILURE; 42 | } 43 | return result; 44 | } 45 | -------------------------------------------------------------------------------- /test/url/compose-url.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {composeUrl} from "../../chrome-extension/url.js"; 5 | import sinon from "sinon"; 6 | 7 | const origin = "https://luciopaiva.com:1234"; 8 | const queryAndFragment = "?q=true#hello"; 9 | 10 | describe("Resolve include URL", function () { 11 | 12 | beforeEach(function () { 13 | sinon.restore(); 14 | }); 15 | 16 | it ("absolute path", function () { 17 | const url = origin + "/witchcraft/index.html" + queryAndFragment; 18 | const result = composeUrl(url, "/foo/bar"); 19 | assert.strictEqual(result, origin + "/foo/bar" + queryAndFragment); 20 | }); 21 | 22 | it ("relative path", function () { 23 | const url = origin + "/witchcraft/index.html" + queryAndFragment; 24 | const result = composeUrl(url, "foo/bar.html"); 25 | assert.strictEqual(result, origin + "/witchcraft/foo/bar.html" + queryAndFragment); 26 | }); 27 | 28 | it ("relative path with ..", function () { 29 | const url = origin + "/witchcraft/foo/index.html" + queryAndFragment; 30 | const result = composeUrl(url, "../bar/fizz.html"); 31 | assert.strictEqual(result, origin + "/witchcraft/bar/fizz.html" + queryAndFragment); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /chrome-extension/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Witchcraft 6 | 7 | 8 | 9 | 10 |
11 | Current tab 12 |
13 | 14 |
15 |
No scripts found
16 |
17 |
18 | 19 |
20 |
Server
21 |
22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | Reset to default 30 |
31 |
32 | 33 |
34 |
Witchcraft
35 |
36 | 37 |
38 |
39 | Docs 40 | Report issue 41 |
42 |
43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Witchcraft 3 | description: A developer-friendly Chrome extension for injecting JS and CSS scripts 4 | template: splash 5 | hero: 6 | tagline: A developer-friendly Chrome extension for injecting JS and CSS scripts 7 | image: 8 | file: ../../assets/witch.svg 9 | actions: 10 | - text: Get it on Chrome 11 | link: https://chromewebstore.google.com/detail/witchcraft-jscss-injector/hokcepcfcicnhalinladgknhaljndhpc 12 | icon: external 13 | variant: primary 14 | - text: Documentation 15 | link: /witchcraft/introduction/ 16 | icon: right-arrow 17 | variant: secondary 18 | --- 19 | 20 | import { Card, CardGrid } from '@astrojs/starlight/components'; 21 | 22 | {/* ## Next steps */} 23 | 24 | 25 | 26 | {/* */} 27 | {/* */} 28 | {/* Edit `src/content/docs/index.mdx` to see this page change. */} 29 | {/* */} 30 | {/* */} 31 | {/* Add Markdown or MDX files to `src/content/docs` to create new pages. */} 32 | {/* */} 33 | {/* */} 34 | {/* Edit your `sidebar` and other config in `astro.config.mjs`. */} 35 | {/* */} 36 | {/* */} 37 | {/* Learn more in [the Starlight Docs](https://starlight.astro.build/). */} 38 | {/* */} 39 | {/* */} 40 | -------------------------------------------------------------------------------- /test/util/splice-string.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {util} from "../../chrome-extension/util/index.js"; 5 | import sinon from "sinon"; 6 | 7 | describe("Splice string", function () { 8 | 9 | beforeEach(function () { 10 | sinon.restore(); 11 | }); 12 | 13 | it("simple splice", async function () { 14 | const result = util.spliceString("foobarfoo", 3, 6, "foo"); 15 | assert.strictEqual(result, "foofoofoo"); 16 | }); 17 | 18 | it("replace with larger string", async function () { 19 | const result = util.spliceString("foobarfoo", 3, 6, "hello"); 20 | assert.strictEqual(result, "foohellofoo"); 21 | }); 22 | 23 | it("replace entire string", async function () { 24 | const result = util.spliceString("foobarfoo", 0, 9, "hello"); 25 | assert.strictEqual(result, "hello"); 26 | }); 27 | 28 | it("replace with empty", async function () { 29 | const result = util.spliceString("foobarfoo", 3, 6, ""); 30 | assert.strictEqual(result, "foofoo"); 31 | }); 32 | 33 | it("no operation", async function () { 34 | const result = util.spliceString("foobarfoo", 5, 5, ""); 35 | assert.strictEqual(result, "foobarfoo"); 36 | }); 37 | 38 | it("append", async function () { 39 | const result = util.spliceString("foo", 3, 3, "bar"); 40 | assert.strictEqual(result, "foobar"); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /docs/src/assets/witchcraft-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # ~/start-server.sh 3 | # Usage: ./start-server.sh start | stop | status 4 | 5 | PORT=5743 6 | SERVER_CMD="python3 -m http.server $PORT" 7 | SERVER_DIR="$HOME/witchcraft-scripts" 8 | LOG_FILE="/tmp/site-http.log" 9 | PID_FILE="/tmp/site-http.pid" 10 | 11 | start_server() { 12 | if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then 13 | echo "Server already running (PID $(cat $PID_FILE))" 14 | exit 0 15 | fi 16 | 17 | cd "$SERVER_DIR" || exit 1 18 | nohup $SERVER_CMD >"$LOG_FILE" 2>&1 & 19 | echo $! >"$PID_FILE" 20 | echo "Server started (PID $(cat $PID_FILE))" 21 | } 22 | 23 | stop_server() { 24 | if [ -f "$PID_FILE" ]; then 25 | PID=$(cat "$PID_FILE") 26 | if kill -0 "$PID" 2>/dev/null; then 27 | kill "$PID" 28 | rm -f "$PID_FILE" 29 | echo "Server (PID $PID) stopped" 30 | else 31 | echo "Stale PID file found, removing" 32 | rm -f "$PID_FILE" 33 | fi 34 | else 35 | echo "No PID file found. Server may not be running." 36 | fi 37 | } 38 | 39 | status_server() { 40 | if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then 41 | echo "Server is running (PID $(cat $PID_FILE))" 42 | else 43 | echo "Server is not running" 44 | fi 45 | } 46 | 47 | case "$1" in 48 | start) 49 | start_server 50 | ;; 51 | stop) 52 | stop_server 53 | ;; 54 | status) 55 | status_server 56 | ;; 57 | *) 58 | echo "Usage: $0 {start|stop|status}" 59 | exit 1 60 | ;; 61 | esac 62 | -------------------------------------------------------------------------------- /test/path/iterate-domain-levels.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import path from "../../chrome-extension/path.js"; 5 | import sinon from "sinon"; 6 | 7 | const iterateDomainLevels = path.iterateDomainLevels; 8 | 9 | describe("Iterate domain levels", function () { 10 | 11 | beforeEach(function () { 12 | sinon.restore(); 13 | }); 14 | 15 | it("one-level domain", function () { 16 | const levels = [...iterateDomainLevels("foo")]; 17 | assert.deepStrictEqual(levels, [ 18 | "foo" 19 | ]); 20 | }); 21 | 22 | it("two-level domain", function () { 23 | const levels = [...iterateDomainLevels("luciopaiva.com")]; 24 | assert.deepStrictEqual(levels, [ 25 | "com", "luciopaiva.com" 26 | ]); 27 | }); 28 | 29 | it("three-level domain", function () { 30 | const levels = [...iterateDomainLevels("www.google.com")]; 31 | assert.deepStrictEqual(levels, [ 32 | "com", "google.com", "www.google.com" 33 | ]); 34 | }); 35 | 36 | it("IP address", function () { 37 | const levels = [...iterateDomainLevels("10.0.1.2")]; 38 | assert.deepStrictEqual(levels, [ 39 | "2", "1.2", "0.1.2", "10.0.1.2" 40 | ]); 41 | }); 42 | 43 | it("empty domain", function () { 44 | const levels = [...iterateDomainLevels("")]; 45 | assert.strictEqual(levels.length, 0); 46 | }); 47 | 48 | it("undefined domain", function () { 49 | const levels = [...iterateDomainLevels(undefined)]; 50 | assert.strictEqual(levels.length, 0); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/util/fetch-script.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {util} from "../../chrome-extension/util/index.js"; 5 | import {FETCH_RESPONSE_OUTCOME} from "../../chrome-extension/util/fetch-script.js"; 6 | import sinon from "sinon"; 7 | 8 | describe("Fetch script", function () { 9 | 10 | beforeEach(function () { 11 | sinon.restore(); 12 | }); 13 | 14 | it("success", async function () { 15 | const result = await util.fetchScript("", fakeFetch(200, "foo")); 16 | assert.strictEqual(result.outcome, FETCH_RESPONSE_OUTCOME.SUCCESS); 17 | assert.strictEqual(result.contents, "foo"); 18 | }); 19 | 20 | it("not found", async function () { 21 | const result = await util.fetchScript("", fakeFetch(404)); 22 | assert.strictEqual(result.outcome, FETCH_RESPONSE_OUTCOME.NOT_FOUND); 23 | assert.strictEqual(result.contents, undefined); 24 | }); 25 | 26 | it("server error", async function () { 27 | const result = await util.fetchScript("", fakeFetch(500)); 28 | assert.strictEqual(result.outcome, FETCH_RESPONSE_OUTCOME.SERVER_FAILURE); 29 | assert.strictEqual(result.contents, undefined); 30 | }); 31 | 32 | it("fetch error", async function () { 33 | const result = await util.fetchScript("", () => { throw new Error(); }); 34 | assert.strictEqual(result.outcome, FETCH_RESPONSE_OUTCOME.FETCH_FAILURE); 35 | assert.strictEqual(result.contents, undefined); 36 | }); 37 | }); 38 | 39 | function fakeFetch(status, text) { 40 | return async () => { 41 | return { 42 | status: status, 43 | text: () => text, 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/integration/sandbox.js: -------------------------------------------------------------------------------- 1 | import DummyScriptServer from "./utils/dummy-script-server.js"; 2 | import {setScriptServerAddress, startBrowser, toggleDevModeOn, toggleUserScripts} from "./utils/browser-test-utils.js"; 3 | import DummyWebServer from "./utils/dummy-web-server.js"; 4 | 5 | (async () => { 6 | const browser = await startBrowser(false); 7 | 8 | const dummyWebServer = new DummyWebServer(); 9 | await dummyWebServer.start(); 10 | dummyWebServer.addPage("/hello.html", "

Hello World

"); 11 | 12 | const dummyScriptServer = new DummyScriptServer(); 13 | await dummyScriptServer.start(); 14 | // dummyScriptServer.addScript("/_global.js", "console.log('Global script loaded');"); 15 | dummyScriptServer.addScript("/_global.js", ` 16 | document.querySelector('h1').innerText = "Goodbye World"; 17 | `); 18 | 19 | await toggleDevModeOn(browser); 20 | 21 | // Set the server address in local storage 22 | await setScriptServerAddress(browser, `http://127.0.0.1:${dummyScriptServer.port}`); 23 | 24 | // const popUpPage = await browser.newPage() 25 | // await popUpPage.setViewport({width: 1000, height: 800}); 26 | // await popUpPage.goto(`chrome-extension://${EXTENSION_ID}/popup/popup.html`) 27 | 28 | const helloPage = await browser.newPage() 29 | // await helloPage.goto(`http://127.0.0.1:${dummyWebServer.port}/hello.html`) 30 | // await helloPage.goto(`http://foo.bar:${dummyWebServer.port}/hello.html`) 31 | await helloPage.goto(`chrome-extension://hokcepcfcicnhalinladgknhaljndhpc/popup/popup.html`); 32 | 33 | await toggleUserScripts(browser); 34 | 35 | await new Promise(() => {}); // Keeps the browser open indefinitely 36 | await browser.close() 37 | })() 38 | -------------------------------------------------------------------------------- /chrome-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Witchcraft: JS/CSS injector", 3 | "short_name": "Witchcraft", 4 | "manifest_version": 3, 5 | "version": "3.2.0", 6 | "description": "Witchcraft loads custom Javascript and CSS directly from a folder in your file system. Think GreaseMonkey for developers.", 7 | "icons": { 8 | "16": "witch-16.png", 9 | "24": "witch-24.png", 10 | "32": "witch-32.png", 11 | "64": "witch-64.png", 12 | "128": "witch-128.png" 13 | }, 14 | "action": { 15 | "default_icon": { 16 | "16": "witch-16.png", 17 | "24": "witch-24.png", 18 | "32": "witch-32.png", 19 | "64": "witch-64.png", 20 | "128": "witch-128.png" 21 | }, 22 | "default_title": "Witchcraft", 23 | "default_popup": "popup/popup.html" 24 | }, 25 | "content_scripts": [ 26 | { 27 | "world": "ISOLATED", 28 | "all_frames": true, 29 | "run_at": "document_start", 30 | "matches": [""], 31 | "js": ["content-script.js"] 32 | } 33 | ], 34 | "background": { 35 | "service_worker": "background.js", 36 | "type": "module" 37 | }, 38 | "permissions": [ 39 | "webNavigation", 40 | "storage", 41 | "activeTab", 42 | "scripting", 43 | "userScripts" 44 | ], 45 | "host_permissions": ["http://*/*", "https://*/*"], 46 | "content_security_policy": { 47 | "extension_pages": "script-src 'self'; object-src 'self'" 48 | }, 49 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjzqrpSUv6MTDsg0iay4oaGSBy/hErXo0W+vDixY0nrsptpVVzgd9bta0OepfLqEJxDLWLrp7XR7Q85DMy1gsY8VmCdowIPW+JQMxnogk8SIzwMMeonLZVyfqoaNn4/vzReMjn0iIRKcf6wznQ53podXZcCSGKBHah3Be0FSBaDXkzzIPq1WNpZa04OWwEcIc+V/bF4NvN9ySl45c+9dpt7hd2ly9gLf7Q6lZXVJHr4Z9EQr00dQnsmotDseHiuLq1GkhmiG3N9PdxxCbQ+yXjoSIsbEFK+prQQhGN4jr58nrXkZHF9PvFgpLaikrTtr0EDAbR56Flhh2pGJSQFVYVwIDAQAB" 50 | } 51 | -------------------------------------------------------------------------------- /docs/src/content/docs/cookbook/password.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Filling in unconventional password fields 3 | --- 4 | 5 | import { Card, Code } from '@astrojs/starlight/components'; 6 | import "./password.css"; 7 | 8 | Some websites use unconventional methods to handle password inputs, which can prevent standard password managers from recognizing and filling these fields. This guide will help you create a Witchcraft script to automatically fill in such password fields. 9 | 10 | Let's say you encounter a website that requires an 8-character password but then asks you to fill in only specific, random character positions from that password: 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 | The password manager will either try to fill the fields (incorrectly) or none at all. To solve this, you can create a Witchcraft script that injects a temporary password field into the page, allowing the password manager to fill it in. Once filled, the script can then read the necessary characters and populate the actual fields. 24 | 25 | ===Insert code here=== 26 | 27 | ## Variant: a virtual keyboard 28 | 29 | A variant of this problem is when the website provides a virtual keyboard for password entry, which can also prevent password managers from functioning correctly. 30 | 31 | The solution is similar: to inject a temporary password field into the page so that the password manager can fill it in. Once filled, the script can then read every character and press the corresponding keys on the virtual keyboard. 32 | -------------------------------------------------------------------------------- /test/path/generate-potential-script-names.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {GLOBAL_SCRIPT_NAME} from "../../chrome-extension/path.js"; 5 | import path from "../../chrome-extension/path.js"; 6 | import sinon from "sinon"; 7 | 8 | const {generatePotentialScriptNames} = path; 9 | 10 | describe("Script name generator", function () { 11 | 12 | beforeEach(function () { 13 | sinon.restore(); 14 | }); 15 | 16 | it("host and path", function () { 17 | const levels = [...generatePotentialScriptNames("https://www.luciopaiva.com/foo/bar/index.html")]; 18 | assert.deepStrictEqual(levels, [ 19 | GLOBAL_SCRIPT_NAME, 20 | `${GLOBAL_SCRIPT_NAME}/foo`, 21 | `${GLOBAL_SCRIPT_NAME}/foo/bar`, 22 | `${GLOBAL_SCRIPT_NAME}/foo/bar/index.html`, 23 | "com", 24 | `com/foo`, 25 | `com/foo/bar`, 26 | `com/foo/bar/index.html`, 27 | "luciopaiva.com", 28 | `luciopaiva.com/foo`, 29 | `luciopaiva.com/foo/bar`, 30 | `luciopaiva.com/foo/bar/index.html`, 31 | "www.luciopaiva.com", 32 | "www.luciopaiva.com/foo", 33 | "www.luciopaiva.com/foo/bar", 34 | "www.luciopaiva.com/foo/bar/index.html", 35 | ]); 36 | }); 37 | 38 | it("only host", function () { 39 | const levels = [...generatePotentialScriptNames("https://www.luciopaiva.com")]; 40 | assert.deepStrictEqual(levels, [ 41 | GLOBAL_SCRIPT_NAME, 42 | "com", 43 | "luciopaiva.com", 44 | "www.luciopaiva.com", 45 | ]); 46 | }); 47 | 48 | it("empty host", function () { 49 | const levels = [...generatePotentialScriptNames("")]; 50 | assert.deepStrictEqual(levels, [ 51 | GLOBAL_SCRIPT_NAME, 52 | ]); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/storage/frame-data.js: -------------------------------------------------------------------------------- 1 | import {beforeEach} from "mocha"; 2 | import sinon from "sinon"; 3 | import {storage} from "../../chrome-extension/storage/index.js"; 4 | import {browser} from "../../chrome-extension/browser.js"; 5 | import assert from "node:assert"; 6 | 7 | describe("Frame data storage", function () { 8 | 9 | beforeEach(function () { 10 | sinon.restore(); 11 | }); 12 | 13 | it("retrieve data from specific frame in tab", async function () { 14 | const tabId = 123; 15 | const frameId = 0; 16 | 17 | sinon.replace(browser, "retrieveKey", sinon.fake.resolves({ 18 | tabId, 19 | frameId, 20 | scripts: JSON.stringify(["script1.js", "script2.js"]), 21 | })); 22 | 23 | const result = await storage.retrieveFrame(tabId, frameId); 24 | 25 | sinon.assert.calledOnce(browser.retrieveKey); 26 | sinon.assert.calledWithExactly(browser.retrieveKey, "frame-scripts:123:0"); 27 | assert.deepStrictEqual(result, { 28 | tabId, 29 | frameId, 30 | scripts: JSON.stringify(["script1.js", "script2.js"]), 31 | }); 32 | }); 33 | 34 | it("retrieve data from all frames in tab", async function () { 35 | const tabId = 123; 36 | 37 | sinon.replace(browser, "retrieveAllEntries", sinon.fake.resolves([ 38 | ["frame-scripts:123:0", 1000], 39 | ["frame-scripts:123:1", 1001], 40 | ["frame-scripts:223:0", 1002], 41 | ])); 42 | 43 | const result = await storage.retrieveAllFrames(tabId); 44 | 45 | sinon.assert.calledOnce(browser.retrieveAllEntries); 46 | sinon.assert.calledWithExactly(browser.retrieveAllEntries); 47 | console.log(result); 48 | assert.deepStrictEqual(result, { 49 | "frame-scripts:123:0": 1000, 50 | "frame-scripts:123:1": 1001, 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/path/iterate-path-segments.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import path from "../../chrome-extension/path.js"; 5 | import sinon from "sinon"; 6 | 7 | const iteratePathSegments = path.iteratePathSegments; 8 | 9 | describe("Iterate path segments", function () { 10 | 11 | beforeEach(function () { 12 | sinon.restore(); 13 | }); 14 | 15 | it ("empty string", function () { 16 | const segments = [...iteratePathSegments("")]; 17 | assert.strictEqual(segments.length, 0); 18 | }); 19 | 20 | it ("undefined", function () { 21 | const segments = [...iteratePathSegments(undefined)]; 22 | assert.strictEqual(segments.length, 0); 23 | }); 24 | 25 | it ("empty path", function () { 26 | const segments = [...iteratePathSegments("/")]; 27 | assert.strictEqual(segments.length, 0); 28 | }); 29 | 30 | it ("empty path with duplicated slashes", function () { 31 | const segments = [...iteratePathSegments("//")]; 32 | assert.strictEqual(segments.length, 0); 33 | }); 34 | 35 | it ("regular path", function () { 36 | const segments = [...iteratePathSegments("/foo/bar/index.html")]; 37 | assert.deepStrictEqual(segments, [ 38 | "/foo", 39 | "/foo/bar", 40 | "/foo/bar/index.html", 41 | ]); 42 | }); 43 | 44 | it ("trailing slash", function () { 45 | const segments = [...iteratePathSegments("/foo/bar/")]; 46 | assert.deepStrictEqual(segments, [ 47 | "/foo", 48 | "/foo/bar", 49 | ]); 50 | }); 51 | 52 | it ("duplicated slashes", function () { 53 | const segments = [...iteratePathSegments("//foo/bar///index.html")]; 54 | assert.deepStrictEqual(segments, [ 55 | "/foo", 56 | "/foo/bar", 57 | "/foo/bar/index.html", 58 | ]); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 10 | 11 | ## 🚀 Project Structure 12 | 13 | Inside of your Astro + Starlight project, you'll see the following folders and files: 14 | 15 | ``` 16 | . 17 | ├── public/ 18 | ├── src/ 19 | │ ├── assets/ 20 | │ ├── content/ 21 | │ │ └── docs/ 22 | │ └── content.config.ts 23 | ├── astro.config.mjs 24 | ├── package.json 25 | └── tsconfig.json 26 | ``` 27 | 28 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 29 | 30 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 31 | 32 | Static assets, like favicons, can be placed in the `public/` directory. 33 | 34 | ## 🧞 Commands 35 | 36 | All commands are run from the root of the project, from a terminal: 37 | 38 | | Command | Action | 39 | | :------------------------ | :----------------------------------------------- | 40 | | `npm install` | Installs dependencies | 41 | | `npm run dev` | Starts local dev server at `localhost:4321` | 42 | | `npm run build` | Build your production site to `./dist/` | 43 | | `npm run preview` | Preview your build locally, before deploying | 44 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 45 | | `npm run astro -- --help` | Get help using the Astro CLI | 46 | 47 | ## 👀 Want to learn more? 48 | 49 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 50 | -------------------------------------------------------------------------------- /docs/src/content/docs/wsl2-python-server.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: WSL2 Python server 3 | --- 4 | 5 | import { Code } from '@astrojs/starlight/components'; 6 | import witchCraftServerScript from "../../assets/witchcraft-server.sh?raw"; 7 | 8 | If you're on Windows and have WSL2 installed, you can use the following bash script to start a Python HTTP server that serves your Witchcraft scripts folder. You can then use Windows Task Scheduler to run this script automatically at startup. 9 | 10 | Let's start by creating the script: 11 | 12 | 13 | 14 | Make it executable: 15 | 16 | ```bash 17 | chmod +x witchcraft-server.sh 18 | ``` 19 | 20 | And you can now test it to bring the server up and then down: 21 | 22 | ```bash 23 | ./witchcraft-server.sh start 24 | ./witchcraft-server.sh stop 25 | ``` 26 | 27 | With that working, you can now create a task in Windows Task Scheduler to run this script at startup: 28 | 29 | 1. Open Task Scheduler (you can find it by searching in the Start menu). 30 | 2. Click on "Create Task..." (not "basic") in the right-hand pane. 31 | 3. In the "General" tab: 32 | - Name your task (e.g., "Witchcraft Server") 33 | - Configure for "Windows 10/11" or your version of Windows 34 | 4. In the "Triggers" tab: 35 | - Click "New..." 36 | - Set "Begin the task" to "At log on" 37 | - Uncheck "Stop task if it runs longer than..." 38 | 5. In the "Actions" tab: 39 | - Click "New..." 40 | - Set "Action" to "Start a program" 41 | - In the "Program/script" field, enter `C:\Windows\System32\wsl.exe` 42 | - In the "Add arguments (optional)" field, enter this: `-d Ubuntu -- bash -lc '~/witchcraft-server.sh start'` (but double-check the script path to make sure it matches where you saved it in WSL2) 43 | 44 | And that's it. You can now manually run the task to test it, or simply restart your computer and check if the Witchcraft server is running by looking at the extension's status indicator. 45 | -------------------------------------------------------------------------------- /chrome-extension/storage/evict-stale.js: -------------------------------------------------------------------------------- 1 | import {browser} from "../browser.js"; 2 | import {storage} from "./index.js"; 3 | 4 | const EVICTION_PERIOD_IN_MILLIS = 1000 * 60 * 10; // 10 min 5 | 6 | export async function evictStale() { 7 | const now = Date.now(); 8 | if (await isItTimeToEvict(now)) { 9 | await browser.storeKey(storage.EVICTION_TIME_KEY, now + EVICTION_PERIOD_IN_MILLIS); 10 | await lookForKeysToEvict(); 11 | } 12 | } 13 | 14 | async function lookForKeysToEvict() { 15 | console.info("Looking for old cache entries to evict..."); 16 | 17 | let removedCount = 0; 18 | for (const entry of await browser.retrieveAllEntries()) { 19 | const [key, value] = entry; 20 | if (key.startsWith(storage.FRAME_SCRIPTS_KEY_PREFIX)) { 21 | const {tabId, frameId} = value; 22 | removedCount += (await tryEvictFrameKey(key, tabId, frameId)) ? 1 : 0; 23 | } else if (key.startsWith(storage.TAB_SCRIPT_COUNT_KEY_PREFIX)) { 24 | const {tabId} = value; 25 | removedCount += (await tryEvictTabScriptCountKey(key, tabId)) ? 1 : 0; 26 | } 27 | } 28 | 29 | console.info(`Entries removed: ${removedCount}`); 30 | } 31 | 32 | async function tryEvictFrameKey(key, tabId, frameId) { 33 | if (!(await doesFrameExist(tabId, frameId))) { 34 | // console.info(`Removing key ${key}...`); 35 | await storage.removeFrame(tabId, frameId); 36 | return true; 37 | } 38 | return false; 39 | } 40 | 41 | async function tryEvictTabScriptCountKey(key, tabId) { 42 | if (!(await doesFrameExist(tabId, 0))) { 43 | // console.info(`Removing key ${key}...`); 44 | await storage.removeTabScriptSet(tabId); 45 | return true; 46 | } 47 | return false; 48 | } 49 | 50 | async function doesFrameExist(tabId, frameId) { 51 | return !!(await browser.getFrame(tabId, frameId)); 52 | } 53 | 54 | async function isItTimeToEvict(now) { 55 | const nextTime = await browser.retrieveKey(storage.EVICTION_TIME_KEY); 56 | return (nextTime ?? 0) < now; 57 | } 58 | -------------------------------------------------------------------------------- /test/browser/chrome-api/get-tab-url.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import {describe, it} from "mocha"; 3 | import sinon from "sinon"; 4 | import {browser} from "../../../chrome-extension/browser.js"; 5 | 6 | describe("Chrome API - Get tab URL", function () { 7 | 8 | beforeEach(function () { 9 | sinon.restore(); 10 | }); 11 | 12 | it("successful get", async function () { 13 | const url = "https://foo.com"; 14 | 15 | const chrome = { 16 | tabs: { 17 | get: sinon.fake((tabId, callback) => { 18 | callback({url}); 19 | }), 20 | } 21 | }; 22 | sinon.replace(browser, "chrome", () => chrome); 23 | 24 | const tabId = 10; 25 | assert.strictEqual(await browser.getTabUrl(tabId), url); 26 | assert(chrome.tabs.get.calledWith(tabId)); 27 | }); 28 | 29 | it("resolved with runtime error", async function () { 30 | const url = "https://foo.com"; 31 | 32 | const chrome = { 33 | tabs: { 34 | get: sinon.fake((tabId, callback) => { 35 | callback({url}); 36 | }), 37 | }, 38 | runtime: { 39 | lastError: { 40 | message: "Something went wrong", 41 | } 42 | } 43 | }; 44 | sinon.replace(browser, "chrome", () => chrome); 45 | 46 | const tabId = 10; 47 | await assert.rejects(async () => { 48 | await browser.getTabUrl(tabId); 49 | }); 50 | assert(chrome.tabs.get.calledWith(tabId)); 51 | }); 52 | 53 | it("failed get", async function () { 54 | const chrome = { 55 | tabs: { 56 | get: sinon.fake.throws(), 57 | } 58 | }; 59 | sinon.replace(browser, "chrome", () => chrome); 60 | 61 | const tabId = 10; 62 | await assert.rejects(async () => { 63 | await browser.getTabUrl(tabId); 64 | }); 65 | assert(chrome.tabs.get.calledWith(tabId)); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/loader/load-includes.js: -------------------------------------------------------------------------------- 1 | 2 | import { describe, it } from "mocha"; 3 | import sinon from "sinon"; 4 | import {loader} from "../../chrome-extension/loader.js"; 5 | import {script} from "../../chrome-extension/script/index.js"; 6 | import {analytics} from "../../chrome-extension/analytics/index.js"; 7 | import {EXT_JS} from "../../chrome-extension/path.js"; 8 | 9 | const {IncludeContext, ScriptContext} = script; 10 | const {Metrics} = analytics; 11 | 12 | describe("Load includes", function () { 13 | 14 | beforeEach(function () { 15 | sinon.restore(); 16 | }); 17 | 18 | it("no includes", function () { 19 | const findIncludeDirective = sinon.stub(script, "findIncludeDirective"); 20 | const processIncludeDirective = sinon.stub(script, "processIncludeDirective"); 21 | const expandInclude = sinon.stub(script, "expandInclude"); 22 | 23 | const baseScript = new ScriptContext(); 24 | const metrics = new Metrics(); 25 | const visitedUrls = new Set(); 26 | 27 | loader.loadIncludes(baseScript, metrics, visitedUrls); 28 | 29 | sinon.assert.calledOnce(findIncludeDirective); 30 | sinon.assert.notCalled(processIncludeDirective); 31 | sinon.assert.notCalled(expandInclude); 32 | }); 33 | 34 | it("one include", async function () { 35 | const findIncludeDirective = sinon.stub(script, "findIncludeDirective"); 36 | 37 | const includeScript = new ScriptContext("/foo/bar", EXT_JS); 38 | const include = new IncludeContext(includeScript, 2, 7); 39 | findIncludeDirective.onFirstCall().returns(include); 40 | 41 | const processIncludeDirective = sinon.stub(script, "processIncludeDirective"); 42 | const expandInclude = sinon.stub(script, "expandInclude"); 43 | 44 | const baseScript = new ScriptContext(); 45 | const metrics = new Metrics(); 46 | const visitedUrls = new Set(); 47 | 48 | await loader.loadIncludes(baseScript, metrics, visitedUrls); 49 | 50 | sinon.assert.calledTwice(findIncludeDirective); 51 | sinon.assert.calledOnce(processIncludeDirective); 52 | sinon.assert.calledOnce(expandInclude); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/browser/chrome-api/capture-runtime-error.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import {describe, it} from "mocha"; 3 | import sinon from "sinon"; 4 | import {browser} from "../../../chrome-extension/browser.js"; 5 | 6 | describe("Chrome API - Capture runtime error", function () { 7 | 8 | beforeEach(function () { 9 | sinon.restore(); 10 | }); 11 | 12 | it("no errors", function () { 13 | const chrome = { 14 | runtime: {} 15 | }; 16 | sinon.replace(browser, "chrome", () => chrome); 17 | 18 | const logger = { 19 | error: sinon.stub(), 20 | }; 21 | 22 | assert.strictEqual(browser.captureRuntimeError(logger), false); 23 | assert(logger.error.notCalled); 24 | }); 25 | 26 | it("unknown error", function () { 27 | const chrome = { 28 | runtime: { 29 | lastError: { 30 | message: "Some unexpected error happened.", 31 | } 32 | } 33 | }; 34 | sinon.replace(browser, "chrome", () => chrome); 35 | 36 | const logger = { 37 | error: sinon.stub(), 38 | }; 39 | 40 | assert.strictEqual(browser.captureRuntimeError(logger), true); 41 | assert(logger.error.calledOnce); 42 | }); 43 | 44 | [ 45 | { 46 | name: "no tab with id", 47 | message: "No tab with id: 1064422151.", 48 | }, 49 | { 50 | name: "no frame with id", 51 | message: "No frame with id 3216546 in tab 1064422151.", 52 | }, 53 | ].forEach(({ name, message }) => { 54 | it(`ignored error: ${name}`, function () { 55 | const chrome = { 56 | runtime: { 57 | lastError: { 58 | message, 59 | }, 60 | } 61 | }; 62 | sinon.replace(browser, "chrome", () => chrome); 63 | 64 | const logger = { 65 | error: sinon.stub(), 66 | }; 67 | 68 | assert.strictEqual(browser.captureRuntimeError(logger), true); 69 | assert(logger.error.notCalled); 70 | }); 71 | }) 72 | }); 73 | -------------------------------------------------------------------------------- /chrome-extension/analytics/metrics.js: -------------------------------------------------------------------------------- 1 | 2 | import {EXT_CSS, EXT_JS} from "../path.js"; 3 | 4 | export default class Metrics { 5 | #jsHitCount = 0; 6 | #cssHitCount = 0; 7 | #errorCount = 0; 8 | #failCount = 0; 9 | #jsIncludesHitCount = 0; 10 | #cssIncludesHitCount = 0; 11 | #jsIncludesNotFoundCount = 0; 12 | #cssIncludesNotFoundCount = 0; 13 | #hasData = false; 14 | 15 | incrementHitCount(type) { 16 | if (type === EXT_JS) { 17 | this.#jsHitCount++; 18 | } else if (type === EXT_CSS) { 19 | this.#cssHitCount++; 20 | } 21 | this.#hasData = true; 22 | } 23 | 24 | incrementErrorCount() { 25 | this.#errorCount++; 26 | this.#hasData = true; 27 | } 28 | 29 | incrementFailCount() { 30 | this.#failCount++; 31 | this.#hasData = true; 32 | } 33 | 34 | incrementIncludesHit(type) { 35 | if (type === EXT_JS) { 36 | this.#jsIncludesHitCount++; 37 | } else if (type === EXT_CSS) { 38 | this.#cssIncludesHitCount++; 39 | } 40 | this.#hasData = true; 41 | } 42 | 43 | incrementIncludesNotFound(type) { 44 | if (type === EXT_JS) { 45 | this.#jsIncludesNotFoundCount++; 46 | } else if (type === EXT_CSS) { 47 | this.#cssIncludesNotFoundCount++; 48 | } 49 | this.#hasData = true; 50 | } 51 | 52 | get jsHitCount() { 53 | return this.#jsHitCount; 54 | } 55 | 56 | get cssHitCount() { 57 | return this.#cssHitCount; 58 | } 59 | 60 | get errorCount() { 61 | return this.#errorCount; 62 | } 63 | 64 | get failCount() { 65 | return this.#failCount; 66 | } 67 | 68 | get jsIncludesHitCount() { 69 | return this.#jsIncludesHitCount; 70 | } 71 | 72 | get cssIncludesHitCount() { 73 | return this.#cssIncludesHitCount; 74 | } 75 | 76 | get jsIncludesNotFoundCount() { 77 | return this.#jsIncludesNotFoundCount; 78 | } 79 | 80 | get cssIncludesNotFoundCount() { 81 | return this.#cssIncludesNotFoundCount; 82 | } 83 | 84 | get hasData() { 85 | return this.#hasData; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build docs 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | paths: ["docs/**"] 8 | 9 | # Runs on PRs to validate docs build 10 | pull_request: 11 | branches: ["main"] 12 | paths: ["docs/**"] 13 | 14 | # Allows you to run this workflow manually from the Actions tab 15 | workflow_dispatch: 16 | 17 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 18 | permissions: 19 | contents: read 20 | pages: write 21 | id-token: write 22 | 23 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 24 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 25 | concurrency: 26 | group: "pages" 27 | cancel-in-progress: false 28 | 29 | jobs: 30 | build: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Setup Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version-file: '.nvmrc' 40 | cache: 'npm' 41 | cache-dependency-path: 'docs/package-lock.json' 42 | 43 | - name: Setup Pages 44 | id: pages 45 | uses: actions/configure-pages@v5 46 | if: github.event_name == 'push' 47 | 48 | - name: Install dependencies 49 | run: npm ci 50 | working-directory: ./docs 51 | 52 | - name: Build with Astro 53 | run: npm run build 54 | working-directory: ./docs 55 | env: 56 | # Tell Astro where the site will be deployed 57 | ASTRO_SITE: ${{ steps.pages.outputs.origin }} 58 | ASTRO_BASE: ${{ steps.pages.outputs.base_path }} 59 | 60 | - name: Upload artifact 61 | uses: actions/upload-pages-artifact@v3 62 | if: github.event_name == 'push' 63 | with: 64 | path: ./docs/dist 65 | 66 | deploy: 67 | environment: 68 | name: github-pages 69 | url: ${{ steps.deployment.outputs.page_url }} 70 | runs-on: ubuntu-latest 71 | needs: build 72 | if: github.event_name == 'push' 73 | steps: 74 | - name: Deploy to GitHub Pages 75 | id: deployment 76 | uses: actions/deploy-pages@v4 77 | -------------------------------------------------------------------------------- /test/loader/inject-script.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import {describe, it, beforeEach} from "mocha"; 3 | import sinon from "sinon"; 4 | import {loader} from "../../chrome-extension/loader.js"; 5 | import {EXT_CSS, EXT_JS} from "../../chrome-extension/path.js"; 6 | import {browser} from "../../chrome-extension/browser.js"; 7 | 8 | describe("Inject script", function () { 9 | 10 | beforeEach(function () { 11 | sinon.restore(); 12 | }); 13 | 14 | const tabUrl = "https://bar.com"; 15 | const tabId = 10; 16 | const frameId = 20; 17 | 18 | [EXT_JS, EXT_CSS].forEach(type => { 19 | const script = { 20 | type: type, 21 | url: "https://foo.com", 22 | contents: "/** foo **/", 23 | }; 24 | 25 | it(`inject ${type}`, async function () { 26 | await runTest({ 27 | injectJs: sinon.fake(), 28 | injectCss: sinon.fake(), 29 | getTabUrl: sinon.fake.resolves(tabUrl), 30 | }); 31 | }); 32 | 33 | it(`fails to get tab URL when injecting ${type}`, async function () { 34 | await runTest({ 35 | injectJs: sinon.fake(), 36 | injectCss: sinon.fake(), 37 | // failing to get tab URL should not prevent script from loading 38 | getTabUrl: sinon.fake.rejects(), 39 | }); 40 | }); 41 | 42 | async function runTest(mockBrowser) { 43 | for (const [fn, fake] of Object.entries(mockBrowser)) { 44 | sinon.replace(browser, fn, fake); 45 | } 46 | 47 | await loader.injectScript(script, tabId, frameId); 48 | 49 | if (type === EXT_JS) { 50 | assert(mockBrowser.injectCss.notCalled); 51 | assert(mockBrowser.injectJs.calledOnce); 52 | assert(mockBrowser.injectJs.calledWithExactly(script.contents, tabId, frameId)); 53 | } else if (type === EXT_CSS) { 54 | assert(mockBrowser.injectJs.notCalled); 55 | assert(mockBrowser.injectCss.calledOnce); 56 | assert(mockBrowser.injectCss.calledWithExactly(script.contents, tabId, frameId)); 57 | } 58 | assert(mockBrowser.getTabUrl.calledOnce); 59 | assert(mockBrowser.getTabUrl.calledWithExactly(tabId)); 60 | } 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/util/embed-script.js: -------------------------------------------------------------------------------- 1 | import vm from "node:vm"; 2 | import assert from "node:assert"; 3 | import {describe, it} from "mocha"; 4 | import sinon from "sinon"; 5 | import {util} from "../../chrome-extension/util/index.js"; 6 | 7 | describe("Embed script", function () { 8 | 9 | beforeEach(function () { 10 | sinon.restore(); 11 | }); 12 | 13 | it("simple embed", function () { 14 | // step 1: prepare script to embed 15 | 16 | const contents = ` 17 | window.x = 123; 18 | `; 19 | const code = util.embedScript(contents); 20 | 21 | // step 2: pass it to the content script execution environment 22 | 23 | const script = { 24 | text: "", 25 | } 26 | 27 | const appendChildFn = sinon.fake(); 28 | 29 | const document = { 30 | createElement: sinon.fake.returns(script), 31 | documentElement: { 32 | appendChild: appendChildFn, 33 | } 34 | }; 35 | 36 | const context = { 37 | document, 38 | } 39 | 40 | // this simulates the content script execution environment injecting the script tag into the DOM 41 | vm.runInNewContext(code, context); 42 | 43 | assert(appendChildFn.calledOnce); 44 | 45 | // step 3: pass it to the page execution environment 46 | 47 | const scriptElement = appendChildFn.getCall(0).firstArg; 48 | const injectedCode = scriptElement.text; 49 | 50 | // this simulates the page execution environment running the injected code 51 | const pageContext = { 52 | window: {}, 53 | } 54 | vm.runInNewContext(injectedCode, pageContext); 55 | 56 | assert.strictEqual(pageContext.window.x, 123); 57 | }); 58 | 59 | it("injector", function () { 60 | const script = { 61 | text: "", 62 | } 63 | 64 | const appendChildFn = sinon.fake(); 65 | 66 | const document = { 67 | createElement: sinon.fake.returns(script), 68 | documentElement: { 69 | appendChild: appendChildFn, 70 | } 71 | }; 72 | 73 | util.injector(document); 74 | 75 | assert(document.documentElement.appendChild.calledOnce); 76 | assert(document.documentElement.appendChild.getCall(0).firstArg === script) 77 | 78 | return script.text; 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/integration/utils/dummy-web-server.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | 3 | class DummyWebServer { 4 | constructor() { 5 | this.server = null; 6 | this.port = null; 7 | this.pages = new Map(); 8 | this.cspPolicy = null; // Add CSP policy support 9 | } 10 | 11 | addPage(pagePath, pageContents) { 12 | this.pages.set(pagePath, pageContents); 13 | } 14 | 15 | // Add method to set CSP policy 16 | setCSPPolicy(policy) { 17 | this.cspPolicy = policy; 18 | } 19 | 20 | async start() { 21 | return new Promise((resolve, reject) => { 22 | this.server = http.createServer((req, res) => { 23 | if (this.pages.has(req.url)) { 24 | let contentType = 'text/html'; 25 | if (req.url.endsWith('.css')) { 26 | contentType = 'text/css'; 27 | } 28 | 29 | const headers = { 'Content-Type': contentType }; 30 | 31 | // Add CSP header if policy is set 32 | if (this.cspPolicy) { 33 | headers['Content-Security-Policy'] = this.cspPolicy; 34 | } 35 | 36 | res.writeHead(200, headers); 37 | res.end(this.pages.get(req.url)); 38 | } else { 39 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 40 | res.end('Not Found'); 41 | } 42 | }); 43 | 44 | this.server.listen(0, () => { 45 | this.port = this.server.address().port; 46 | console.log(`DummyWebServer is listening on port ${this.port}`); 47 | resolve(this.port); 48 | }); 49 | 50 | this.server.on('error', (err) => { 51 | reject(err); 52 | }); 53 | }); 54 | } 55 | 56 | async stop() { 57 | return new Promise((resolve, reject) => { 58 | if (this.server) { 59 | this.server.close((err) => { 60 | if (err) { 61 | reject(err); 62 | } else { 63 | this.server = null; 64 | this.port = null; 65 | resolve(); 66 | } 67 | }); 68 | } else { 69 | resolve(); 70 | } 71 | }); 72 | } 73 | } 74 | 75 | export default DummyWebServer; 76 | -------------------------------------------------------------------------------- /test/loader/load-scripts.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from "mocha"; 2 | import sinon from "sinon"; 3 | import {loader} from "../../chrome-extension/loader.js"; 4 | import {util} from "../../chrome-extension/util/index.js"; 5 | import {FETCH_RESPONSE_OUTCOME} from "../../chrome-extension/util/fetch-script.js"; 6 | import {DEFAULT_SERVER_ADDRESS} from "../../chrome-extension/constants.js"; 7 | import {browser} from "../../chrome-extension/browser.js"; 8 | 9 | describe("Load scripts", function () { 10 | 11 | beforeEach(function () { 12 | sinon.restore(); 13 | }); 14 | 15 | it("load simple scripts", async function () { 16 | const url = "https://google.com"; 17 | const tabId = 123; 18 | const frameId = 456; 19 | 20 | sinon.replace(browser, "retrieveKey", async (key) => { 21 | switch (key) { 22 | case "server-address": 23 | return DEFAULT_SERVER_ADDRESS; 24 | default: 25 | return null; 26 | } 27 | }); 28 | sinon.replace(browser, "removeKey", async () => {}); 29 | sinon.replace(browser, "storeKey", async () => {}); 30 | sinon.replace(browser, "setBadgeText", async () => {}); 31 | 32 | const fetchScript = sinon.stub(util, "fetchScript"); 33 | fetchScript 34 | .withArgs(`${DEFAULT_SERVER_ADDRESS}/_global.js`) 35 | .returns({ 36 | outcome: FETCH_RESPONSE_OUTCOME.SUCCESS, 37 | contents: `console.info("global");`, 38 | }) 39 | .withArgs(`${DEFAULT_SERVER_ADDRESS}/google.com.js`) 40 | .returns({ 41 | outcome: FETCH_RESPONSE_OUTCOME.SUCCESS, 42 | contents: `console.info("google.com");`, 43 | }); 44 | // all other script attempts will return 404 45 | fetchScript.returns({ 46 | outcome: FETCH_RESPONSE_OUTCOME.NOT_FOUND, 47 | contents: undefined, 48 | }); 49 | 50 | const sendScript = sinon.stub(loader, "injectScript"); 51 | 52 | await loader.loadScripts(url, tabId, frameId); 53 | 54 | sinon.assert.calledTwice(sendScript); 55 | sinon.assert.calledWith(sendScript, sinon.match({ 56 | path: '_global.js', 57 | type: 'js', 58 | url: `${DEFAULT_SERVER_ADDRESS}/_global.js` 59 | }), tabId, frameId); 60 | sinon.assert.calledWith(sendScript, sinon.match({ 61 | path: 'google.com.js', 62 | type: 'js', 63 | url: `${DEFAULT_SERVER_ADDRESS}/google.com.js` 64 | }), tabId, frameId); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /chrome-extension/path.js: -------------------------------------------------------------------------------- 1 | 2 | import ScriptContext from "./script/script-context.js"; 3 | import {tryParseUrl} from "./url.js"; 4 | 5 | export const GLOBAL_SCRIPT_NAME = "_global"; 6 | export const EXT_JS = "js"; 7 | export const EXT_CSS = "css"; 8 | 9 | function generatePotentialScriptNames(url) { 10 | const {hostName, pathName} = tryParseUrl(url); 11 | 12 | const domains = [GLOBAL_SCRIPT_NAME, ...iterateDomainLevels(hostName)]; 13 | const paths = [...iteratePathSegments(pathName)]; 14 | const result = []; 15 | 16 | for (const domain of domains) { 17 | result.push(domain); 18 | for (const path of paths) { 19 | result.push(domain + path); 20 | } 21 | } 22 | 23 | return result; 24 | } 25 | 26 | /** 27 | * Maps a domain like "foo.bar.com" to ("com", "bar.com", "foo.bar.com"). 28 | * 29 | * @param {string} hostName 30 | * @returns {IterableIterator} 31 | */ 32 | function *iterateDomainLevels(hostName = "") { 33 | const parts = hostName.split(".").filter(p => p.length > 0); 34 | for (let i = parts.length - 1; i >= 0; i--) { 35 | yield parts.slice(i, parts.length).join("."); 36 | } 37 | } 38 | 39 | /** 40 | * Maps a path like "/foo/bar/index.html" to ("/foo", "/foo/bar", "/foo/bar/index.html"). 41 | * 42 | * @param {string} pathName 43 | * @return {IterableIterator} 44 | */ 45 | function *iteratePathSegments(pathName = "/") { 46 | const segments = pathName 47 | .split(/\/+/) 48 | .filter(s => s.length > 0); 49 | 50 | for (let i = 1; i <= segments.length; i++) { 51 | yield "/" + segments.slice(0, i).join("/"); 52 | } 53 | } 54 | 55 | /** 56 | * Maps "foo.bar.com/fizz/buzz.html" to ["foo.bar.com/fizz/buzz.html.js", "foo.bar.com/fizz/buzz.html.css"]. 57 | * 58 | * @param {string} path 59 | * @returns {[[string, string], [string, string]]} 60 | */ 61 | function mapToJsAndCss(path) { 62 | return [ 63 | [path, EXT_JS], 64 | [path, EXT_CSS], 65 | ]; 66 | } 67 | 68 | function pathTupleToScriptContext([path, ext]) { 69 | return new ScriptContext(appendExtension(path, ext), ext); 70 | } 71 | 72 | function appendExtension(path, ext) { 73 | return `${path}.${ext}`; 74 | } 75 | 76 | /* 77 | * Functions are exported like this instead of directly exposed so that they can be unit-tested. 78 | * See https://javascript.plainenglish.io/unit-testing-challenges-with-modular-javascript-patterns-22cc22397362 79 | */ 80 | const path = { 81 | generatePotentialScriptNames, 82 | iterateDomainLevels, 83 | iteratePathSegments, 84 | mapToJsAndCss, 85 | pathTupleToScriptContext, 86 | }; 87 | 88 | export default path; 89 | -------------------------------------------------------------------------------- /docs/src/content/docs/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: Introduction to Witchcraft 4 | --- 5 | 6 | import { Image } from 'astro:assets'; 7 | import { Code } from '@astrojs/starlight/components'; 8 | import exampleImage from "../../assets/screenshot.png"; 9 | import exampleJs from "../../assets/google.com.js?raw"; 10 | import exampleCss from "../../assets/google.com.css?raw"; 11 | 12 | Witchcraft is a Google Chrome extension for loading custom JavaScript and CSS directly from a folder on your local machine, injecting them into web pages that match specified URL patterns. 13 | 14 | Pattern-matching is done using the page's URL. You can target specific pages, partial or entire domains. For example, navigating to `https://google.com` will match `google.com.js` and `google.com.css`: 15 | 16 | Witchcraft screenshot 17 | 18 | For the screenshot above, the following scripts were used: 19 | 20 | 21 | 22 | 23 | 24 | Actual practical uses include getting rid of nasty ads, automating clicks and improving page layouts with your own CSS rules. You're only limited by what Javascript and CSS can do. 25 | 26 | ## Main features 27 | 28 | This is what makes Witchcraft different than other similar extensions: 29 | 30 | - **JavaScript and CSS injection** 31 | 32 | Witchcraft is not only able to run Javascript, but also CSS; 33 | 34 | - **editing scripts is easy** 35 | 36 | Just edit your scripts in your favorite editor and refresh the page; 37 | 38 | - **cross-platform** 39 | 40 | Since Witchcraft runs as a Chrome extension, it is runs on any platform that supports Chrome extensions (Linux, Windows, MacOS); 41 | 42 | - **runs on all Chromium-based browsers** 43 | 44 | Witchcraft should work on any Chromium-based browser that supports extensions, such as Microsoft Edge, Brave and Opera; 45 | 46 | - **runs on all frames** 47 | 48 | Witchcraft will inject your scripts into all frames of the page, including iframes; 49 | 50 | - **runs on the page's context** 51 | 52 | Witchcraft runs your scripts in the page's context, giving you full access to the page's JavaScript environment; 53 | 54 | - **open source** 55 | 56 | Witchcraft is open source and available on [GitHub](https://github.com/luciopaiva/witchcraft). That means you can load it directly into Chrome as an unpacked extension, if you prefer (which is not the case for other popular extensions like Tampermonkey, which was turned closed source). 57 | 58 | For a deeper dive into Witchcraft's features, check out the [user guide](/how-to-use). 59 | -------------------------------------------------------------------------------- /chrome-extension/icon.js: -------------------------------------------------------------------------------- 1 | 2 | import {browser} from "./browser.js"; 3 | import {storage} from "./storage/index.js"; 4 | import {SERVER_PING_PERIOD_IN_MILLIS} from "./constants.js"; 5 | import {util} from "./util/index.js"; 6 | 7 | const ICON_SIZE = 16; 8 | const ICON_SERVER_ON_NAME = "server-on"; 9 | const ICON_SERVER_OFF_NAME = "server-off"; 10 | 11 | async function createIcons() { 12 | const image = await loadImage("/witch-16.png"); 13 | await makeIconWithStatusColor(image, "#00ff00", icon.ICON_SERVER_ON_NAME); 14 | await makeIconWithStatusColor(image, "#ff0000", icon.ICON_SERVER_OFF_NAME); 15 | } 16 | 17 | async function loadImage(path) { 18 | const response = await fetch(browser.getFileUrl(path)); 19 | const blob = await response.blob(); 20 | return await createImageBitmap(blob); 21 | } 22 | 23 | async function makeIconWithStatusColor(baseImage, color, iconKey) { 24 | const canvas = new OffscreenCanvas(icon.ICON_SIZE, icon.ICON_SIZE); 25 | const ctx = canvas.getContext("2d"); 26 | ctx.drawImage(baseImage, 0, 0); 27 | 28 | ctx.fillStyle = color; 29 | ctx.beginPath(); 30 | const size = 3; 31 | ctx.fillRect(icon.ICON_SIZE - size, 0, size, size); 32 | ctx.fill(); 33 | 34 | const imageData = ctx.getImageData(0, 0, icon.ICON_SIZE, icon.ICON_SIZE); 35 | await storage.storeIcon(iconKey, imageData.data); 36 | } 37 | 38 | async function initialize() { 39 | await icon.createIcons(); 40 | await icon.updateServerStatus(); 41 | setInterval(icon.updateServerStatus, SERVER_PING_PERIOD_IN_MILLIS); 42 | } 43 | 44 | const cache = new Map(); 45 | 46 | async function loadIcon(name) { 47 | let imageData = cache.get(name); 48 | if (!imageData) { 49 | const data = await storage.retrieveIcon(name); 50 | imageData = new ImageData(data, icon.ICON_SIZE, icon.ICON_SIZE); 51 | cache.set(name, imageData); 52 | } else { 53 | } 54 | return imageData; 55 | } 56 | 57 | async function loadServerOffIcon() { 58 | return await icon.loadIcon(icon.ICON_SERVER_OFF_NAME); 59 | } 60 | 61 | async function loadServerOnIcon() { 62 | return await icon.loadIcon(icon.ICON_SERVER_ON_NAME); 63 | } 64 | 65 | async function updateServerStatus() { 66 | const isOnline = await util.ping(); 67 | const imageData = isOnline ? await icon.loadServerOnIcon() : await icon.loadServerOffIcon(); 68 | await browser.setIcon(imageData); 69 | await storage.storeServerStatus(isOnline); 70 | } 71 | 72 | export const icon = { 73 | ICON_SERVER_ON_NAME, 74 | ICON_SERVER_OFF_NAME, 75 | ICON_SIZE, 76 | createIcons, 77 | loadIcon, 78 | loadServerOnIcon, 79 | loadServerOffIcon, 80 | initialize, 81 | updateServerStatus, 82 | }; 83 | -------------------------------------------------------------------------------- /development.md: -------------------------------------------------------------------------------- 1 | 2 | # Development 3 | 4 | Node.js is required, but just to run tests. `nvm` is recommended to manage Node.js versions, but not required (just make sure your Node.js version is similar to the one `.nvmrc` currently points to). To install test dependencies: 5 | 6 | nvm i 7 | npm i 8 | 9 | Then you're ready to run the tests with coverage: 10 | 11 | npm test 12 | 13 | You can also run tests with Docker: 14 | 15 | docker build -t witchcraft-tests . 16 | docker run --rm witchcraft-tests 17 | 18 | ## Note about integration tests 19 | 20 | Integration tests use `puppeteer` to run a headless Chrome instance. One of the tests checks if the scripts server is reachable from the browser context, but Witchcraft only checks for the server once every 5 seconds, which is too slow for the test. To work around this, I've added a build step that simply copies all the code into `./dist` while also changing constants.js to make Witchcraft check for the server more frequently. This is done in `npm run build`, which is run automatically when running `npm test:integration`. 21 | 22 | I tried to use other methods of verifying whether Chrome was running by an automated test, but it ended up being simpler to just change the code for the tests. If you have a better idea, a PR is welcome. 23 | 24 | ## Running integration tests on WSL2 25 | 26 | It's possible to run integration tests on WSL2, but it requires some setup. [This SO answer](https://stackoverflow.com/a/78776116/778272) nails it: 27 | 28 | ``` 29 | sudo apt update 30 | sudo apt install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils 31 | ``` 32 | 33 | After installing all these packages, you should be able to run the following test: 34 | 35 | ``` 36 | npm i puppeteer # nodejs +14 37 | cat < index.js 38 | const puppeteer = require('puppeteer'); 39 | (async () => { 40 | const browser = await puppeteer.launch() 41 | const page = await browser.newPage() 42 | await page.goto('https://www.google.com/') 43 | const title = await page.title() 44 | console.log(title) // prints "Google" 45 | await browser.close() 46 | })() 47 | EOF 48 | node index.js 49 | # prints "Google" 50 | ``` 51 | 52 | ## Analytics 53 | 54 | Analytics is not required, but can be optionally set via the following instructions. 55 | 56 | To set up GA, the file `./chrome-extension/credentials.json` must be created. Its format should be: 57 | 58 | { 59 | "measurementId": "G-XXXXXXXXXX", 60 | "apiSecret": "0123456789012345678901" 61 | } 62 | 63 | Where `measurementId` and `apiSecret` are values obtained from the Google Analytics console. Witchcraft is currently set up to use GA4. 64 | 65 | -------------------------------------------------------------------------------- /test/integration/utils/dummy-script-server.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | 3 | class DummyScriptServer { 4 | constructor() { 5 | this.server = null; 6 | this.port = null; 7 | this.scripts = new Map(); 8 | this.requests = []; 9 | } 10 | 11 | addScript(scriptPath, scriptContents) { 12 | if (typeof scriptContents === "function") { 13 | scriptContents = scriptContents.toString().trim(); 14 | // console.info(scriptContents); 15 | 16 | // check if is lambda function 17 | if (scriptContents.startsWith('function')) { 18 | scriptContents = scriptContents.substring(scriptContents.indexOf('{') + 1, scriptContents.lastIndexOf('}')).trim(); 19 | } else if (scriptContents.startsWith('() =>')) { 20 | scriptContents = scriptContents.substring(scriptContents.indexOf('() =>') + 6).trim(); 21 | } 22 | } 23 | this.scripts.set(scriptPath, scriptContents); 24 | } 25 | 26 | async start() { 27 | return new Promise((resolve, reject) => { 28 | this.server = http.createServer((req, res) => { 29 | if (this.scripts.has(req.url)) { 30 | this.recordRequest(req.url, "HIT"); 31 | 32 | let contentType = 'text/plain'; 33 | if (req.url.endsWith('.js')) { 34 | contentType = 'application/javascript'; 35 | } else if (req.url.endsWith('.css')) { 36 | contentType = 'text/css'; 37 | } 38 | 39 | res.writeHead(200, { 'Content-Type': contentType }); 40 | res.end(this.scripts.get(req.url)); 41 | } else { 42 | this.recordRequest(req.url, "MISS"); 43 | 44 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 45 | res.end('Not Found'); 46 | } 47 | }); 48 | 49 | this.server.listen(0, () => { 50 | this.port = this.server.address().port; 51 | console.log(`DummyScriptServer is listening on port ${this.port}`); 52 | resolve(this.port); 53 | }); 54 | 55 | this.server.on('error', (err) => { 56 | reject(err); 57 | }); 58 | }); 59 | } 60 | 61 | async stop() { 62 | return new Promise((resolve, reject) => { 63 | if (this.server) { 64 | this.server.close((err) => { 65 | if (err) { 66 | reject(err); 67 | } else { 68 | this.server = null; 69 | this.port = null; 70 | resolve(); 71 | } 72 | }); 73 | } else { 74 | resolve(); 75 | } 76 | }); 77 | } 78 | 79 | recordRequest(url, status) { 80 | this.requests.push([url, status]); 81 | // console.info(`Script Server Request: ${url} (${status})`); 82 | } 83 | } 84 | 85 | export default DummyScriptServer; -------------------------------------------------------------------------------- /chrome-extension/util/index.js: -------------------------------------------------------------------------------- 1 | 2 | import {FETCH_RESPONSE_OUTCOME, fetchScript} from "./fetch-script.js"; 3 | import {embedScript, injector} from "./embed-script.js"; 4 | import {storage} from "../storage/index.js"; 5 | import {browser} from "../browser.js"; 6 | 7 | function base64ToTypedArray(base64Data, TypedArrayType) { 8 | const decodedString = atob(base64Data); 9 | const byteValues = Array.from(decodedString).map((char) => char.charCodeAt(0)); 10 | return new TypedArrayType(byteValues); 11 | } 12 | 13 | async function ping() { 14 | const serverAddress = await storage.retrieveServerAddress(); 15 | const response = await util.fetchScript(serverAddress); 16 | return !response || (response.outcome !== FETCH_RESPONSE_OUTCOME.FETCH_FAILURE); 17 | } 18 | 19 | async function sequential(tasks) { 20 | for (const task of tasks) { 21 | if (typeof task === "function") { 22 | await task(); 23 | } else if (task instanceof Promise) { 24 | await task; 25 | } 26 | } 27 | } 28 | 29 | async function until(conditionFunction, timeout = 2000) { 30 | const start = performance.now(); 31 | while (performance.now() - start < timeout) { 32 | if (conditionFunction()) { 33 | return true; 34 | } 35 | await new Promise(resolve => setTimeout(resolve, 10)); 36 | } 37 | return false; 38 | } 39 | 40 | /** 41 | * Splices a string. See https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Array/splice 42 | * for more info. 43 | * 44 | * @param {String} str - string that is going to be spliced 45 | * @param {Number} startIndex - where to start the cut 46 | * @param {Number} endIndex - where to end the cut 47 | * @param {String} whatToReplaceWith - the substring that will replace the removed one 48 | * @return {String} the resulting string 49 | */ 50 | function spliceString(str, startIndex, endIndex, whatToReplaceWith) { 51 | return str.substring(0, startIndex) + whatToReplaceWith + str.substring(endIndex); 52 | } 53 | 54 | function typedArrayToBase64(data) { 55 | return btoa(String.fromCharCode.apply(null, data)); 56 | } 57 | 58 | async function loadJson(jsonFileName, fetchFn = fetch) { 59 | try { 60 | const response = await fetchFn(browser.getFileUrl(jsonFileName)); 61 | if (response.ok) { 62 | return await response.json(); 63 | } 64 | } catch (_) { 65 | } 66 | return undefined; 67 | } 68 | 69 | function zip(...arrays) { 70 | const length = Math.min(...arrays.map(array => array.length)); 71 | const result = []; 72 | for (let i = 0; i < length; i++) { 73 | result.push(arrays.map(array => array[i])) 74 | } 75 | return result; 76 | } 77 | 78 | /* 79 | * Functions are exported like this instead of directly exposed so that they can be unit-tested. 80 | * See https://javascript.plainenglish.io/unit-testing-challenges-with-modular-javascript-patterns-22cc22397362 81 | */ 82 | export const util = { 83 | base64ToTypedArray, 84 | embedScript, 85 | fetchScript, 86 | injector, 87 | loadJson, 88 | ping, 89 | sequential, 90 | spliceString, 91 | typedArrayToBase64, 92 | zip, 93 | until, 94 | }; 95 | -------------------------------------------------------------------------------- /test/integration/utils/browser-test-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Browser-related utility functions for integration testing 3 | */ 4 | import puppeteer from "puppeteer"; 5 | 6 | const EXTENSION_PATH = "./dist"; 7 | const EXTENSION_ID = "hokcepcfcicnhalinladgknhaljndhpc"; 8 | 9 | export async function startBrowser(headless = true) { 10 | return await puppeteer.launch({ 11 | headless: headless, 12 | args: [ 13 | `--disable-extensions-except=${EXTENSION_PATH}`, 14 | `--load-extension=${EXTENSION_PATH}`, 15 | "--enable-extension-developer-mode", 16 | "--host-resolver-rules=MAP * 127.0.0.1", // this allows tests to fake requests to various domains 17 | "--no-sandbox", // necessary to run in Docker 18 | ], 19 | }); 20 | } 21 | 22 | /** 23 | * Toggles developer mode on in Chrome extensions page 24 | * @param {puppeteer.Browser} browser - The Puppeteer browser instance 25 | */ 26 | export async function toggleDevModeOn(browser) { 27 | // taken from https://github.com/puppeteer/puppeteer/issues/5095#issuecomment-590292518 28 | const [chromeExtensionsTab] = await browser.pages(); 29 | // const chromeExtensionsTab = await browser.newPage(); 30 | 31 | await chromeExtensionsTab.goto("chrome://extensions"); 32 | await chromeExtensionsTab.waitForSelector("body > extensions-manager"); 33 | const devModeToggle = await chromeExtensionsTab.evaluateHandle( 34 | 'document.querySelector("body > extensions-manager").shadowRoot.querySelector("extensions-toolbar").shadowRoot.querySelector("#devMode")' 35 | ); 36 | await devModeToggle.click(); 37 | console.info(`DevMode Toggled on`); 38 | 39 | await new Promise(resolve => setTimeout(resolve, 1000)); 40 | } 41 | 42 | export async function setScriptServerAddress(browser, serverAddress) { 43 | // const targets = await browser.targets(); 44 | // const extensionTarget = targets.find(target => target.type() === 'background_page' || target.type() === 'service_worker'); 45 | // const client = await extensionTarget.createCDPSession(); 46 | // await client.send('Runtime.evaluate', { 47 | // expression: `browser.storage.local.set({ "server-address": "${serverAddress}" })`, 48 | // }); 49 | 50 | // Open the extension popup to access its chrome.storage context 51 | const extensionPage = await browser.newPage(); 52 | await extensionPage.goto(`chrome-extension://${EXTENSION_ID}/popup/popup.html`); 53 | 54 | // Set the server-address using Chrome Extensions storage API 55 | await extensionPage.evaluate((address) => { 56 | return new Promise((resolve) => { 57 | chrome.storage.local.set({ 'server-address': address }, resolve); 58 | }); 59 | }, serverAddress); 60 | 61 | await extensionPage.close(); 62 | } 63 | 64 | export async function toggleUserScripts(browser) { 65 | const extPage = await browser.newPage(); 66 | await extPage.goto(`chrome://extensions/?id=${EXTENSION_ID}`); 67 | 68 | await extPage.waitForSelector("body > extensions-manager"); 69 | const userScriptsToggle = await extPage.evaluateHandle( 70 | 'document.querySelector("body > extensions-manager").shadowRoot.querySelector("#viewManager > extensions-detail-view").shadowRoot.querySelector("#allow-user-scripts").shadowRoot.querySelector("#crToggle")' 71 | ); 72 | await userScriptsToggle.click(); 73 | } 74 | -------------------------------------------------------------------------------- /test/script/process-include-directive.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it, setup, teardown } from "mocha"; 4 | import sinon from "sinon"; 5 | import {loader} from "../../chrome-extension/loader.js"; 6 | import {script} from "../../chrome-extension/script/index.js"; 7 | import Metrics from "../../chrome-extension/analytics/metrics.js"; 8 | import {url} from "../../chrome-extension/url.js"; 9 | import {EXT_CSS, EXT_JS} from "../../chrome-extension/path.js"; 10 | 11 | const IncludeContext = script.IncludeContext; 12 | const ScriptContext = script.ScriptContext; 13 | 14 | describe("Process include directive", function () { 15 | 16 | let metrics; 17 | let ctx; 18 | let include; 19 | let visitedUrls; 20 | 21 | setup(function () { 22 | sinon.stub(url, "composeUrl").callsFake((_, path) => "https://www.google.com" + path); 23 | 24 | metrics = new Metrics(); 25 | ctx = new ScriptContext("/foo/bar", EXT_JS); 26 | include = new IncludeContext(ctx, 1, 2); 27 | visitedUrls = new Set(); 28 | }); 29 | 30 | teardown(function () { 31 | sinon.restore(); 32 | }); 33 | 34 | [EXT_JS, EXT_CSS].forEach(scriptType => { 35 | it(`unvisited ${scriptType} script`, async function () { 36 | ctx.type = scriptType; 37 | 38 | sinon.stub(loader, "loadSingleScript").callsFake((script) => { 39 | script.contents = "foo"; 40 | }); 41 | 42 | await script.processIncludeDirective(ctx, include, metrics, visitedUrls); 43 | 44 | assert.ok(include.script.hasContents); 45 | assert.strictEqual(include.script.contents, "foo"); 46 | assert.strictEqual(metrics.jsIncludesHitCount, scriptType === EXT_JS ? 1 : 0); 47 | assert.strictEqual(metrics.cssIncludesHitCount, scriptType === EXT_CSS ? 1 : 0); 48 | assert.strictEqual(metrics.jsIncludesNotFoundCount, 0); 49 | assert.strictEqual(metrics.cssIncludesNotFoundCount, 0); 50 | }); 51 | }); 52 | 53 | [EXT_JS, EXT_CSS].forEach(scriptType => { 54 | it(`${scriptType} script not found`, async function () { 55 | ctx.type = scriptType; 56 | 57 | sinon.stub(loader, "loadSingleScript"); 58 | 59 | await script.processIncludeDirective(ctx, include, metrics, visitedUrls); 60 | 61 | assert.ok(include.script.hasContents); 62 | assert.notStrictEqual(include.script.contents, "foo"); 63 | assert.strictEqual(metrics.jsIncludesHitCount, 0); 64 | assert.strictEqual(metrics.cssIncludesHitCount, 0); 65 | assert.strictEqual(metrics.jsIncludesNotFoundCount, scriptType === EXT_JS ? 1 : 0); 66 | assert.strictEqual(metrics.cssIncludesNotFoundCount, scriptType === EXT_CSS ? 1 : 0); 67 | }); 68 | }); 69 | 70 | it("visited script", async function () { 71 | sinon.stub(loader, "loadSingleScript").callsFake((script) => { 72 | script.contents = "foo"; 73 | }); 74 | 75 | visitedUrls.add("https://www.google.com/foo/bar"); 76 | 77 | await script.processIncludeDirective(ctx, include, metrics, visitedUrls); 78 | 79 | assert.ok(include.script.hasContents); 80 | assert.notStrictEqual(include.script.contents, "foo"); 81 | assert.strictEqual(metrics.jsIncludesHitCount, 0); 82 | assert.strictEqual(metrics.jsIncludesNotFoundCount, 0); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/loader/load-single-script.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {loader} from "../../chrome-extension/loader.js"; 5 | import {script} from "../../chrome-extension/script/index.js"; 6 | import sinon from "sinon"; 7 | import {analytics} from "../../chrome-extension/analytics/index.js"; 8 | import {util} from "../../chrome-extension/util/index.js"; 9 | import {FETCH_RESPONSE_OUTCOME} from "../../chrome-extension/util/fetch-script.js"; 10 | import {EXT_CSS, EXT_JS} from "../../chrome-extension/path.js"; 11 | 12 | const {ScriptContext} = script; 13 | const {Metrics} = analytics; 14 | 15 | describe("Load single script", function () { 16 | 17 | const SAMPLE_CONTENT = "let x = 1;"; 18 | const SAMPLE_URL = "https://foo.bar"; 19 | 20 | const testCase1 = { 21 | scriptType: EXT_JS, 22 | outcome: FETCH_RESPONSE_OUTCOME.SUCCESS, 23 | didLoad: true, 24 | jsHitCount: 1, 25 | cssHitCount: 0, 26 | errorCount: 0, 27 | failCount: 0, 28 | }; 29 | 30 | const testCase2 = { 31 | scriptType: EXT_JS, 32 | outcome: FETCH_RESPONSE_OUTCOME.SERVER_FAILURE, 33 | didLoad: false, 34 | jsHitCount: 0, 35 | cssHitCount: 0, 36 | errorCount: 1, 37 | failCount: 0, 38 | }; 39 | 40 | const testCase3 = { 41 | scriptType: EXT_JS, 42 | outcome: FETCH_RESPONSE_OUTCOME.FETCH_FAILURE, 43 | didLoad: false, 44 | jsHitCount: 0, 45 | cssHitCount: 0, 46 | errorCount: 0, 47 | failCount: 1, 48 | }; 49 | 50 | const testCase4 = { 51 | scriptType: EXT_CSS, 52 | outcome: FETCH_RESPONSE_OUTCOME.SUCCESS, 53 | didLoad: true, 54 | jsHitCount: 0, 55 | cssHitCount: 1, 56 | errorCount: 0, 57 | failCount: 0, 58 | }; 59 | 60 | beforeEach(function () { 61 | sinon.restore(); 62 | }); 63 | 64 | [testCase1, testCase2, testCase3, testCase4].forEach(testCase => { 65 | it(`simple load, type ${testCase.scriptType}, outcome ${testCase.outcome}`, async function () { 66 | sinon.stub(util, "fetchScript").returns({ 67 | outcome: testCase.outcome, 68 | contents: SAMPLE_CONTENT, 69 | }); 70 | sinon.stub(script, "processIncludeDirective"); 71 | sinon.stub(script, "expandInclude"); 72 | sinon.stub(script, "findIncludeDirective"); 73 | const loadIncludes = sinon.stub(loader, "loadIncludes"); 74 | 75 | const ctx = new ScriptContext("/foo/bar", testCase.scriptType); 76 | ctx.url = SAMPLE_URL; 77 | const metrics = new Metrics(); 78 | const visitedUrls = new Set(); 79 | 80 | await loader.loadSingleScript(ctx, metrics, visitedUrls); 81 | 82 | assert.strictEqual(visitedUrls.size, 1); 83 | assert.strictEqual([...visitedUrls.values()][0], SAMPLE_URL); 84 | assert.strictEqual(ctx.hasContents, testCase.didLoad); 85 | assert.strictEqual(ctx.contents, testCase.didLoad ? SAMPLE_CONTENT : undefined); 86 | assert.strictEqual(metrics.jsHitCount, testCase.jsHitCount); 87 | assert.strictEqual(metrics.cssHitCount, testCase.cssHitCount); 88 | assert.strictEqual(metrics.errorCount, testCase.errorCount); 89 | assert.strictEqual(metrics.failCount, testCase.failCount); 90 | if (testCase.didLoad) { 91 | sinon.assert.calledOnce(loadIncludes); 92 | } else { 93 | sinon.assert.notCalled(loadIncludes); 94 | } 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /chrome-extension/script/index.js: -------------------------------------------------------------------------------- 1 | 2 | import {util} from "../util/index.js"; 3 | import {url} from "../url.js"; 4 | import {loader} from "../loader.js"; 5 | import {EXT_CSS} from "../path.js"; 6 | import ScriptContext from "./script-context.js"; 7 | import IncludeContext from "./include-context.js"; 8 | 9 | /** 10 | * 11 | * @param {ScriptContext} script 12 | * @param {IncludeContext} include 13 | */ 14 | function expandInclude(script, include) { 15 | script.contents = util.spliceString( 16 | script.contents, 17 | include.startIndex, 18 | include.endIndex, 19 | include.script.contents 20 | ); 21 | } 22 | 23 | // either `// @include foo.js` or `/* @include foo.js */` 24 | const INCLUDE_JS_RE = /(?:\/\/|\/\*)[ \t]*@include[ \t]*(".*?"|[^*\s]+).*$/m; 25 | // only `/* @include foo.js */` is acceptable 26 | const INCLUDE_CSS_RE = /\/\*[ \t]*@include[ \t]*(".*?"|\S+)[ \t]*\*\/.*$/m; 27 | 28 | /** 29 | * @param {string} script 30 | * @param {string} scriptType 31 | * @return {IncludeContext|undefined} 32 | */ 33 | function findIncludeDirective(script, scriptType) { 34 | const includeDirective = scriptType === EXT_CSS ? INCLUDE_CSS_RE : INCLUDE_JS_RE; 35 | 36 | const result = script.match(includeDirective); 37 | if (result) { 38 | const startIndex = result.index; 39 | const endIndex = startIndex + result[0].length; 40 | 41 | // determine full path to include file 42 | const scriptFileName = result[1].replace(/^"|"$/g, ""); // remove quotes, if any 43 | return new IncludeContext(new ScriptContext(scriptFileName, scriptType), startIndex, endIndex); 44 | } 45 | } 46 | 47 | function prependServerOrigin(serverOrigin, script) { 48 | script.url = url.composeUrl(serverOrigin, script.path); 49 | return script; 50 | } 51 | 52 | /** 53 | * Process `@include` directives, replacing them with the actual scripts they refer to. The processing is recursive, 54 | * i.e., included files also have their `@include` directives processed. The algorithm detects dependency cycles and 55 | * avoids them by not including any file more than once. 56 | * 57 | * @param {ScriptContext} scriptContext 58 | * @param {IncludeContext} include 59 | * @param {Metrics} metrics 60 | * @param {Set} visitedUrls 61 | * @return {Promise} 62 | */ 63 | async function processIncludeDirective(scriptContext, include, metrics, visitedUrls) { 64 | const includeUrl = include.script.url = url.composeUrl(scriptContext.url, include.script.path); 65 | 66 | // check for dependency cycles 67 | if (!visitedUrls.has(includeUrl)) { 68 | await loader.loadSingleScript(include.script, metrics, visitedUrls); 69 | 70 | if (include.script.hasContents) { 71 | metrics.incrementIncludesHit(scriptContext.type); 72 | } else { 73 | // script not found or error 74 | include.script.contents = `/* WITCHCRAFT: could not include "${includeUrl}"; script was not found */`; 75 | 76 | metrics.incrementIncludesNotFound(scriptContext.type); 77 | } 78 | } else { 79 | // this script was already included before 80 | include.script.contents = `/* WITCHCRAFT: skipping inclusion of "${includeUrl}" due to dependency cycle */`; 81 | } 82 | } 83 | 84 | /* 85 | * Functions are exported like this instead of directly exposed so that they can be unit-tested. 86 | * See https://javascript.plainenglish.io/unit-testing-challenges-with-modular-javascript-patterns-22cc22397362 87 | */ 88 | export const script = { 89 | IncludeContext, 90 | ScriptContext, 91 | expandInclude, 92 | findIncludeDirective, 93 | prependServerOrigin, 94 | processIncludeDirective, 95 | }; 96 | -------------------------------------------------------------------------------- /chrome-extension/popup/popup.css: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --background-color: #141f1c; 4 | --background-color-lighter: #3b4243; 5 | --primary-color: #c9b320; 6 | --secondary-color: #b66ac3; 7 | --text-color: #bebebe; 8 | --highlighted-color: white; 9 | --js-color: #eddd51; 10 | --js-text-color: black; 11 | --css-color: #3d91f2; 12 | --css-text-color: white; 13 | --server-online-fill: #00ff00; 14 | --server-online-stroke: #00ff00; 15 | --server-offline-fill: #ff0000; 16 | --server-offline-stroke: #d20000; 17 | --container-margin: 4px; 18 | --panel-label-padding: 3px; 19 | --panel-label-width: 60px; 20 | --row-height: 20px; 21 | } 22 | 23 | body { 24 | font-family: Roboto, sans-serif; 25 | background-color: var(--background-color); 26 | font-size: 12px; 27 | min-width: 300px; 28 | } 29 | 30 | body, a { 31 | color: var(--text-color); 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | a:hover { 37 | color: var(--highlighted-color); 38 | } 39 | 40 | input { 41 | font-family: Roboto, sans-serif; 42 | } 43 | 44 | .hidden { 45 | display: none !important; 46 | } 47 | 48 | table.scripts { 49 | width: 100%; 50 | border-collapse: collapse; 51 | } 52 | 53 | table.scripts tr { 54 | height: var(--row-height); 55 | } 56 | 57 | td.script-name { 58 | white-space: nowrap; 59 | overflow: hidden; 60 | text-overflow: ellipsis; 61 | font-family: monospace; 62 | } 63 | 64 | tr.page-frame { 65 | background-color: #232b28; 66 | color: #505953; 67 | } 68 | 69 | tr.page-frame td { 70 | padding-left: 5px; 71 | } 72 | 73 | td.script-name { 74 | padding-left: 5px; 75 | } 76 | 77 | td.script-type { 78 | width: 15%; 79 | padding: 0; 80 | } 81 | 82 | div.js, div.css { 83 | box-sizing: border-box; 84 | font-weight: bold; 85 | border-radius: 10px; 86 | font-size: 95%; 87 | width: 100%; 88 | display: flex; 89 | align-items: center; 90 | justify-content: center; 91 | height: calc(var(--row-height) - 2px); 92 | } 93 | 94 | div.js { 95 | background-color: var(--js-color); 96 | color: var(--js-text-color); 97 | } 98 | 99 | div.css { 100 | background-color: var(--css-color); 101 | color: var(--css-text-color); 102 | } 103 | 104 | .header { 105 | display: flex; 106 | flex-direction: row; 107 | align-items: center; 108 | justify-content: space-between; 109 | height: 18px; 110 | padding: 5px; 111 | font-size: 120%; 112 | background-color: var(--background-color-lighter); 113 | } 114 | 115 | .container { 116 | /*margin: var(--container-margin);*/ 117 | background-color: var(--background-color); 118 | } 119 | 120 | .row { 121 | padding: var(--panel-label-padding); 122 | display: flex; 123 | flex-direction: row; 124 | justify-content: space-between; 125 | align-items: center; 126 | } 127 | 128 | .row label { 129 | width: var(--panel-label-width); 130 | display: inline-block; 131 | } 132 | 133 | .row input { 134 | width: calc(100% - var(--panel-label-width) - 2 * var(--panel-label-padding) - 2 * var(--container-margin)); 135 | display: inline-block; 136 | } 137 | 138 | .row.right { 139 | flex-direction: row-reverse; 140 | } 141 | 142 | .row.center { 143 | justify-content: center; 144 | } 145 | 146 | #server-status { 147 | height: 4px; 148 | width: 4px; 149 | border-radius: 3px; 150 | border: 1px solid var(--server-offline-stroke); 151 | background-color: var(--server-offline-fill); 152 | display: inline-block; 153 | } 154 | 155 | #server-status.online { 156 | border: 1px solid var(--server-online-stroke); 157 | background-color: var(--server-online-fill); 158 | } 159 | -------------------------------------------------------------------------------- /docs/src/content/docs/how-to-use.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: How to use 3 | --- 4 | 5 | import { Code } from '@astrojs/starlight/components'; 6 | import includeJsExample from "../../assets/include.js?raw"; 7 | import includeCssExample from "../../assets/include.css?raw"; 8 | import includeRemoteExample from "../../assets/include-remote.js?raw"; 9 | 10 | Once your Witchcraft is [set up](/how-to-install), you can start creating scripts. This guide will help you understand how Witchcraft looks for scripts and how to name them properly. 11 | 12 | To give you a basic idea of how it works, let's use as an example the page `https://gist.github.com`. Once you navigate to that page, Witchcraft will kick in and look for scripts to inject. This is what it will look for, in order: 13 | 14 | - `_global.js` (see "Global scripts" below) 15 | - `_global.css` 16 | - `com.js` 17 | - `com.css` 18 | - `github.com.js` 19 | - `github.com.css` 20 | - `gist.github.com.js` 21 | - `gist.github.com.css` 22 | 23 | For each candidate script above, Witchcraft will make a request to your local server (the one you set up in the [installation guide](/how-to-install)) to see if the script exists. If it does, it will be injected into the page. If it doesn't, Witchcraft will move on to the next candidate script. 24 | 25 | ## Path segments 26 | 27 | Witchcraft also tries to match path segments. For example, if you navigate to `https://example.com/foo/bar.html`, the following paths will be looked up, after being concatenated with each of the domain-based candidates above: 28 | 29 | - `foo.js` 30 | - `foo.css` 31 | - `foo/bar.html.js` 32 | - `foo/bar.html.css` 33 | 34 | ## Global scripts 35 | 36 | Witchcraft also looks for two special global scripts that are injected into every page, regardless of the domain or path: 37 | 38 | - `_global.js` 39 | - `_global.css` 40 | 41 | Moreover, if you have a folder named `_global` in your scripts directory, Witchcraft will look for scripts inside that folder as well, trying to match them against the path segments. For example, if you navigate to `https://example.com/foo/bar.html`, the following paths will be looked up inside the `_global` folder: 42 | 43 | - `_global/foo.js` 44 | - `_global/foo.css` 45 | - `_global/foo/bar.html.js` 46 | - `_global/foo/bar.html.css` 47 | 48 | ## Script lookup order 49 | 50 | Now that we went through all the different types of scripts Witchcraft looks for, let's summarize the full lookup order when navigating to a page. Let's use `https://example.com/foo/bar.html` as an example again. The full lookup order will be: 51 | 52 | 1. `_global.js` 53 | 2. `_global.css` 54 | 3. `_global/foo.js` 55 | 4. `_global/foo.css` 56 | 5. `_global/foo/bar.html.js` 57 | 6. `_global/foo/bar.html.css` 58 | 7. `com.js` 59 | 8. `com.css` 60 | 9. `com/foo.js` 61 | 10. `com/foo.css` 62 | 11. `com/foo/bar.html.js` 63 | 12. `com/foo/bar.html.css` 64 | 13. `example.com.js` 65 | 14. `example.com.css` 66 | 15. `example.com/foo.js` 67 | 16. `example.com/foo.css` 68 | 17. `example.com/foo/bar.html.js` 69 | 18. `example.com/foo/bar.html.css` 70 | 71 | ## Including other scripts 72 | 73 | Witchcraft supports including other scripts using the `@include` directive. This works for both JavaScript and CSS files: 74 | 75 | 76 | 77 | 78 | 79 | You may add multiple directives to the same script. If your script name contains spaces, you can use double quotes to refer to it, like in the example above. 80 | 81 | It is possible to include remote scripts as well: 82 | 83 | 84 | 85 | Useful for loading scripts from gists, for example. 86 | 87 | Included scripts can also `@include` scripts of their own; the parser will recursively iterate through them. Dependency cycles (e.g., `foo.js` includes `bar.js`, which includes `foo.js`) are automatically resolved. 88 | 89 | This directive may be useful for including third-party libraries. 90 | -------------------------------------------------------------------------------- /docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | import starlight from '@astrojs/starlight'; 3 | import starlightThemeRapide from "starlight-theme-rapide"; 4 | 5 | /** Quick and dirty plugin that: 6 | * - Throws an error if relative links are found 7 | * - Prepends the configured base to all absolute links (starting with /) 8 | */ 9 | function remarkAbsoluteLinksSimple(base = '/') { 10 | const prefix = `[absolute-links]`; 11 | let indentLevel = 0; 12 | const indent = () => indentLevel += 2; 13 | const dedent = () => indentLevel -= 2; 14 | const log = (msg) => console.info(`${prefix}${" ".repeat(indentLevel)} ${msg}`); 15 | 16 | const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; 17 | 18 | return (tree, file) => { 19 | log(`Processing file: ${file.path}`); 20 | 21 | function visit(node) { 22 | if (!node || typeof node !== "object") return; 23 | 24 | indent(); 25 | // log(`node: type=${node.type} children=${Array.isArray(node.children) ? node.children.length : 0}`); 26 | 27 | if (node.type === "link" && typeof node.url === "string") { 28 | const url = node.url; 29 | log(`link: ${url}`); 30 | 31 | // fail on relative links 32 | if (node.url.startsWith(".")) { 33 | throw new Error(`Relative links starting with './' are not supported. Please use root-relative links starting with '/' instead. Found in file ${file.path}: ${node.url}`); 34 | } 35 | 36 | if (node.url.startsWith(base)) { 37 | throw new Error(`Do not include the base ('${base}') in links. Links should be root-relative starting with '/'. Found in file ${file.path}: ${node.url}`); 38 | } 39 | 40 | // prepend base to all absolute links (doesn't touch external links) 41 | if (node.url.startsWith('/')) { 42 | node.url = `${normalizedBase}${node.url}`; 43 | } 44 | } 45 | 46 | for (const child of node.children || []) { 47 | visit(child); 48 | } 49 | 50 | dedent(); 51 | } 52 | visit(tree); 53 | } 54 | } 55 | 56 | const astroBase = process.env.ASTRO_BASE || '/witchcraft/'; 57 | 58 | // https://astro.build/config 59 | export default defineConfig({ 60 | site: process.env.ASTRO_SITE || 'https://luciopaiva.com', 61 | base: astroBase, 62 | markdown: { 63 | remarkPlugins: [[remarkAbsoluteLinksSimple, astroBase]], 64 | }, 65 | integrations: [ 66 | starlight({ 67 | title: '', 68 | logo: { 69 | src: "./src/assets/title.png", 70 | }, 71 | social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/luciopaiva/witchcraft' }], 72 | plugins: [starlightThemeRapide()], 73 | customCss: [ 74 | './src/styles/global.css', 75 | ], 76 | sidebar: [ 77 | { 78 | label: 'Guide', 79 | items: [ 80 | { label: 'Introduction', slug: 'introduction' }, 81 | { label: 'How to install', slug: 'how-to-install' }, 82 | { label: 'How to use', slug: 'how-to-use' }, 83 | { label: 'New in version 3', slug: 'new-in-v3' }, 84 | { label: 'FAQ', slug: 'faq' }, 85 | { label: 'Credits', slug: 'credits' }, 86 | ] 87 | }, 88 | { 89 | label: 'Technical notes', 90 | items: [ 91 | { label: 'Architecture', slug: 'architecture' }, 92 | ] 93 | } 94 | // { 95 | // label: 'Cookbook', 96 | // autogenerate: { directory: 'cookbook' }, 97 | // }, 98 | ], 99 | }), 100 | ], 101 | }); 102 | -------------------------------------------------------------------------------- /docs/src/content/docs/how-to-install.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: How to install 3 | --- 4 | 5 | import { Image } from 'astro:assets'; 6 | import { Steps, Tabs, TabItem } from '@astrojs/starlight/components';import serverOnlineImage from "../../assets/server-online.png"; 7 | 8 | Follow these steps: 9 | 10 | 11 | 12 | 1. Install Witchcraft Chrome extension: get it [here](https://chrome.google.com/webstore/detail/witchcraft-inject-js-and/hokcepcfcicnhalinladgknhaljndhpc) 13 | 14 | 1. Right-click on the extension icon in the Chrome toolbar and choose "Manage extension" 15 | 16 | ![Witchcraft extension in Chrome toolbar](../../assets/manage-extension.png) 17 | 18 | 1. Enable the "Allow user scripts" option 19 | 20 | ![Allow user scripts option](../../assets/allow-user-scripts.png) 21 | 22 | :::note 23 | If you don't see this option, make sure you are using the latest Chrome version. 24 | 25 | Alternatively, you can skip this step and Witchcraft will fall back to using an older method for loading scripts, but it won't work for sites that use strict Content Security Policies (CSP). 26 | 27 | 1. Choose a local folder where your scripts will be stored. For instance, `~/witchcraft-scripts` (create it now if it doesn't exist yet) 28 | 29 |
30 | 1. Start a local web server to serve that folder. You can use any web server you like, but here are some simple options: 31 | 32 | 33 | ```bash 34 | python3 -m http.server 5743 35 | ``` 36 | 37 | 38 | ```bash 39 | npx http-server . -p 5743 40 | ``` 41 | 42 | 43 | A WSL2 bash script + Windows Task Scheduler to auto-start it. See details [here](/wsl2-python-server). 44 | 45 | 46 | ```yml title="docker-compose.yml" ins="./witchcraft-scripts" 47 | version: "3.9" 48 | services: 49 | web: 50 | image: nginx:alpine 51 | container_name: simple-web 52 | ports: 53 | - "5743:80" 54 | volumes: 55 | - ./witchcraft-scripts:/usr/share/nginx/html:ro 56 | ``` 57 | 58 | Just make sure to change `./witchcraft-scripts` to the correct path of your scripts folder. 59 | 60 |
61 | Homelab alternative 62 | A wild idea is to run a Docker container in your homelab (in case you have one) and then mount the remote scripts folder as a local volume on your machine, this way you can have a single scripts folder that is shared across multiple devices on your network. Just make sure to set Witchcraft to point to the correct server address instead of the default `127.0.0.1`. 63 |
64 | 65 |
66 | 67 | A comprehensive list of ways to start a web server can be found [here](https://gist.github.com/willurd/5720255). 68 | 69 |
70 | 71 | 72 | ## Checking that everything is working 73 | 74 | As soon as Witchcraft is able to reach the server, the status indicator will turn green: 75 | 76 | Witchcraft server online indicator 77 | 78 | If it doesn't, check that the server address is correct. Witchcraft will look for the server at `http://127.0.0.1:5743`. If you choose a different port (or start the server in a different machine), make sure to configure it accordingly in the extension's settings by clicking on the Witchcraft icon in the Chrome toolbar and updating the server address field. 79 | 80 | You can now [create a script](/introduction) in your scripts folder and open the corresponding web page to see it in action. 81 | 82 | If you encounter any problems, feel free to ask for help [here](https://github.com/luciopaiva/witchcraft/issues). 83 | -------------------------------------------------------------------------------- /chrome-extension/storage/index.js: -------------------------------------------------------------------------------- 1 | import {evictStale} from "./evict-stale.js"; 2 | import {browser} from "../browser.js"; 3 | import {util} from "../util/index.js"; 4 | import {DEFAULT_SERVER_ADDRESS} from "../constants.js"; 5 | 6 | const SERVER_ADDRESS_KEY = "server-address"; 7 | const SERVER_STATUS_KEY = "server-status"; 8 | 9 | const EVICTION_TIME_KEY = "eviction-time"; 10 | const FRAME_SCRIPTS_KEY_PREFIX = "frame-scripts"; 11 | const TAB_SCRIPT_COUNT_KEY_PREFIX = "tab-script-count"; 12 | const ICON_KEY_PREFIX = "icon"; 13 | 14 | async function clear() { 15 | await browser.clearStorage(); 16 | } 17 | 18 | function makeIconKey(iconName) { 19 | return `${storage.ICON_KEY_PREFIX}:${iconName}`; 20 | } 21 | 22 | function makeTabScriptCountKey(tabId) { 23 | return `${storage.TAB_SCRIPT_COUNT_KEY_PREFIX}:${tabId}`; 24 | } 25 | 26 | async function removeFrame(tabId, frameId) { 27 | await browser.removeKey(storage.frameScriptsKey(tabId, frameId)); 28 | } 29 | 30 | async function retrieveServerStatus() { 31 | return (await browser.retrieveKey(SERVER_STATUS_KEY)) || false; 32 | } 33 | 34 | async function storeIcon(iconName, iconData) { 35 | const encoded = util.typedArrayToBase64(iconData); 36 | await browser.storeKey(storage.makeIconKey(iconName), encoded); 37 | } 38 | 39 | async function storeServerAddress(address) { 40 | await browser.storeKey(SERVER_ADDRESS_KEY, address); 41 | } 42 | 43 | async function storeServerStatus(status) { 44 | await browser.storeKey(SERVER_STATUS_KEY, status); 45 | } 46 | 47 | async function storeFrame(tabId, frameId, scriptNames) { 48 | await browser.storeKey(storage.frameScriptsKey(tabId, frameId), { 49 | tabId, 50 | frameId, 51 | scriptNames, 52 | }); 53 | } 54 | 55 | async function retrieveIcon(iconName) { 56 | const base64 = await browser.retrieveKey(storage.makeIconKey(iconName)); 57 | return util.base64ToTypedArray(base64, Uint8ClampedArray); 58 | } 59 | 60 | async function incrementTabScriptCount(tabId, increment) { 61 | const key = storage.makeTabScriptCountKey(tabId); 62 | const result = await browser.retrieveKey(key); 63 | const newCount = (result?.count ?? 0) + increment; 64 | await browser.storeKey(key, { 65 | tabId, 66 | count: newCount, 67 | }); 68 | return newCount; 69 | } 70 | 71 | function frameScriptsKey(tabId, frameId) { 72 | return `${storage.FRAME_SCRIPTS_KEY_PREFIX}:${tabId}:${frameId}`; 73 | } 74 | 75 | async function retrieveServerAddress(defaultAddress = DEFAULT_SERVER_ADDRESS) { 76 | return (await browser.retrieveKey(SERVER_ADDRESS_KEY)) ?? defaultAddress; 77 | } 78 | 79 | async function removeTabScriptSet(tabId) { 80 | const tabKey = storage.makeTabScriptCountKey(tabId); 81 | await browser.removeKey(tabKey); 82 | } 83 | 84 | async function addToTabScriptSet(tabId, frameId, scripts) { 85 | const scriptKeys = scripts.map(script => `${frameId}:${script}`); 86 | const tabKey = storage.makeTabScriptCountKey(tabId); 87 | const tabResult = await browser.retrieveKey(tabKey); 88 | const existingScripts = tabResult?.scripts ? JSON.parse(tabResult?.scripts) : []; 89 | 90 | const updatedScripts = new Set([...existingScripts, ...scriptKeys]); 91 | 92 | await browser.storeKey(tabKey, { 93 | tabId, 94 | scripts: JSON.stringify(Array.from(updatedScripts)), 95 | }); 96 | return updatedScripts.size; 97 | } 98 | 99 | async function retrieveFrame(tabId, frameId) { 100 | return await browser.retrieveKey(storage.frameScriptsKey(tabId, frameId)); 101 | } 102 | 103 | async function retrieveAllFrames(tabId) { 104 | const entries = await browser.retrieveAllEntries() ?? []; 105 | const prefix = `${FRAME_SCRIPTS_KEY_PREFIX}:${tabId}:`; 106 | const results = {}; 107 | 108 | for (const entry of entries) { 109 | const [key, value] = entry; 110 | if (key.startsWith(prefix)) { 111 | results[key] = value; 112 | } 113 | } 114 | 115 | return results; 116 | } 117 | 118 | async function clearAllFrames(tabId) { 119 | const keysToRemove = Object.keys(await retrieveAllFrames(tabId)); 120 | for (const key of keysToRemove) { 121 | await browser.removeKey(key); 122 | } 123 | } 124 | 125 | export const storage = { 126 | EVICTION_TIME_KEY, 127 | ICON_KEY_PREFIX, 128 | FRAME_SCRIPTS_KEY_PREFIX, 129 | TAB_SCRIPT_COUNT_KEY_PREFIX, 130 | clear, 131 | evictStale, 132 | frameScriptsKey, 133 | incrementTabScriptCount, 134 | makeIconKey, 135 | makeTabScriptCountKey, 136 | removeFrame, 137 | removeTabScriptSet, 138 | retrieveFrame, 139 | retrieveAllFrames, 140 | clearAllFrames, 141 | retrieveIcon, 142 | retrieveServerAddress, 143 | storeFrame, 144 | storeIcon, 145 | storeServerAddress, 146 | addToTabScriptSet, 147 | storeServerStatus, 148 | retrieveServerStatus, 149 | } 150 | -------------------------------------------------------------------------------- /docs/src/content/docs/faq.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: FAQ 3 | --- 4 | 5 | import { Aside } from '@astrojs/starlight/components'; 6 | import { Code } from '@astrojs/starlight/components'; 7 | 8 | These are some of the most frequently asked questions. 9 | 10 | 13 | 14 | {/* If your question is not answered below, feel free to ask it [here](https://github.com/luciopaiva/witchcraft/issues). */} 15 | 16 | ## Where should I put my Witchcraft scripts? 17 | 18 | You can choose any folder on your local machine to store your scripts. Just make sure to start a local web server to serve that folder (see [How to install](/how-to-install)). 19 | 20 | ## Why do I need a local web server? 21 | 22 | Chrome extensions are not allowed to read files directly from your file system for security reasons. By using a local web server, Witchcraft can fetch your scripts over HTTP, which is a secure and supported way to load resources in a browser extension. 23 | 24 | Witchcraft performs GET requests to your local web server to check if a script exists for the current page. If the script is found, it is injected into the page. 25 | 26 | ## How to add third-party libraries to my scripts? 27 | 28 | Use the `@include` directive as described [here](/how-to-use#including-other-scripts). You can either download the library and include it locally or reference it via a CDN URL. Example: 29 | 30 | 31 | 32 | ## Can't Witchcraft spawn its own local web server? 33 | 34 | Unfortunately, no. See question above. 35 | 36 | ## When exactly my script gets loaded? 37 | 38 | As of v3.3.0, it is loaded at `document_start` via a content script. This is the earliest moment you can inject a script into a page. At this point, the DOM is not fully built yet, so your script should not rely on DOM elements being present. If you need to wait for the DOM to be ready, you can listen for the `DOMContentLoaded` event inside your script, but first check `document.readyState` to see if the event has already fired: 39 | 40 | ```js 41 | if (document.readyState === 'loading') { 42 | // DOM is still loading, so let's wait for it 43 | document.addEventListener('DOMContentLoaded', () => { 44 | // DOM is ready now 45 | }); 46 | } else { 47 | // DOM is already ready 48 | } 49 | ``` 50 | 51 | ## My script seems to be loading multiple times. What is wrong? 52 | 53 | Your page probably has frames/iframes in it. By design, Witchcraft loads itself into every frame it can find. If you want to run your script in a specific frame, you need to enforce that inside your script. For instance, you may want your script to run only in the top frame. In that case, just check if `window.self === window.top`. More details here. 54 | 55 | ## How to load Witchcraft only in the topmost window? 56 | 57 | See question above. 58 | 59 | ## How do I inject scripts into `localhost`? 60 | 61 | In case you are developing a web page and testing it using a local web server, you can inject code in it by naming your script `localhost.js`/`localhost.css`. If you're running multiple web servers on different ports and need to tell them apart, do it by checking the port inside your script (check the `location.port` property). See an example here. 62 | 63 | ## How can I inject scripts when accessing a website via an IP address instead of a domain name? 64 | 65 | Just name your script using the IP address. For instance, if you want to load a script when accessing `http://10.0.0.1`, name your script `10.0.0.1.js`. 66 | 67 | ## Can I install Witchcraft from sources instead? 68 | 69 | Yes, you can install Witchcraft directly from sources instead of downloading it from the Chrome store. Look for the extension source under the folder `/chrome-extension` and manually load it as an unpacked extension. Check the Chrome documentation [here](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world#load-unpacked). 70 | 71 | ## Is Google Analytics being used? Why? 72 | 73 | Yes. It is being used to track actions performed inside the Witchcraft pop-up window (.e.g: "user clicked the report issue button") and to collect anonymous statistics (e.g.: how many times was a JS include directive triggered) in order to help improve the extension. 74 | 75 | Code is carefully tailored to make sure no user-identifiable information is sent to GA. For instance, URLs being accessed and script names are not sent to GA in any way. People are encouraged to verify the code themselves and also load the extension right from the sources if they don't trust the Chrome Web Store package. 76 | 77 | ## How can I load other resources in my scripts, like images? 78 | 79 | There are three proposed ways to do it here. 80 | 81 | ## Why is my script not updating when I reload the page? 82 | 83 | Reloading the page should be enough to update the script. If that's not the case, check if your HTTP server might be caching it on the server side. Try some of the [recommended ways to run a local web server](/how-to-install#web-servers) to see if it solves the problem. 84 | -------------------------------------------------------------------------------- /test/script/find-include-directive.js: -------------------------------------------------------------------------------- 1 | 2 | import assert from "assert"; 3 | import { describe, it } from "mocha"; 4 | import {script} from "../../chrome-extension/script/index.js"; 5 | import {EXT_CSS, EXT_JS} from "../../chrome-extension/path.js"; 6 | import sinon from "sinon"; 7 | 8 | describe("Find include directive", function () { 9 | 10 | beforeEach(function () { 11 | sinon.restore(); 12 | }); 13 | 14 | it("empty script", function () { 15 | const js = ""; 16 | const result = script.findIncludeDirective(js, EXT_JS); 17 | assert.strictEqual(result, undefined); 18 | }); 19 | 20 | it("no directive", function () { 21 | const js = ` 22 | // foo 23 | let x = 10; 24 | `; 25 | const result = script.findIncludeDirective(js, EXT_JS); 26 | assert.strictEqual(result, undefined); 27 | }); 28 | 29 | it("single line comment, bare JS include", function () { 30 | const js = ` 31 | // @include my-script.js 32 | let x = 10; 33 | `; 34 | const result = script.findIncludeDirective(js, EXT_JS); 35 | assert.strictEqual(result.startIndex, 11); 36 | assert.strictEqual(result.endIndex, 35); 37 | assert.strictEqual(result.script.path, "my-script.js"); 38 | assert.strictEqual(result.script.type, EXT_JS); 39 | }); 40 | 41 | it("single line comment after code, bare JS include", function () { 42 | const js = `let x = 10; // @include my-script.js`; 43 | const result = script.findIncludeDirective(js, EXT_JS); 44 | assert.strictEqual(result.startIndex, 12); 45 | assert.strictEqual(result.endIndex, 36); 46 | assert.strictEqual(result.script.path, "my-script.js"); 47 | assert.strictEqual(result.script.type, EXT_JS); 48 | }); 49 | 50 | it("single line comment, bare remote JS include", function () { 51 | const js = ` 52 | // @include https://raw.githubusercontent.com/luciopaiva/foo/master/bar.js 53 | let x = 10; 54 | `; 55 | const result = script.findIncludeDirective(js, EXT_JS); 56 | assert.strictEqual(result.startIndex, 11); 57 | assert.strictEqual(result.endIndex, 85); 58 | assert.strictEqual(result.script.path, "https://raw.githubusercontent.com/luciopaiva/foo/master/bar.js"); 59 | assert.strictEqual(result.script.type, EXT_JS); 60 | }); 61 | 62 | it("multi line comment, bare JS include", function () { 63 | const js = ` 64 | /* @include my-script.js */ 65 | let x = 10; 66 | `; 67 | const result = script.findIncludeDirective(js, EXT_JS); 68 | assert.strictEqual(result.startIndex, 11); 69 | assert.strictEqual(result.endIndex, 38); 70 | assert.strictEqual(result.script.path, "my-script.js"); 71 | assert.strictEqual(result.script.type, EXT_JS); 72 | }); 73 | 74 | it("single line comment, quoted JS include", function () { 75 | const js = ` 76 | // @include "my script.js" 77 | let x = 10; 78 | `; 79 | const result = script.findIncludeDirective(js, EXT_JS); 80 | assert.strictEqual(result.startIndex, 11); 81 | assert.strictEqual(result.endIndex, 37); 82 | assert.strictEqual(result.script.path, "my script.js"); 83 | assert.strictEqual(result.script.type, EXT_JS); 84 | }); 85 | 86 | it("multi line comment, quoted JS include", function () { 87 | const js = ` 88 | /* @include "my script.js" */ 89 | let x = 10; 90 | `; 91 | const result = script.findIncludeDirective(js, EXT_JS); 92 | assert.strictEqual(result.startIndex, 11); 93 | assert.strictEqual(result.endIndex, 40); 94 | assert.strictEqual(result.script.path, "my script.js"); 95 | assert.strictEqual(result.script.type, EXT_JS); 96 | }); 97 | 98 | it("bare CSS include", function () { 99 | const js = ` 100 | a {} 101 | /* @include my-script.css */ 102 | body { 103 | background-color: black; 104 | } 105 | `; 106 | const result = script.findIncludeDirective(js, EXT_CSS); 107 | assert.strictEqual(result.startIndex, 26); 108 | assert.strictEqual(result.endIndex, 54); 109 | assert.strictEqual(result.script.path, "my-script.css"); 110 | assert.strictEqual(result.script.type, EXT_CSS); 111 | }); 112 | 113 | it("quoted CSS include", function () { 114 | const js = ` 115 | a {} 116 | /* @include "my script.css" */ 117 | body { 118 | background-color: black; 119 | } 120 | `; 121 | const result = script.findIncludeDirective(js, EXT_JS); 122 | assert.strictEqual(result.startIndex, 26); 123 | assert.strictEqual(result.endIndex, 56); 124 | assert.strictEqual(result.script.path, "my script.css"); 125 | assert.strictEqual(result.script.type, EXT_JS); 126 | }); 127 | 128 | it("quoted remote CSS include", function () { 129 | const js = ` 130 | a {} 131 | /* @include "https://raw.githubusercontent.com/luciopaiva/foo/master/bar.css" */ 132 | body { 133 | background-color: black; 134 | } 135 | `; 136 | const result = script.findIncludeDirective(js, EXT_JS); 137 | assert.strictEqual(result.startIndex, 26); 138 | assert.strictEqual(result.endIndex, 106); 139 | assert.strictEqual(result.script.path, "https://raw.githubusercontent.com/luciopaiva/foo/master/bar.css"); 140 | assert.strictEqual(result.script.type, EXT_JS); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /chrome-extension/loader.js: -------------------------------------------------------------------------------- 1 | import {script as scripts} from "./script/index.js"; 2 | import {FETCH_RESPONSE_OUTCOME} from "./util/fetch-script.js"; 3 | import {util} from "./util/index.js"; 4 | import {EXT_CSS, EXT_JS} from "./path.js"; 5 | import {browser} from "./browser.js"; 6 | import {storage} from "./storage/index.js"; 7 | import path from "./path.js"; 8 | import {badge} from "./badge.js"; 9 | import Metrics from "./analytics/metrics.js"; 10 | 11 | /** 12 | * Fetches scripts from the server and injects those that were found. 13 | * 14 | * The input script order is guaranteed to be respected. This function will cleverly fetch all scripts in parallel, but 15 | * will inject them in the intended order. This means that if a given script S takes longer to fetch (e.g., a remote 16 | * file), all subsequent script injections will hold until S is either downloaded and fetched or failed (in case of 17 | * HTTP 404). 18 | * 19 | * @param {Script[]} scripts 20 | * @param {number} tabId 21 | * @param {number} frameId 22 | * @returns {Promise} 23 | */ 24 | async function fetchAndInject(scripts, tabId, frameId) { 25 | const metrics = new Metrics(); 26 | 27 | // start fetching all immediately, in parallel 28 | const fetchPromises = scripts.map(async script => await loader.loadSingleScript(script, metrics)); 29 | 30 | // prepare functions, but do not run them yet 31 | const injectTasks = scripts.map(script => tryInject.bind(null, script, tabId, frameId)); 32 | 33 | const orderedTasks = util.zip(fetchPromises, injectTasks).flat(); 34 | 35 | await util.sequential(orderedTasks); 36 | 37 | return metrics; 38 | } 39 | 40 | async function tryInject(script, tabId, frameId) { 41 | if (script.hasContents) { 42 | await loader.injectScript(script, tabId, frameId); 43 | } 44 | } 45 | 46 | /** 47 | * @param {ScriptContext} script 48 | * @param {number} tabId 49 | * @param {number} frameId 50 | * @returns {Promise} 51 | */ 52 | async function injectScript(script, tabId, frameId) { 53 | if (script.type === EXT_JS) { 54 | injectJs(script.url, script.contents, tabId, frameId); 55 | } else if (script.type === EXT_CSS) { 56 | injectCss(script.url, script.contents, tabId, frameId); 57 | } 58 | } 59 | 60 | function injectJs(url, contents, tabId, frameId) { 61 | browser.injectJs(contents, tabId, frameId); 62 | logInjection(tabId, frameId, "JS", url); 63 | } 64 | 65 | function injectCss(url, contents, tabId, frameId) { 66 | browser.injectCss(contents, tabId, frameId); 67 | logInjection(tabId, frameId, "CSS", url); 68 | } 69 | 70 | function logInjection(tabId, frameId, type, scriptUrl) { 71 | let tabUrl = "failed to obtain URL"; 72 | browser.getTabUrl(tabId) 73 | .then(retrievedUrl => { 74 | tabUrl = retrievedUrl ?? "blank URL"; 75 | }) 76 | .catch(() => { /* do nothing */ }) 77 | .finally(() => { 78 | const tabAndFrame = `tab ${tabId}, frame ${frameId}`; 79 | console.info(`Injected ${type} ${scriptUrl} into [${tabAndFrame}] (${tabUrl})`); 80 | }); 81 | } 82 | 83 | async function loadIncludes(script, metrics, visitedUrls) { 84 | let include = scripts.findIncludeDirective(script.contents, script.type); 85 | while (include) { 86 | await scripts.processIncludeDirective(script, include, metrics, visitedUrls); 87 | scripts.expandInclude(script, include); 88 | 89 | include = scripts.findIncludeDirective(script.contents, script.type); 90 | } 91 | } 92 | 93 | async function loadScripts(scriptUrl, tabId, frameId) { 94 | // clear any info about previously-loaded scripts 95 | await storage.removeFrame(tabId, frameId); 96 | if (frameId === 0) { 97 | await storage.clearAllFrames(tabId); 98 | await badge.clear(tabId); 99 | } 100 | 101 | const serverAddress = await storage.retrieveServerAddress(); 102 | /** @type {ScriptContext[]} */ 103 | const scriptCandidates = path.generatePotentialScriptNames(scriptUrl) 104 | .flatMap(path.mapToJsAndCss) 105 | .map(path.pathTupleToScriptContext) 106 | .map(scripts.prependServerOrigin.bind(null, serverAddress)); 107 | 108 | const metrics = await loader.fetchAndInject(scriptCandidates, tabId, frameId); 109 | 110 | // persist so the popup window can read it when needed 111 | const scriptNames = scriptCandidates 112 | .filter(script => script.hasContents) 113 | .map(script => script.path); 114 | 115 | if (scriptNames.length > 0) { 116 | await storage.storeFrame(tabId, frameId, scriptNames); 117 | 118 | // update the icon badge for this tab 119 | await badge.registerScripts(tabId, frameId, scriptNames); 120 | } 121 | 122 | return metrics; 123 | } 124 | 125 | function updateMetrics(outcome, metrics, scriptType) { 126 | switch (outcome) { 127 | case FETCH_RESPONSE_OUTCOME.SUCCESS: 128 | return metrics.incrementHitCount(scriptType); 129 | case FETCH_RESPONSE_OUTCOME.SERVER_FAILURE: 130 | return metrics.incrementErrorCount(); 131 | case FETCH_RESPONSE_OUTCOME.FETCH_FAILURE: 132 | return metrics.incrementFailCount(); 133 | } 134 | } 135 | 136 | /** 137 | * 138 | * @param {ScriptContext} script 139 | * @param {Metrics} metrics 140 | * @param {Set} visitedUrls 141 | * @returns {Promise} 142 | */ 143 | async function loadSingleScript(script, metrics, visitedUrls = new Set()) { 144 | visitedUrls.add(script.url); 145 | 146 | const fetchResult = await util.fetchScript(script.url); 147 | 148 | updateMetrics(fetchResult.outcome, metrics, script.type); 149 | 150 | if (fetchResult.outcome === FETCH_RESPONSE_OUTCOME.SUCCESS) { 151 | script.contents = fetchResult.contents; 152 | await loader.loadIncludes(script, metrics, visitedUrls); 153 | } 154 | } 155 | 156 | export const loader = { 157 | fetchAndInject, 158 | loadIncludes, 159 | loadScripts, 160 | loadSingleScript, 161 | injectScript, 162 | }; 163 | -------------------------------------------------------------------------------- /art/witch-hat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 46 | 50 | 54 | 58 | 60 | 64 | 68 | 69 | 73 | 78 | 80 | 81 | 83 | 84 | 86 | 87 | 89 | 90 | 92 | 93 | 95 | 96 | 98 | 99 | 101 | 102 | 104 | 105 | 107 | 108 | 110 | 111 | 113 | 114 | 116 | 117 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /docs/src/assets/witch-hat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 46 | 50 | 54 | 58 | 60 | 64 | 68 | 69 | 73 | 78 | 80 | 81 | 83 | 84 | 86 | 87 | 89 | 90 | 92 | 93 | 95 | 96 | 98 | 99 | 101 | 102 | 104 | 105 | 107 | 108 | 110 | 111 | 113 | 114 | 116 | 117 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /chrome-extension/analytics/agent.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Adapted from https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/functional-samples/tutorial.google-analytics/scripts/google-analytics.js 3 | */ 4 | import {browser} from "../browser.js"; 5 | import {util} from "../util/index.js"; 6 | 7 | const GA_ENDPOINT = 'https://www.google-analytics.com/mp/collect'; 8 | 9 | const DEFAULT_ENGAGEMENT_TIME_MSEC = 100; 10 | 11 | // Duration of inactivity after which a new session is created 12 | const SESSION_EXPIRATION_IN_MIN = 30; 13 | 14 | export class Agent { 15 | constructor(credentialsPromise, debug = false) { 16 | this.credentialsPromise = credentialsPromise; 17 | this.measurementId = null; 18 | this.apiSecret = null; 19 | this.debug = debug; 20 | } 21 | 22 | // Returns the client id, or creates a new one if one doesn't exist. 23 | // Stores client id in local storage to keep the same client id as long as 24 | // the extension is installed. 25 | async getOrCreateClientId() { 26 | // let { clientId } = await chrome.storage.local.get('clientId'); 27 | let clientId = await browser.retrieveKey("ga-client-id"); 28 | if (!clientId) { 29 | // Generate a unique client ID, the actual value is not relevant 30 | clientId = self.crypto.randomUUID(); 31 | // await chrome.storage.local.set({ clientId }); 32 | await browser.storeKey("ga-client-id", clientId); 33 | } 34 | return clientId; 35 | } 36 | 37 | // Returns the current session id, or creates a new one if one doesn't exist or 38 | // the previous one has expired. 39 | async getOrCreateSessionId() { 40 | let sessionData = await browser.retrieveKey("ga-session-data"); 41 | const currentTimeInMs = Date.now(); 42 | // Check if session exists and is still valid 43 | if (sessionData && sessionData.timestamp) { 44 | // Calculate how long ago the session was last updated 45 | const durationInMin = (currentTimeInMs - sessionData.timestamp) / 60000; 46 | // Check if last update lays past the session expiration threshold 47 | if (durationInMin > SESSION_EXPIRATION_IN_MIN) { 48 | // Clear old session id to start a new session 49 | sessionData = null; 50 | } else { 51 | // Update timestamp to keep session alive 52 | sessionData.timestamp = currentTimeInMs; 53 | // await chrome.storage.session.set({ sessionData }); 54 | await browser.storeKey("ga-session-data", sessionData); 55 | } 56 | } 57 | if (!sessionData) { 58 | // Create and store a new session 59 | sessionData = { 60 | session_id: currentTimeInMs.toString(), 61 | timestamp: currentTimeInMs.toString() 62 | }; 63 | // await chrome.storage.session.set({ sessionData }); 64 | await browser.storeKey("ga-session-data", sessionData); 65 | } 66 | return sessionData.session_id; 67 | } 68 | 69 | // Fires an event with optional params. Event names must only include letters and underscores. 70 | async fireEvent(name, params = {}) { 71 | if (!(await this.hasCredentials())) { 72 | this.debug && console.info("Skipping GA event"); 73 | return; 74 | } 75 | 76 | // Configure session id and engagement time if not present, for more details see: 77 | // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#recommended_parameters_for_reports 78 | if (!params.session_id) { 79 | params.session_id = await this.getOrCreateSessionId(); 80 | } 81 | if (!params.engagement_time_msec) { 82 | params.engagement_time_msec = DEFAULT_ENGAGEMENT_TIME_MSEC; 83 | } 84 | if (this.debug) { 85 | params.debug_mode = true; 86 | } 87 | params.app_version = browser.getAppVersion(); 88 | 89 | this.debug && console.info(`GA event`, name, params); 90 | 91 | const measurementId = await this.getMeasurementId(); 92 | const apiSecret = await this.getApiSecret(); 93 | const url = `${GA_ENDPOINT}?measurement_id=${measurementId}&api_secret=${apiSecret}`; 94 | try { 95 | const response = await fetch(url, { 96 | method: 'POST', 97 | body: JSON.stringify({ 98 | client_id: await this.getOrCreateClientId(), 99 | events: [ 100 | { 101 | name, 102 | params 103 | } 104 | ] 105 | }), 106 | } 107 | ); 108 | this.debug && console.info(await response.text()); 109 | } catch (e) { 110 | console.error('Google Analytics request failed with an exception', e); 111 | } 112 | } 113 | 114 | async getMeasurementId() { 115 | if (!this.measurementId) { 116 | const credentials = await this.credentialsPromise; 117 | this.measurementId = credentials?.measurementId; 118 | } 119 | return this.measurementId; 120 | } 121 | 122 | async getApiSecret() { 123 | if (!this.apiSecret) { 124 | const credentials = await this.credentialsPromise; 125 | this.apiSecret = credentials?.apiSecret; 126 | } 127 | return this.apiSecret; 128 | } 129 | 130 | async hasCredentials() { 131 | return await this.getMeasurementId() && await this.getApiSecret(); 132 | } 133 | 134 | // Fire a page view event. 135 | async firePageViewEvent(pageLocation, pageTitle, additionalParams = {}) { 136 | return this.fireEvent('page_view', { 137 | page_title: pageTitle, 138 | page_location: pageLocation, 139 | ...additionalParams 140 | }); 141 | } 142 | } 143 | 144 | const credentialsPromise = util.loadJson("/credentials.json"); 145 | 146 | const singleton = new Agent(credentialsPromise, false); 147 | export default singleton; 148 | -------------------------------------------------------------------------------- /chrome-extension/browser.js: -------------------------------------------------------------------------------- 1 | 2 | const NAVIGATION_FILTER = { url: [{ schemes: ["http", "https", "file", "ftp"] }] }; 3 | 4 | const IGNORED_ERRORS = new RegExp([ 5 | "No frame with id \\d+ in tab \\d+", 6 | "No tab with id: \\d+", 7 | "Cannot access a chrome:// URL", 8 | "The extensions gallery cannot be scripted", // shown when user navigates the Chrome extension store page 9 | ].join("|")); 10 | 11 | export function captureRuntimeError(logger = console) { 12 | const error = browser.chrome().runtime?.lastError; 13 | if (error) { 14 | if (IGNORED_ERRORS.test(error.message)) { 15 | // frame is no longer available - nothing to worry about, just ignore 16 | } else { 17 | logger.error(JSON.stringify(error, null, 2)); 18 | } 19 | } 20 | return !!error; 21 | } 22 | 23 | async function clearStorage() { 24 | return new Promise(resolve => browser.chrome().storage.local.clear(resolve)); 25 | } 26 | 27 | async function createTab(url) { 28 | return new Promise(resolve => { 29 | browser.chrome().tabs.create({ url }, tab => resolve(tab)); 30 | }); 31 | } 32 | 33 | async function getActiveTabId() { 34 | return new Promise(resolve => { 35 | browser.chrome().tabs.query({ active: true, currentWindow: true }, tabs => { 36 | if (Array.isArray(tabs) && tabs.length > 0) { 37 | resolve(tabs[0].id); 38 | } else { 39 | resolve(undefined); 40 | } 41 | }); 42 | }); 43 | } 44 | 45 | async function getAllFrames(tabId) { 46 | return new Promise(resolve => { 47 | browser.chrome().webNavigation.getAllFrames({ tabId: tabId }, details => { 48 | resolve(details?.map(frame => frame.frameId) ?? []); 49 | }); 50 | }); 51 | } 52 | 53 | function getFileUrl(path) { 54 | return browser.chrome().runtime.getURL(path); 55 | } 56 | 57 | function getAppVersion() { 58 | return browser.chrome().runtime.getManifest().version; 59 | } 60 | 61 | async function getFrame(tabId, frameId) { 62 | return new Promise(resolve => { 63 | browser.chrome().webNavigation.getFrame({ tabId, frameId }, resolve); 64 | }); 65 | } 66 | 67 | function getTabUrl(tabId) { 68 | return new Promise((resolve, reject) => { 69 | try { 70 | browser.chrome().tabs.get(tabId, details => { 71 | if (browser.captureRuntimeError()) { 72 | reject(); 73 | } else { 74 | resolve(details?.url); 75 | } 76 | }); 77 | } catch (e) { 78 | console.error(e); 79 | reject(); 80 | } 81 | }); 82 | } 83 | 84 | function injectCss(contents, tabId, frameId) { 85 | browser.chrome().scripting.insertCSS({ 86 | css: contents, 87 | target: { 88 | tabId: tabId, 89 | frameIds: [frameId] 90 | }, 91 | }).catch(browser.captureRuntimeError); 92 | } 93 | 94 | function isUserScriptsEnabled() { 95 | return typeof browser.chrome()?.userScripts?.execute === "function"; 96 | } 97 | 98 | function injectJs(contents, tabId, frameId) { 99 | if (isUserScriptsEnabled()) { 100 | browser.chrome().userScripts.execute({ 101 | injectImmediately: true, 102 | target: { tabId: tabId, frameIds: [frameId] }, 103 | js: [{ 104 | code: contents, 105 | }], 106 | world: "MAIN" 107 | }).catch(browser.captureRuntimeError); 108 | } else { 109 | browser.chrome().scripting.executeScript({ 110 | injectImmediately: true, 111 | target: { tabId: tabId, frameIds: [frameId] }, 112 | func: (contents) => Function(contents)(), 113 | args: [contents], 114 | world: "MAIN" 115 | }).catch(browser.captureRuntimeError); 116 | } 117 | } 118 | 119 | function onCommitted(callback) { 120 | browser.chrome().webNavigation.onCommitted.addListener(callback, NAVIGATION_FILTER); 121 | } 122 | 123 | function onInstalled(callback) { 124 | browser.chrome().runtime.onInstalled.addListener(callback); 125 | } 126 | 127 | function onMessage(callback) { 128 | browser.chrome().runtime.onMessage.addListener(callback); 129 | } 130 | 131 | function onStorageChanged(callback) { 132 | browser.chrome().storage.onChanged.addListener(callback); 133 | return () => { 134 | browser.chrome().storage.onChanged.removeListener(callback); 135 | } 136 | } 137 | 138 | function onSuspend(callback) { 139 | browser.chrome().runtime.onSuspend.addListener(() => callback); 140 | } 141 | 142 | async function removeKey(key) { 143 | return new Promise(resolve => { 144 | browser.chrome().storage.local.remove(key, resolve); 145 | }); 146 | } 147 | 148 | async function retrieveAllEntries() { 149 | const result = await browser.chrome().storage.local.get(); 150 | return Object.entries(result); 151 | } 152 | 153 | async function retrieveKey(key) { 154 | return new Promise(resolve => { 155 | browser.chrome().storage.local.get(key, result => { 156 | resolve(result[key]); 157 | }); 158 | }); 159 | } 160 | 161 | async function setBadgeText(tabId, text) { 162 | return new Promise(resolve => { 163 | browser.chrome().action.setBadgeText({ 164 | tabId, 165 | text, 166 | }, () => { 167 | browser.captureRuntimeError(); 168 | resolve(); 169 | }); 170 | }); 171 | } 172 | 173 | async function setIcon(imageData) { 174 | return new Promise(resolve => { 175 | browser.chrome().action.setIcon({ 176 | imageData: imageData 177 | }, resolve); 178 | }); 179 | } 180 | 181 | async function storeKey(key, value) { 182 | return new Promise(resolve => { 183 | const obj = {}; 184 | obj[key] = value; 185 | browser.chrome().storage.local.set(obj, resolve); 186 | }); 187 | } 188 | 189 | function sendMessage(message) { 190 | browser.chrome().runtime.sendMessage(message); 191 | } 192 | 193 | export const browser = { 194 | captureRuntimeError, 195 | chrome: () => chrome, // for mocking purposes 196 | clearStorage, 197 | createTab, 198 | getActiveTabId, 199 | getAllFrames, 200 | getFileUrl, 201 | getFrame, 202 | getAppVersion, 203 | getTabUrl, 204 | injectCss, 205 | injectJs, 206 | onInstalled, 207 | onMessage, 208 | onCommitted, 209 | onStorageChanged, 210 | onSuspend, 211 | removeKey, 212 | retrieveAllEntries, 213 | retrieveKey, 214 | sendMessage, 215 | setBadgeText, 216 | setIcon, 217 | storeKey, 218 | }; 219 | -------------------------------------------------------------------------------- /docs/src/content/docs/architecture.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Architecture 3 | --- 4 | 5 | This technical documents talks a bit about the different architectures Witchcraft has had over time, the problems faced and how they were solved. 6 | 7 | ## Version 2 8 | 9 | Up until Witchcraft v2, the architecture was composed of a background script, a content script and the popup window. These are the relevant parts of the manifest file: 10 | 11 | ```json title="manifest.json (excerpt)" 12 | "content_scripts": [{ 13 | "all_frames": true, 14 | "run_at": "document_start", 15 | "matches": ["http://*/*", "https://*/*"], 16 | "js": ["content-script.js"] 17 | }], 18 | "background": { 19 | "page": "background.html", 20 | "persistent": false 21 | }, 22 | "content_security_policy": "script-src 'self'; object-src 'self'" 23 | ``` 24 | 25 | This is how it worked: 26 | 27 | 1. The content script was injected into every frame of every tab (the `content_scripts.matches` property) 28 | 29 | 2. at `document_start`, the content script sent a message to the background script passing `window.location` as argument: 30 | 31 | ```js title="content-script.js (excerpt)" 32 | chrome.runtime.sendMessage(location); 33 | ``` 34 | 35 | 3. the background would listen for these messages (via `chrome.runtime.onMessage.addListener()`), receive the message and fetch all the scripts that could be applied to that URL 36 | 37 | 4. the background script would then send each loaded script as text to the content script at, using `chrome.tabs.sendMessage()`, targeting the specific tab and frame 38 | 39 | 5. the content script would then run the script like so: 40 | 41 | ```js title="content-script.js (excerpt, simplified version)" 42 | chrome.runtime.onMessage.addListener(scriptContents => { 43 | Function(scriptContents)(); 44 | }); 45 | ``` 46 | 47 | *(CSS worked similarly, but instead of using `Function()`, it would create a `