@x8r/sapphire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ <h1 align="center">
2
+ <img src="sapphire.png" width=500>
3
+ </h1>
4
+
5
+ A Scramjet plugin that emulates the `chrome.*` extension APIs inside proxied frames, so
6
+ Chrome extensions (Vencord-style userscripts, ad blockers, etc.) can run against sites
7
+ loaded through Scramjet. Originally forked from
8
+ [carbonicality/amethyst](https://github.com/carbonicality/amethyst); this version is a
9
+ full TypeScript rewrite built directly around Scramjet's plugin API instead of a
10
+ standalone postMessage-bridged iframe runtime, and ships no UI of its own — it's a
11
+ library, the host app builds whatever extension-manager UI it wants on top.
12
+
13
+ ## Architecture
14
+
15
+ - `Sapphire` (`src/sapphire.ts`) — orchestrator owning installed-extension state
16
+ (IndexedDB-backed), background contexts, and content-script registrations. Create one
17
+ per app.
18
+ - `SapphireContentScriptPlugin` (`src/SapphirePlugin.ts`) — a Scramjet `ManagedPlugin`.
19
+ Create one per `Frame` (matching Scramjet's own convention, see
20
+ `@mercuryworkshop/scramjet-controller`'s `createFrame({ plugins: [...] })`), passing it
21
+ the shared `Sapphire` instance and that frame's tab id.
22
+ - `chromeApi.ts` — builds a *live* `chrome.*`/`browser.*` object directly on the target
23
+ realm's real `Window` (obtained from Scramjet's `frame.hooks.init.pre` context). No
24
+ postMessage bridge: `addListener` captures real function references from that realm, so
25
+ dispatch is a direct call, not a serialized round-trip. This fixes real bugs the old
26
+ amethyst design had (e.g. `chrome.runtime.getManifest()` returning `undefined` because
27
+ it was silently async under the hood) and removes a class of injection-timing races.
28
+ - Background pages/scripts/service-workers all run in one persistent, invisible iframe
29
+ per extension (not tied to any tab's lifecycle) — a deliberate simplification over
30
+ spec-faithful Worker-based MV3 semantics; see the doc comment in `background.ts`.
31
+ - `dnr.ts` exports a pure `checkDeclarativeNetRequest()` matcher; it is *not* wired into
32
+ Scramjet's fetch path (that hook is undocumented/unstable — see project notes), so wire
33
+ it into your own request routing if you want extension-driven blocking.
34
+
35
+ ## Usage
36
+
37
+ ```ts
38
+ import { Sapphire, SapphireContentScriptPlugin } from "@x8r/sapphire";
39
+
40
+ const sapphire = new Sapphire({
41
+ host: {
42
+ getTabId: (win) => /* map a proxied window back to your tab id */,
43
+ getTab: (id) => /* { id, windowId, url, title, active } */,
44
+ getAllTabs: () => /* TabInfo[] */,
45
+ getTabWindow: (id) => /* live Window for tabs.executeScript/cookies.* */,
46
+ navigateTab: (id, url) => /* your router */,
47
+ },
48
+ });
49
+ await sapphire.init(); // loads previously-installed extensions
50
+
51
+ // per tab, when creating its Scramjet frame:
52
+ controller.createFrame(iframeEl, {
53
+ plugins: [new SapphireContentScriptPlugin(sapphire, tabId)],
54
+ });
55
+
56
+ // installing an extension (host owns the file picker / drag-drop UI):
57
+ const extId = await sapphire.installExtension(await file.arrayBuffer(), file.name);
58
+ ```
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@x8r/sapphire",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Scramjet plugin that emulates the chrome.* extension APIs inside proxied frames",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.worker.json"
16
+ },
17
+ "dependencies": {
18
+ "@mercuryworkshop/scramjet": "^2.0.67-alpha.1",
19
+ "jszip": "^3.10.1"
20
+ },
21
+ "peerDependencies": {
22
+ "@mercuryworkshop/scramjet-controller": "^0.0.13"
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ import { ManagedPlugin } from "@mercuryworkshop/scramjet-controller";
2
+ import type { Frame } from "@mercuryworkshop/scramjet-controller";
3
+ import { injectContentScripts } from "./contentScripts";
4
+ import type { Sapphire } from "./sapphire";
5
+
6
+ export class SapphireContentScriptPlugin extends ManagedPlugin {
7
+ private readonly sapphire: Sapphire;
8
+ private readonly tabId: number;
9
+
10
+ constructor(sapphire: Sapphire, tabId: number) {
11
+ super("sapphire-content-scripts", []);
12
+ this.sapphire = sapphire;
13
+ this.tabId = tabId;
14
+ }
15
+
16
+ install(frame: Frame): void {
17
+ this.tap(frame.hooks.init.pre, (ctx) => {
18
+ if (!ctx?.window) return;
19
+ const url = ctx.window.location.href;
20
+ void injectContentScripts(ctx.window, this.tabId, url, ctx.isTopLevel, this.sapphire.registry, this.sapphire.host);
21
+ });
22
+ }
23
+ }
@@ -0,0 +1,58 @@
1
+ const wrapCache = new WeakMap<object, object>();
2
+
3
+ export function withMissingMemberFallback<T extends object>(real: T): T {
4
+ const cached = wrapCache.get(real);
5
+ if (cached) return cached as T;
6
+ const proxy = new Proxy(real, {
7
+ get(target, prop, receiver) {
8
+ if (prop in target) {
9
+ const value = Reflect.get(target, prop, receiver);
10
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
11
+ return withMissingMemberFallback(value as object);
12
+ }
13
+ return value;
14
+ }
15
+ if (typeof prop !== "string") return undefined;
16
+ return createAutoStub();
17
+ },
18
+ });
19
+ wrapCache.set(real, proxy);
20
+ return proxy as T;
21
+ }
22
+
23
+ function createAutoStub(): unknown {
24
+ const cache = new Map<string, unknown>();
25
+ const target = () => {};
26
+ return new Proxy(target, {
27
+ get(_t, prop) {
28
+ if (prop === Symbol.toPrimitive) {
29
+ return (hint: string) => (hint === "number" ? 0 : "");
30
+ }
31
+ if (typeof prop !== "string") return undefined;
32
+ if (prop === "toJSON") return undefined;
33
+ if (prop === "valueOf") return () => 0;
34
+ if (prop === "toString") return () => "";
35
+ if (prop === "then") {
36
+ return (resolve?: (v: unknown) => void) => resolve?.(undefined);
37
+ }
38
+ const cached = cache.get(prop);
39
+ if (cached !== undefined) return cached;
40
+ let value: unknown;
41
+ if (prop === "addListener" || prop === "removeListener") value = () => {};
42
+ else if (prop === "hasListener") value = () => false;
43
+ else value = createAutoStub();
44
+ cache.set(prop, value);
45
+ return value;
46
+ },
47
+ apply(_t, _thisArg, args) {
48
+ const cb = args[args.length - 1];
49
+ if (typeof cb === "function") {
50
+ try {
51
+ cb(undefined);
52
+ } catch {
53
+ }
54
+ }
55
+ return createAutoStub();
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,67 @@
1
+ import { installChromeApi } from "./chromeApi";
2
+ import { readExtFileText } from "./fileStore";
3
+ import { bootstrapExtensionFrame, injectScriptFromUrl, rewriteExtHtml, writeDocument } from "./htmlInject";
4
+ import { getBackgroundInfo } from "./manifest";
5
+ import type { ExtensionState, SapphireRegistry } from "./registry";
6
+ import type { SapphireHostBindings } from "./types";
7
+ import { buildExtensionUrl, chromeExtensionUrl } from "./urlScheme";
8
+
9
+ export function stopBackground(ext: ExtensionState): void {
10
+ if (ext.background?.kind === "frame") ext.background.frame?.remove();
11
+ if (ext.background?.kind === "worker") ext.background.worker?.terminate();
12
+ ext.background = null;
13
+ }
14
+
15
+ export async function startBackground(
16
+ ext: ExtensionState,
17
+ registry: SapphireRegistry,
18
+ host: SapphireHostBindings,
19
+ rootEl: HTMLElement,
20
+ ): Promise<void> {
21
+ const bgInfo = getBackgroundInfo(ext.manifest);
22
+ if (!bgInfo) return;
23
+ stopBackground(ext);
24
+
25
+ const frame = document.createElement("iframe");
26
+ frame.style.cssText = "position:fixed;width:0;height:0;border:none;opacity:0;pointer-events:none;z-index:-1;";
27
+ frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
28
+ frame.setAttribute("aria-hidden", "true");
29
+ rootEl.appendChild(frame);
30
+
31
+ let win: Window;
32
+ try {
33
+ win = await bootstrapExtensionFrame(frame, ext.id);
34
+ } catch (e) {
35
+ console.error(`[sapphire] failed to bootstrap background frame for ${ext.manifest.name}`, e);
36
+ frame.remove();
37
+ return;
38
+ }
39
+
40
+ const backgroundPath = bgInfo.type === "page" ? bgInfo.page : bgInfo.type === "worker" ? bgInfo.script : bgInfo.scripts[0];
41
+
42
+ const events = installChromeApi(win, {
43
+ extId: ext.id,
44
+ tabId: null,
45
+ isBackground: true,
46
+ registry,
47
+ host,
48
+ senderUrl: backgroundPath ? chromeExtensionUrl(ext.id, backgroundPath) : undefined,
49
+ });
50
+ ext.background = { kind: "frame", frame, events };
51
+
52
+ if (bgInfo.type === "page") {
53
+ const raw = await readExtFileText(ext.id, bgInfo.page);
54
+ const html = raw !== null ? await rewriteExtHtml(ext.id, raw, bgInfo.page) : "<!DOCTYPE html><html><head></head><body></body></html>";
55
+ writeDocument(win, html);
56
+ } else {
57
+ writeDocument(win, "<!DOCTYPE html><html><head></head><body></body></html>");
58
+ const scriptPaths = bgInfo.type === "worker" ? [bgInfo.script] : bgInfo.scripts;
59
+ const isModule = bgInfo.type === "worker" && bgInfo.isModule;
60
+ for (const path of scriptPaths) {
61
+ await injectScriptFromUrl(win, buildExtensionUrl(ext.id, path), isModule);
62
+ }
63
+ }
64
+
65
+ events.runtimeOnInstalled.fire({ reason: "install" });
66
+ events.runtimeOnStartup.fire();
67
+ }