@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 +58 -0
- package/package.json +24 -0
- package/src/SapphirePlugin.ts +23 -0
- package/src/autoStub.ts +58 -0
- package/src/background.ts +67 -0
- package/src/chromeApi.ts +1390 -0
- package/src/commands.ts +36 -0
- package/src/contentScripts.ts +103 -0
- package/src/crx.ts +60 -0
- package/src/db.ts +81 -0
- package/src/dnr.ts +88 -0
- package/src/eventHub.ts +56 -0
- package/src/extensions.ts +216 -0
- package/src/fileStore.ts +22 -0
- package/src/htmlInject.ts +84 -0
- package/src/index.ts +12 -0
- package/src/manifest.ts +81 -0
- package/src/newtab.ts +29 -0
- package/src/pageMount.ts +39 -0
- package/src/popup.ts +32 -0
- package/src/registry.ts +174 -0
- package/src/router/SapphireRouter.ts +65 -0
- package/src/router/swEntry.ts +9 -0
- package/src/sapphire.ts +101 -0
- package/src/types.ts +132 -0
- package/src/urlScheme.ts +56 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { buildExtensionUrl, extensionPathDir, resolveExtensionResourcePath, sapphireExtensionBase, sapphireBootstrapUrl } from "./urlScheme";
|
|
2
|
+
|
|
3
|
+
const RESOURCE_ATTR_RE = /(<(?:script|link|img)\b[^>]*\s(?:src|href)=["'])([^"']+)(["'][^>]*>)/gi;
|
|
4
|
+
|
|
5
|
+
export async function rewriteExtHtml(extId: string, html: string, pagePath: string): Promise<string> {
|
|
6
|
+
const pageDir = extensionPathDir(pagePath);
|
|
7
|
+
let result = html.replace(RESOURCE_ATTR_RE, (full, prefix: string, url: string, suffix: string) => {
|
|
8
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|data:|blob:)/i.test(url)) return full;
|
|
9
|
+
return `${prefix}${buildExtensionUrl(extId, resolveExtensionResourcePath(pageDir, url))}${suffix}`;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const baseTag = `<base href="${sapphireExtensionBase(extId)}${pageDir ? `${pageDir}/` : ""}">`;
|
|
13
|
+
if (/<head[^>]*>/i.test(result)) {
|
|
14
|
+
result = result.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`);
|
|
15
|
+
} else {
|
|
16
|
+
result = result.replace(/<html([^>]*)>/i, `<html$1><head>${baseTag}</head>`);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bootstrapExtensionFrame(frame: HTMLIFrameElement, extId: string): Promise<Window> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
frame.addEventListener(
|
|
24
|
+
"load",
|
|
25
|
+
() => {
|
|
26
|
+
const win = frame.contentWindow;
|
|
27
|
+
if (win) resolve(win);
|
|
28
|
+
else reject(new Error("sapphire: bootstrap frame has no window after load"));
|
|
29
|
+
},
|
|
30
|
+
{ once: true },
|
|
31
|
+
);
|
|
32
|
+
frame.src = sapphireBootstrapUrl(extId);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeDocument(win: Window, html: string): void {
|
|
37
|
+
win.document.open();
|
|
38
|
+
win.document.write(html);
|
|
39
|
+
win.document.close();
|
|
40
|
+
|
|
41
|
+
setTimeout(() => win.dispatchEvent(new Event("load")), 0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function injectScript(win: Window, code: string): void {
|
|
45
|
+
try {
|
|
46
|
+
const doc = win.document;
|
|
47
|
+
const script = doc.createElement("script");
|
|
48
|
+
script.textContent = code;
|
|
49
|
+
(doc.head || doc.documentElement).appendChild(script);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.warn("[sapphire] injectScript failed", e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function injectScriptFromUrl(win: Window, url: string, isModule: boolean): Promise<void> {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
try {
|
|
58
|
+
const doc = win.document;
|
|
59
|
+
const script = doc.createElement("script");
|
|
60
|
+
if (isModule) script.type = "module";
|
|
61
|
+
script.src = url;
|
|
62
|
+
script.addEventListener("load", () => resolve(), { once: true });
|
|
63
|
+
script.addEventListener("error", (e) => {
|
|
64
|
+
console.warn("[sapphire] injectScriptFromUrl failed to load", url, e);
|
|
65
|
+
resolve();
|
|
66
|
+
}, { once: true });
|
|
67
|
+
(doc.head || doc.documentElement).appendChild(script);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn("[sapphire] injectScriptFromUrl failed", e);
|
|
70
|
+
resolve();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function injectStyle(win: Window, css: string): void {
|
|
76
|
+
try {
|
|
77
|
+
const doc = win.document;
|
|
78
|
+
const style = doc.createElement("style");
|
|
79
|
+
style.textContent = css;
|
|
80
|
+
(doc.head || doc.documentElement).appendChild(style);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.warn("[sapphire] injectStyle failed", e);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Sapphire, type SapphireOptions } from "./sapphire";
|
|
2
|
+
export { SapphireContentScriptPlugin } from "./SapphirePlugin";
|
|
3
|
+
export { findMatchingCommand, triggerCommand, type MatchedCommand } from "./commands";
|
|
4
|
+
export type { InstalledExtensionSummary } from "./extensions";
|
|
5
|
+
export type { NewTabOverride } from "./newtab";
|
|
6
|
+
export type {
|
|
7
|
+
ChromeManifest,
|
|
8
|
+
DNRDecision,
|
|
9
|
+
DNRRule,
|
|
10
|
+
SapphireHostBindings,
|
|
11
|
+
TabInfo,
|
|
12
|
+
} from "./types";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ChromeManifest, ChromeManifestAction, ChromeManifestIcons } from "./types";
|
|
2
|
+
|
|
3
|
+
export function getManifestVersion(manifest: ChromeManifest): number {
|
|
4
|
+
return Number(manifest.manifest_version) || 2;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type BackgroundInfo =
|
|
8
|
+
| { type: "worker"; script: string; isModule: boolean }
|
|
9
|
+
| { type: "page"; page: string }
|
|
10
|
+
| { type: "scripts"; scripts: string[] }
|
|
11
|
+
| null;
|
|
12
|
+
|
|
13
|
+
export function getBackgroundInfo(manifest: ChromeManifest): BackgroundInfo {
|
|
14
|
+
if (getManifestVersion(manifest) === 3) {
|
|
15
|
+
const sw = manifest.background?.service_worker;
|
|
16
|
+
return sw ? { type: "worker", script: sw, isModule: manifest.background?.type === "module" } : null;
|
|
17
|
+
}
|
|
18
|
+
if (manifest.background?.page) {
|
|
19
|
+
return { type: "page", page: manifest.background.page };
|
|
20
|
+
}
|
|
21
|
+
if (manifest.background?.scripts?.length) {
|
|
22
|
+
return { type: "scripts", scripts: manifest.background.scripts };
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function matchPattern(pattern: string, url: string): boolean {
|
|
28
|
+
if (pattern === "<all_urls>") return true;
|
|
29
|
+
if (pattern === "*://*/*") return url.startsWith("http://") || url.startsWith("https://");
|
|
30
|
+
try {
|
|
31
|
+
const escaped = pattern
|
|
32
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
33
|
+
.replace(/\*/g, ".*")
|
|
34
|
+
.replace(/\?/g, ".");
|
|
35
|
+
const schemeMatch = escaped.match(/^([^:]+):\/\//);
|
|
36
|
+
if (!schemeMatch) return false;
|
|
37
|
+
const re = new RegExp("^" + escaped + "$");
|
|
38
|
+
return re.test(url);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function urlMatchesPatterns(url: string, matches: string[], excludeMatches: string[] = []): boolean {
|
|
45
|
+
if (excludeMatches.some((p) => matchPattern(p, url))) return false;
|
|
46
|
+
return matches.some((p) => matchPattern(p, url));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveManifestI18n(manifest: ChromeManifest, messages: Record<string, { message: string }>): ChromeManifest {
|
|
50
|
+
const resolve = (s: string | undefined): string | undefined => {
|
|
51
|
+
if (!s) return s;
|
|
52
|
+
const match = s.match(/^__MSG_(.+)__$/);
|
|
53
|
+
if (!match) return s;
|
|
54
|
+
return messages[match[1]]?.message ?? s;
|
|
55
|
+
};
|
|
56
|
+
manifest.name = resolve(manifest.name) ?? manifest.name;
|
|
57
|
+
manifest.short_name = resolve(manifest.short_name);
|
|
58
|
+
manifest.description = resolve(manifest.description);
|
|
59
|
+
for (const key of ["action", "browser_action", "page_action"] as const) {
|
|
60
|
+
const action = manifest[key] as ChromeManifestAction | undefined;
|
|
61
|
+
if (action?.default_title) action.default_title = resolve(action.default_title);
|
|
62
|
+
}
|
|
63
|
+
return manifest;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getDefaultIcon(manifest: ChromeManifest): string | null {
|
|
67
|
+
const icons: string | ChromeManifestIcons | undefined =
|
|
68
|
+
(manifest.action as ChromeManifestAction | undefined)?.default_icon ??
|
|
69
|
+
(manifest.browser_action as ChromeManifestAction | undefined)?.default_icon ??
|
|
70
|
+
(manifest.page_action as ChromeManifestAction | undefined)?.default_icon ??
|
|
71
|
+
manifest.icons;
|
|
72
|
+
if (!icons) return null;
|
|
73
|
+
if (typeof icons === "string") return icons;
|
|
74
|
+
const sizes = Object.keys(icons)
|
|
75
|
+
.map(Number)
|
|
76
|
+
.filter((n) => !Number.isNaN(n))
|
|
77
|
+
.sort((a, b) => b - a);
|
|
78
|
+
if (sizes.length) return icons[String(sizes[0])] ?? null;
|
|
79
|
+
const firstKey = Object.keys(icons)[0];
|
|
80
|
+
return firstKey ? icons[firstKey] : null;
|
|
81
|
+
}
|
package/src/newtab.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mountExtensionPage } from "./pageMount";
|
|
2
|
+
import type { SapphireRegistry } from "./registry";
|
|
3
|
+
import type { SapphireHostBindings } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface NewTabOverride {
|
|
6
|
+
extId: string;
|
|
7
|
+
page: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getNewTabOverride(registry: SapphireRegistry): NewTabOverride | null {
|
|
11
|
+
const candidates = registry
|
|
12
|
+
.list()
|
|
13
|
+
.filter((ext) => ext.enabled && typeof ext.manifest.chrome_url_overrides?.newtab === "string")
|
|
14
|
+
.sort((a, b) => b.installedAt - a.installedAt);
|
|
15
|
+
const winner = candidates[0];
|
|
16
|
+
if (!winner) return null;
|
|
17
|
+
return { extId: winner.id, page: winner.manifest.chrome_url_overrides!.newtab! };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function mountNewTabPage(
|
|
21
|
+
frame: HTMLIFrameElement,
|
|
22
|
+
extId: string,
|
|
23
|
+
page: string,
|
|
24
|
+
tabId: number | null,
|
|
25
|
+
registry: SapphireRegistry,
|
|
26
|
+
host: SapphireHostBindings,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
return mountExtensionPage(frame, extId, page, tabId, registry, host, false);
|
|
29
|
+
}
|
package/src/pageMount.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { installChromeApi } from "./chromeApi";
|
|
2
|
+
import { readExtFileText } from "./fileStore";
|
|
3
|
+
import { bootstrapExtensionFrame, rewriteExtHtml, writeDocument } from "./htmlInject";
|
|
4
|
+
import type { SapphireRegistry } from "./registry";
|
|
5
|
+
import type { SapphireHostBindings } from "./types";
|
|
6
|
+
import { chromeExtensionUrl } from "./urlScheme";
|
|
7
|
+
|
|
8
|
+
export async function mountExtensionPage(
|
|
9
|
+
frame: HTMLIFrameElement,
|
|
10
|
+
extId: string,
|
|
11
|
+
pagePath: string,
|
|
12
|
+
tabId: number | null,
|
|
13
|
+
registry: SapphireRegistry,
|
|
14
|
+
host: SapphireHostBindings,
|
|
15
|
+
skipTabRegistration: boolean,
|
|
16
|
+
): Promise<boolean> {
|
|
17
|
+
const win = await bootstrapExtensionFrame(frame, extId);
|
|
18
|
+
|
|
19
|
+
const events = installChromeApi(win, {
|
|
20
|
+
extId,
|
|
21
|
+
tabId,
|
|
22
|
+
isBackground: false,
|
|
23
|
+
registry,
|
|
24
|
+
host,
|
|
25
|
+
skipTabRegistration,
|
|
26
|
+
senderUrl: chromeExtensionUrl(extId, pagePath),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (skipTabRegistration) {
|
|
30
|
+
const ext = registry.get(extId);
|
|
31
|
+
if (ext) ext.popupEvents = events;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const raw = await readExtFileText(extId, pagePath);
|
|
35
|
+
if (raw === null) return false;
|
|
36
|
+
const html = await rewriteExtHtml(extId, raw, pagePath);
|
|
37
|
+
writeDocument(win, html);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
package/src/popup.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mountExtensionPage } from "./pageMount";
|
|
2
|
+
import type { SapphireRegistry } from "./registry";
|
|
3
|
+
import type { SapphireHostBindings } from "./types";
|
|
4
|
+
|
|
5
|
+
function resolvePopupPage(registry: SapphireRegistry, extId: string): string | null {
|
|
6
|
+
const ext = registry.get(extId);
|
|
7
|
+
if (!ext) return null;
|
|
8
|
+
const manifest = ext.manifest;
|
|
9
|
+
return (
|
|
10
|
+
ext.popupPage ??
|
|
11
|
+
manifest.action?.default_popup ??
|
|
12
|
+
manifest.browser_action?.default_popup ??
|
|
13
|
+
manifest.page_action?.default_popup ??
|
|
14
|
+
null
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getExtensionPopupPage(registry: SapphireRegistry, extId: string): string | null {
|
|
19
|
+
return resolvePopupPage(registry, extId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function mountExtensionPopup(
|
|
23
|
+
frame: HTMLIFrameElement,
|
|
24
|
+
extId: string,
|
|
25
|
+
tabId: number | null,
|
|
26
|
+
registry: SapphireRegistry,
|
|
27
|
+
host: SapphireHostBindings,
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
const popupPage = resolvePopupPage(registry, extId);
|
|
30
|
+
if (!popupPage) return false;
|
|
31
|
+
return mountExtensionPage(frame, extId, popupPage, tabId, registry, host, true);
|
|
32
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { EventHub } from "./eventHub";
|
|
2
|
+
import type { ChromeManifest, ContentScriptRegistration, DNRRule } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface PortRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
extId: string;
|
|
8
|
+
remote: { onMessage: EventHub; onDisconnect: EventHub };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ContextMenuItem {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
contexts: string[];
|
|
15
|
+
onclick?: (info: unknown, tab: unknown) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AlarmRecord {
|
|
19
|
+
name: string;
|
|
20
|
+
scheduledTime: number;
|
|
21
|
+
periodInMinutes?: number;
|
|
22
|
+
timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BackgroundContext {
|
|
26
|
+
kind: "worker" | "frame";
|
|
27
|
+
worker?: Worker;
|
|
28
|
+
frame?: HTMLIFrameElement;
|
|
29
|
+
events: RealmEvents;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RealmEvents {
|
|
33
|
+
runtimeOnMessage: EventHub;
|
|
34
|
+
runtimeOnInstalled: EventHub;
|
|
35
|
+
runtimeOnStartup: EventHub;
|
|
36
|
+
runtimeOnConnect: EventHub;
|
|
37
|
+
tabsOnCreated: EventHub;
|
|
38
|
+
tabsOnUpdated: EventHub;
|
|
39
|
+
tabsOnRemoved: EventHub;
|
|
40
|
+
tabsOnActivated: EventHub;
|
|
41
|
+
actionOnClicked: EventHub;
|
|
42
|
+
browserActionOnClicked: EventHub;
|
|
43
|
+
alarmsOnAlarm: EventHub;
|
|
44
|
+
contextMenusOnClicked: EventHub;
|
|
45
|
+
commandsOnCommand: EventHub;
|
|
46
|
+
notificationsOnClicked: EventHub;
|
|
47
|
+
notificationsOnClosed: EventHub;
|
|
48
|
+
notificationsOnButtonClicked: EventHub;
|
|
49
|
+
cookiesOnChanged: EventHub;
|
|
50
|
+
webNavigationOnCompleted: EventHub;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createRealmEvents(): RealmEvents {
|
|
54
|
+
return {
|
|
55
|
+
runtimeOnMessage: new EventHub(),
|
|
56
|
+
runtimeOnInstalled: new EventHub(),
|
|
57
|
+
runtimeOnStartup: new EventHub(),
|
|
58
|
+
runtimeOnConnect: new EventHub(),
|
|
59
|
+
tabsOnCreated: new EventHub(),
|
|
60
|
+
tabsOnUpdated: new EventHub(),
|
|
61
|
+
tabsOnRemoved: new EventHub(),
|
|
62
|
+
tabsOnActivated: new EventHub(),
|
|
63
|
+
actionOnClicked: new EventHub(),
|
|
64
|
+
browserActionOnClicked: new EventHub(),
|
|
65
|
+
alarmsOnAlarm: new EventHub(),
|
|
66
|
+
contextMenusOnClicked: new EventHub(),
|
|
67
|
+
commandsOnCommand: new EventHub(),
|
|
68
|
+
notificationsOnClicked: new EventHub(),
|
|
69
|
+
notificationsOnClosed: new EventHub(),
|
|
70
|
+
notificationsOnButtonClicked: new EventHub(),
|
|
71
|
+
cookiesOnChanged: new EventHub(),
|
|
72
|
+
webNavigationOnCompleted: new EventHub(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ExtensionState {
|
|
77
|
+
id: string;
|
|
78
|
+
manifest: ChromeManifest;
|
|
79
|
+
enabled: boolean;
|
|
80
|
+
installedAt: number;
|
|
81
|
+
filename: string;
|
|
82
|
+
messages: Record<string, { message: string }>;
|
|
83
|
+
dynamicRules: DNRRule[];
|
|
84
|
+
staticRules: DNRRule[];
|
|
85
|
+
rulesetRules: Map<string, DNRRule[]>;
|
|
86
|
+
enabledRulesetIds: Set<string>;
|
|
87
|
+
badgeText: string;
|
|
88
|
+
badgeColor: string | null;
|
|
89
|
+
iconUrl: string | null;
|
|
90
|
+
title: string | null;
|
|
91
|
+
popupPage: string | null;
|
|
92
|
+
background: BackgroundContext | null;
|
|
93
|
+
contextMenuItems: ContextMenuItem[];
|
|
94
|
+
alarms: Map<string, AlarmRecord>;
|
|
95
|
+
ports: Map<string, PortRecord>;
|
|
96
|
+
tabEvents: Map<number, RealmEvents>;
|
|
97
|
+
popupEvents: RealmEvents | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class SapphireRegistry {
|
|
101
|
+
readonly extensions = new Map<string, ExtensionState>();
|
|
102
|
+
readonly contentScripts: ContentScriptRegistration[] = [];
|
|
103
|
+
|
|
104
|
+
createExtensionState(id: string, manifest: ChromeManifest, enabled: boolean, installedAt: number, filename: string): ExtensionState {
|
|
105
|
+
const state: ExtensionState = {
|
|
106
|
+
id,
|
|
107
|
+
manifest,
|
|
108
|
+
enabled,
|
|
109
|
+
installedAt,
|
|
110
|
+
filename,
|
|
111
|
+
messages: {},
|
|
112
|
+
dynamicRules: [],
|
|
113
|
+
staticRules: [],
|
|
114
|
+
rulesetRules: new Map(),
|
|
115
|
+
enabledRulesetIds: new Set(),
|
|
116
|
+
badgeText: "",
|
|
117
|
+
badgeColor: null,
|
|
118
|
+
iconUrl: null,
|
|
119
|
+
title: null,
|
|
120
|
+
popupPage: null,
|
|
121
|
+
background: null,
|
|
122
|
+
contextMenuItems: [],
|
|
123
|
+
alarms: new Map(),
|
|
124
|
+
ports: new Map(),
|
|
125
|
+
tabEvents: new Map(),
|
|
126
|
+
popupEvents: null,
|
|
127
|
+
};
|
|
128
|
+
this.extensions.set(id, state);
|
|
129
|
+
return state;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get(id: string): ExtensionState | undefined {
|
|
133
|
+
return this.extensions.get(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
remove(id: string): void {
|
|
137
|
+
const ext = this.extensions.get(id);
|
|
138
|
+
if (!ext) return;
|
|
139
|
+
for (const alarm of ext.alarms.values()) clearTimeout(alarm.timer);
|
|
140
|
+
if (ext.background?.kind === "worker") ext.background.worker?.terminate();
|
|
141
|
+
if (ext.background?.kind === "frame") ext.background.frame?.remove();
|
|
142
|
+
this.extensions.delete(id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
list(): ExtensionState[] {
|
|
146
|
+
return [...this.extensions.values()];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private changeListeners = new Set<() => void>();
|
|
150
|
+
|
|
151
|
+
onChange(cb: () => void): () => void {
|
|
152
|
+
this.changeListeners.add(cb);
|
|
153
|
+
return () => this.changeListeners.delete(cb);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
notifyChange(): void {
|
|
157
|
+
for (const cb of this.changeListeners) cb();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
broadcast(extId: string, pick: (events: RealmEvents) => EventHub, args: unknown[]): void {
|
|
161
|
+
const ext = this.extensions.get(extId);
|
|
162
|
+
if (!ext) return;
|
|
163
|
+
if (ext.background) pick(ext.background.events).fire(...args);
|
|
164
|
+
for (const events of ext.tabEvents.values()) pick(events).fire(...args);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
broadcastTabLifecycle(pick: (events: RealmEvents) => EventHub, args: unknown[]): void {
|
|
168
|
+
for (const ext of this.extensions.values()) {
|
|
169
|
+
if (!ext.enabled || !ext.background) continue;
|
|
170
|
+
pick(ext.background.events).fire(...args);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
|
|
2
|
+
import { dbGet, dbGetAllKeys, EXT_FILES_STORE } from "../db";
|
|
3
|
+
import { guessMime } from "../crx";
|
|
4
|
+
import { decodeSapphirePath, SAPPHIRE_PREFIX } from "../urlScheme";
|
|
5
|
+
|
|
6
|
+
const clientToExtId = new Map<string, string>();
|
|
7
|
+
|
|
8
|
+
function isBootstrapUrl(pathname: string): string | null {
|
|
9
|
+
const match = pathname.match(/^\/~\/sx\/([^/]+)\/__bootstrap__$/);
|
|
10
|
+
return match ? match[1] : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function shouldRoute(event: FetchEvent): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(event.request.url);
|
|
16
|
+
if (url.pathname.startsWith(SAPPHIRE_PREFIX)) return true;
|
|
17
|
+
if (url.origin !== self.location.origin) return false;
|
|
18
|
+
return Boolean(event.clientId && clientToExtId.has(event.clientId));
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function serveExtensionFile(extId: string, path: string): Promise<Response | null> {
|
|
25
|
+
const bytes = await dbGet<ArrayBuffer>(EXT_FILES_STORE, `${extId}/${path}`);
|
|
26
|
+
if (!bytes) return null;
|
|
27
|
+
return new Response(bytes, { status: 200, headers: { "Content-Type": guessMime(path) } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function route(event: FetchEvent): Promise<Response> {
|
|
31
|
+
const url = new URL(event.request.url);
|
|
32
|
+
|
|
33
|
+
const bootstrapExtId = isBootstrapUrl(url.pathname);
|
|
34
|
+
if (bootstrapExtId) {
|
|
35
|
+
const clientId = (event as unknown as { resultingClientId?: string }).resultingClientId;
|
|
36
|
+
if (clientId) clientToExtId.set(clientId, bootstrapExtId);
|
|
37
|
+
return new Response("<!DOCTYPE html><html><head></head><body></body></html>", {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "Content-Type": "text/html" },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (url.pathname.startsWith(SAPPHIRE_PREFIX)) {
|
|
44
|
+
const decoded = decodeSapphirePath(url.pathname);
|
|
45
|
+
if (!decoded) return new Response("sapphire: malformed extension resource URL", { status: 400 });
|
|
46
|
+
|
|
47
|
+
const response = await serveExtensionFile(decoded.extId, decoded.path);
|
|
48
|
+
if (response) return response;
|
|
49
|
+
|
|
50
|
+
const allKeys = await dbGetAllKeys(EXT_FILES_STORE);
|
|
51
|
+
const prefix = `${decoded.extId}/`;
|
|
52
|
+
const storedForExt = allKeys.filter((k) => typeof k === "string" && k.startsWith(prefix)).map((k) => (k as string).slice(prefix.length));
|
|
53
|
+
return new Response(
|
|
54
|
+
`sapphire: no such extension file: "${decoded.path}"\n\nFiles actually stored for this extension:\n${storedForExt.join("\n") || "(none)"}`,
|
|
55
|
+
{ status: 404, headers: { "Content-Type": "text/plain" } },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const extId = event.clientId ? clientToExtId.get(event.clientId) : undefined;
|
|
60
|
+
if (extId) {
|
|
61
|
+
const response = await serveExtensionFile(extId, url.pathname.replace(/^\//, ""));
|
|
62
|
+
if (response) return response;
|
|
63
|
+
}
|
|
64
|
+
return fetch(event.request);
|
|
65
|
+
}
|
package/src/sapphire.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { buildTabObject } from "./chromeApi";
|
|
2
|
+
import { checkDeclarativeNetRequest } from "./dnr";
|
|
3
|
+
import {
|
|
4
|
+
getInstalledExtensions,
|
|
5
|
+
installExtension as installExtensionImpl,
|
|
6
|
+
loadStoredExtensions,
|
|
7
|
+
setExtensionEnabled,
|
|
8
|
+
uninstallExtension as uninstallExtensionImpl,
|
|
9
|
+
type InstalledExtensionSummary,
|
|
10
|
+
} from "./extensions";
|
|
11
|
+
import { getNewTabOverride, mountNewTabPage, type NewTabOverride } from "./newtab";
|
|
12
|
+
import { getExtensionPopupPage, mountExtensionPopup } from "./popup";
|
|
13
|
+
import { SapphireRegistry } from "./registry";
|
|
14
|
+
import type { DNRDecision, SapphireHostBindings } from "./types";
|
|
15
|
+
|
|
16
|
+
export interface SapphireOptions {
|
|
17
|
+
host: SapphireHostBindings;
|
|
18
|
+
backgroundRoot?: HTMLElement;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Sapphire {
|
|
22
|
+
readonly registry = new SapphireRegistry();
|
|
23
|
+
readonly host: SapphireHostBindings;
|
|
24
|
+
private readonly backgroundRoot: HTMLElement;
|
|
25
|
+
private ready: Promise<number> | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(options: SapphireOptions) {
|
|
28
|
+
this.host = options.host;
|
|
29
|
+
this.backgroundRoot = options.backgroundRoot ?? document.body;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
init(): Promise<number> {
|
|
33
|
+
if (!this.ready) {
|
|
34
|
+
this.ready = loadStoredExtensions(this.registry, this.host, this.backgroundRoot);
|
|
35
|
+
}
|
|
36
|
+
return this.ready;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async installExtension(buffer: ArrayBuffer, filename = "extension.crx"): Promise<string> {
|
|
40
|
+
return installExtensionImpl(buffer, filename, this.registry, this.host, this.backgroundRoot);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async uninstallExtension(extId: string): Promise<void> {
|
|
44
|
+
return uninstallExtensionImpl(extId, this.registry);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async setExtensionEnabled(extId: string, enabled: boolean): Promise<void> {
|
|
48
|
+
return setExtensionEnabled(extId, enabled, this.registry, this.host, this.backgroundRoot);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getInstalledExtensions(): InstalledExtensionSummary[] {
|
|
52
|
+
return getInstalledExtensions(this.registry);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getExtensionPopupPage(extId: string): string | null {
|
|
56
|
+
return getExtensionPopupPage(this.registry, extId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async mountExtensionPopup(frame: HTMLIFrameElement, extId: string, tabId: number | null): Promise<boolean> {
|
|
60
|
+
return mountExtensionPopup(frame, extId, tabId, this.registry, this.host);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getNewTabOverride(): NewTabOverride | null {
|
|
64
|
+
return getNewTabOverride(this.registry);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async mountNewTabPage(frame: HTMLIFrameElement, extId: string, page: string, tabId: number | null): Promise<boolean> {
|
|
68
|
+
return mountNewTabPage(frame, extId, page, tabId, this.registry, this.host);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
checkDeclarativeNetRequest(requestUrl: string, initiatorUrl?: string, resourceType?: string): DNRDecision | null {
|
|
72
|
+
return checkDeclarativeNetRequest(this.registry, requestUrl, initiatorUrl, resourceType);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
onChange(cb: () => void): () => void {
|
|
76
|
+
return this.registry.onChange(cb);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
triggerActionClicked(extId: string, tabId: number | null): void {
|
|
80
|
+
const tab = buildTabObject(this.host, tabId);
|
|
81
|
+
this.registry.broadcast(extId, (e) => e.actionOnClicked, [tab]);
|
|
82
|
+
this.registry.broadcast(extId, (e) => e.browserActionOnClicked, [tab]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
notifyTabCreated(tabId: number): void {
|
|
86
|
+
this.registry.broadcastTabLifecycle((e) => e.tabsOnCreated, [buildTabObject(this.host, tabId)]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
notifyTabUpdated(tabId: number, changeInfo: { status?: string; url?: string }): void {
|
|
90
|
+
const tab = buildTabObject(this.host, tabId);
|
|
91
|
+
this.registry.broadcastTabLifecycle((e) => e.tabsOnUpdated, [tabId, changeInfo, tab]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
notifyTabRemoved(tabId: number, windowId = 1): void {
|
|
95
|
+
this.registry.broadcastTabLifecycle((e) => e.tabsOnRemoved, [tabId, { windowId, isWindowClosing: false }]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
notifyTabActivated(tabId: number, windowId = 1): void {
|
|
99
|
+
this.registry.broadcastTabLifecycle((e) => e.tabsOnActivated, [{ tabId, windowId }]);
|
|
100
|
+
}
|
|
101
|
+
}
|