@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.
@@ -0,0 +1,36 @@
1
+ import type { SapphireRegistry } from "./registry";
2
+
3
+ export interface MatchedCommand {
4
+ extId: string;
5
+ name: string;
6
+ }
7
+
8
+ export function findMatchingCommand(registry: SapphireRegistry, e: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">): MatchedCommand | null {
9
+ for (const ext of registry.list()) {
10
+ if (!ext.enabled) continue;
11
+ const commands = ext.manifest.commands ?? {};
12
+ for (const [name, cmd] of Object.entries(commands)) {
13
+ const shortcut = cmd.suggested_key?.default ?? cmd.suggested_key?.windows ?? "";
14
+ if (!shortcut) continue;
15
+ const parts = shortcut.toLowerCase().split("+").map((s) => s.trim());
16
+ const needsCtrl = parts.includes("ctrl");
17
+ const needsShift = parts.includes("shift");
18
+ const needsAlt = parts.includes("alt");
19
+ const key = parts.find((p) => !["ctrl", "shift", "alt", "command"].includes(p));
20
+ if (
21
+ key &&
22
+ e.key.toLowerCase() === key &&
23
+ (needsCtrl ? e.ctrlKey || e.metaKey : true) &&
24
+ (needsShift ? e.shiftKey : !e.shiftKey) &&
25
+ (needsAlt ? e.altKey : !e.altKey)
26
+ ) {
27
+ return { extId: ext.id, name };
28
+ }
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export function triggerCommand(registry: SapphireRegistry, extId: string, name: string): void {
35
+ registry.broadcast(extId, (e) => e.commandsOnCommand, [name]);
36
+ }
@@ -0,0 +1,103 @@
1
+ import { installChromeApi } from "./chromeApi";
2
+ import { readExtFileText } from "./fileStore";
3
+ import { injectScript, injectStyle } from "./htmlInject";
4
+ import { urlMatchesPatterns } from "./manifest";
5
+ import type { ExtensionState, SapphireRegistry } from "./registry";
6
+ import type { ChromeManifestContentScript, ContentScriptRegistration, SapphireHostBindings } from "./types";
7
+
8
+ export function registerContentScripts(ext: ExtensionState, registry: SapphireRegistry): void {
9
+ const scripts: ChromeManifestContentScript[] = ext.manifest.content_scripts ?? [];
10
+ for (const cs of scripts) {
11
+ registry.contentScripts.push({
12
+ extId: ext.id,
13
+ matches: cs.matches ?? [],
14
+ excludeMatches: cs.exclude_matches ?? [],
15
+ js: cs.js ?? [],
16
+ css: cs.css ?? [],
17
+ runAt: cs.run_at ?? "document_idle",
18
+ allFrames: cs.all_frames ?? false,
19
+ });
20
+ }
21
+ }
22
+
23
+ export function unregisterContentScripts(extId: string, registry: SapphireRegistry): void {
24
+ for (let i = registry.contentScripts.length - 1; i >= 0; i--) {
25
+ if (registry.contentScripts[i].extId === extId) registry.contentScripts.splice(i, 1);
26
+ }
27
+ }
28
+
29
+ function generateDocumentId(): string {
30
+ return typeof crypto?.randomUUID === "function" ? crypto.randomUUID().replace(/-/g, "") : Math.random().toString(36).slice(2).padEnd(32, "0");
31
+ }
32
+
33
+ export async function injectContentScripts(
34
+ win: Window,
35
+ tabId: number,
36
+ url: string,
37
+ isTopLevel: boolean,
38
+ registry: SapphireRegistry,
39
+ host: SapphireHostBindings,
40
+ ): Promise<void> {
41
+ if (!url) return;
42
+ const matching = registry.contentScripts.filter(
43
+ (cs) => (isTopLevel || cs.allFrames) && urlMatchesPatterns(url, cs.matches, cs.excludeMatches),
44
+ );
45
+ if (!matching.length) return;
46
+
47
+ const documentId = generateDocumentId();
48
+ const frameId = isTopLevel ? 0 : 1;
49
+
50
+ const installedExts = new Set<string>();
51
+ const ensureChromeApi = (extId: string) => {
52
+ if (installedExts.has(extId)) return;
53
+ installedExts.add(extId);
54
+ installChromeApi(win, {
55
+ extId,
56
+ tabId,
57
+ isBackground: false,
58
+ registry,
59
+ host,
60
+ senderUrl: url,
61
+ senderFrameId: frameId,
62
+ senderDocumentId: documentId,
63
+ });
64
+ };
65
+
66
+ const byRunAt: Record<ContentScriptRegistration["runAt"], ContentScriptRegistration[]> = {
67
+ document_start: [],
68
+ document_end: [],
69
+ document_idle: [],
70
+ };
71
+ for (const cs of matching) {
72
+ const ext = registry.get(cs.extId);
73
+ if (!ext?.enabled) continue;
74
+ byRunAt[cs.runAt].push(cs);
75
+ }
76
+
77
+ const injectGroup = async (group: ContentScriptRegistration[]) => {
78
+ for (const cs of group) {
79
+ ensureChromeApi(cs.extId);
80
+ for (const cssFile of cs.css) {
81
+ const css = await readExtFileText(cs.extId, cssFile);
82
+ if (css) injectStyle(win, css);
83
+ }
84
+ for (const jsFile of cs.js) {
85
+ const code = await readExtFileText(cs.extId, jsFile);
86
+ if (code) injectScript(win, code);
87
+ }
88
+ }
89
+ };
90
+
91
+ await injectGroup(byRunAt.document_start);
92
+ if (byRunAt.document_end.length) {
93
+ win.addEventListener("DOMContentLoaded", () => void injectGroup(byRunAt.document_end), { once: true });
94
+ }
95
+ if (byRunAt.document_idle.length) {
96
+ win.addEventListener("load", () => void injectGroup(byRunAt.document_idle), { once: true });
97
+ }
98
+
99
+ const tab = host.getTab(tabId);
100
+ for (const extId of new Set(matching.map((cs) => cs.extId))) {
101
+ registry.broadcast(extId, (e) => e.tabsOnUpdated, [tabId, { status: "complete" }, tab]);
102
+ }
103
+ }
package/src/crx.ts ADDED
@@ -0,0 +1,60 @@
1
+ export function crxToZip(buffer: ArrayBuffer): ArrayBuffer {
2
+ const view = new DataView(buffer);
3
+ const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
4
+ if (magic !== "Cr24") {
5
+ return buffer;
6
+ }
7
+ const version = view.getUint32(4, true);
8
+ let zipStart: number;
9
+ if (version === 2) {
10
+ const pubKeyLen = view.getUint32(8, true);
11
+ const sigLen = view.getUint32(12, true);
12
+ zipStart = 16 + pubKeyLen + sigLen;
13
+ } else if (version === 3) {
14
+ const headerSize = view.getUint32(8, true);
15
+ zipStart = 12 + headerSize;
16
+ } else {
17
+ throw new Error(`unknown CRX version: ${version}`);
18
+ }
19
+ return buffer.slice(zipStart);
20
+ }
21
+
22
+ export function generateExtensionId(seed: string): string {
23
+ let hash = 0;
24
+ for (let i = 0; i < seed.length; i++) {
25
+ hash = (hash << 5) - hash + seed.charCodeAt(i);
26
+ hash |= 0;
27
+ }
28
+ const chars = "abcdefghijklmnopqrstuvwxyz";
29
+ let id = "";
30
+ let n = Math.abs(hash);
31
+ for (let i = 0; i < 32; i++) {
32
+ id += chars[n % 26];
33
+ n = Math.floor(n / 26) + i * 7;
34
+ }
35
+ return id.substring(0, 32);
36
+ }
37
+
38
+ const MIME_TYPES: Record<string, string> = {
39
+ js: "application/javascript",
40
+ mjs: "application/javascript",
41
+ css: "text/css",
42
+ html: "text/html",
43
+ htm: "text/html",
44
+ json: "application/json",
45
+ png: "image/png",
46
+ jpg: "image/jpeg",
47
+ jpeg: "image/jpeg",
48
+ gif: "image/gif",
49
+ svg: "image/svg+xml",
50
+ webp: "image/webp",
51
+ ico: "image/x-icon",
52
+ woff: "font/woff",
53
+ woff2: "font/woff2",
54
+ ttf: "font/ttf",
55
+ };
56
+
57
+ export function guessMime(path: string): string {
58
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
59
+ return MIME_TYPES[ext] ?? "application/octet-stream";
60
+ }
package/src/db.ts ADDED
@@ -0,0 +1,81 @@
1
+ const DB_NAME = "sapphire_extensions";
2
+ const DB_VERSION = 1;
3
+
4
+ export const EXT_STORE = "extensions";
5
+ export const EXT_FILES_STORE = "extension_files";
6
+ export const EXT_STORAGE_STORE = "extension_storage";
7
+
8
+ let dbPromise: Promise<IDBDatabase> | null = null;
9
+
10
+ function openDB(): Promise<IDBDatabase> {
11
+ if (dbPromise) return dbPromise;
12
+ dbPromise = new Promise((resolve, reject) => {
13
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
14
+ req.onupgradeneeded = () => {
15
+ const db = req.result;
16
+ if (!db.objectStoreNames.contains(EXT_STORE)) {
17
+ db.createObjectStore(EXT_STORE, { keyPath: "id" });
18
+ }
19
+ if (!db.objectStoreNames.contains(EXT_FILES_STORE)) {
20
+ db.createObjectStore(EXT_FILES_STORE);
21
+ }
22
+ if (!db.objectStoreNames.contains(EXT_STORAGE_STORE)) {
23
+ db.createObjectStore(EXT_STORAGE_STORE);
24
+ }
25
+ };
26
+ req.onsuccess = () => resolve(req.result);
27
+ req.onerror = () => reject(req.error);
28
+ });
29
+ return dbPromise;
30
+ }
31
+
32
+ export async function dbGet<T = unknown>(store: string, key: IDBValidKey): Promise<T | undefined> {
33
+ const db = await openDB();
34
+ return new Promise((resolve, reject) => {
35
+ const tx = db.transaction(store, "readonly");
36
+ const req = tx.objectStore(store).get(key);
37
+ req.onsuccess = () => resolve(req.result as T | undefined);
38
+ req.onerror = () => reject(req.error);
39
+ });
40
+ }
41
+
42
+ export async function dbPut(store: string, key: IDBValidKey | null, value: unknown): Promise<IDBValidKey> {
43
+ const db = await openDB();
44
+ return new Promise((resolve, reject) => {
45
+ const tx = db.transaction(store, "readwrite");
46
+ const objStore = tx.objectStore(store);
47
+ const req = key === null ? objStore.put(value) : objStore.put(value, key);
48
+ req.onsuccess = () => resolve(req.result);
49
+ req.onerror = () => reject(req.error);
50
+ });
51
+ }
52
+
53
+ export async function dbDelete(store: string, key: IDBValidKey): Promise<void> {
54
+ const db = await openDB();
55
+ return new Promise((resolve, reject) => {
56
+ const tx = db.transaction(store, "readwrite");
57
+ const req = tx.objectStore(store).delete(key);
58
+ req.onsuccess = () => resolve();
59
+ req.onerror = () => reject(req.error);
60
+ });
61
+ }
62
+
63
+ export async function dbGetAll<T = unknown>(store: string): Promise<T[]> {
64
+ const db = await openDB();
65
+ return new Promise((resolve, reject) => {
66
+ const tx = db.transaction(store, "readonly");
67
+ const req = tx.objectStore(store).getAll();
68
+ req.onsuccess = () => resolve(req.result as T[]);
69
+ req.onerror = () => reject(req.error);
70
+ });
71
+ }
72
+
73
+ export async function dbGetAllKeys(store: string): Promise<IDBValidKey[]> {
74
+ const db = await openDB();
75
+ return new Promise((resolve, reject) => {
76
+ const tx = db.transaction(store, "readonly");
77
+ const req = tx.objectStore(store).getAllKeys();
78
+ req.onsuccess = () => resolve(req.result);
79
+ req.onerror = () => reject(req.error);
80
+ });
81
+ }
package/src/dnr.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { DNRDecision, DNRRule } from "./types";
2
+ import type { ExtensionState, SapphireRegistry } from "./registry";
3
+
4
+ export function recomputeStaticRules(ext: ExtensionState): void {
5
+ ext.staticRules = [];
6
+ for (const id of ext.enabledRulesetIds) {
7
+ const rules = ext.rulesetRules.get(id);
8
+ if (rules) ext.staticRules.push(...rules);
9
+ }
10
+ }
11
+
12
+ function ruleMatchesUrl(rule: DNRRule, requestUrl: string): boolean {
13
+ const cond = rule.condition;
14
+ if (cond.urlFilter) {
15
+ const pattern = cond.urlFilter
16
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
17
+ .replace(/\*/g, ".*")
18
+ .replace(/\|\|/g, "(?:https?://)(?:[^/]*\\.)?")
19
+ .replace(/\^/g, "[/?&#]");
20
+ try {
21
+ if (!new RegExp(pattern, "i").test(requestUrl)) return false;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+ if (cond.regexFilter) {
27
+ try {
28
+ if (!new RegExp(cond.regexFilter, "i").test(requestUrl)) return false;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ return true;
34
+ }
35
+
36
+ function ruleMatchesResourceType(rule: DNRRule, resourceType?: string): boolean {
37
+ if (!rule.condition.resourceTypes?.length) return true;
38
+ return resourceType ? rule.condition.resourceTypes.includes(resourceType) : false;
39
+ }
40
+
41
+ function ruleMatchesInitiator(rule: DNRRule, initiatorUrl?: string): boolean {
42
+ const cond = rule.condition;
43
+ if (!initiatorUrl) return !cond.initiatorDomains?.length;
44
+ let initHost: string;
45
+ try {
46
+ initHost = new URL(initiatorUrl).hostname;
47
+ } catch {
48
+ return !cond.initiatorDomains?.length;
49
+ }
50
+ if (cond.initiatorDomains?.length && !cond.initiatorDomains.some((d) => initHost === d || initHost.endsWith(`.${d}`))) {
51
+ return false;
52
+ }
53
+ if (cond.excludedInitiatorDomains?.some((d) => initHost === d || initHost.endsWith(`.${d}`))) {
54
+ return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ export function checkDeclarativeNetRequest(
60
+ registry: SapphireRegistry,
61
+ requestUrl: string,
62
+ initiatorUrl?: string,
63
+ resourceType?: string,
64
+ ): DNRDecision | null {
65
+ for (const ext of registry.list()) {
66
+ if (!ext.enabled) continue;
67
+ const rules = [...ext.dynamicRules, ...ext.staticRules].sort((a, b) => (b.priority ?? 1) - (a.priority ?? 1));
68
+ for (const rule of rules) {
69
+ if (!ruleMatchesUrl(rule, requestUrl)) continue;
70
+ if (!ruleMatchesResourceType(rule, resourceType)) continue;
71
+ if (!ruleMatchesInitiator(rule, initiatorUrl)) continue;
72
+
73
+ const action = rule.action;
74
+ if (action.type === "block") return { action: "block" };
75
+ if (action.type === "redirect") {
76
+ const redirectUrl = action.redirect?.url ?? action.redirect?.regexSubstitution;
77
+ if (redirectUrl) return { action: "redirect", url: redirectUrl };
78
+ }
79
+ if (action.type === "upgradeScheme") {
80
+ return { action: "redirect", url: requestUrl.replace(/^http:/, "https:") };
81
+ }
82
+ if (action.type === "modifyHeaders") {
83
+ return { action: "modifyHeaders", headers: action.requestHeaders ?? [], responseHeaders: action.responseHeaders ?? [] };
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
@@ -0,0 +1,56 @@
1
+ export type Listener = (...args: any[]) => any;
2
+
3
+ export class EventHub {
4
+ private listeners: Listener[] = [];
5
+
6
+ addListener = (fn: Listener): void => {
7
+ if (typeof fn === "function" && !this.listeners.includes(fn)) {
8
+ this.listeners.push(fn);
9
+ }
10
+ };
11
+
12
+ removeListener = (fn: Listener): void => {
13
+ const i = this.listeners.indexOf(fn);
14
+ if (i > -1) this.listeners.splice(i, 1);
15
+ };
16
+
17
+ hasListener = (fn: Listener): boolean => this.listeners.includes(fn);
18
+
19
+ hasListeners = (): boolean => this.listeners.length > 0;
20
+
21
+ fire(...args: unknown[]): void {
22
+ for (const fn of [...this.listeners]) {
23
+ queueMicrotask(() => {
24
+ try {
25
+ fn(...args);
26
+ } catch (e) {
27
+ console.error("[sapphire] event listener threw", e);
28
+ }
29
+ });
30
+ }
31
+ }
32
+
33
+ fireUntilHandled(...args: unknown[]): unknown {
34
+ for (const fn of [...this.listeners]) {
35
+ try {
36
+ const result = fn(...args);
37
+ if (result !== undefined) return result;
38
+ } catch (e) {
39
+ console.error("[sapphire] event listener threw", e);
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ snapshot(): Listener[] {
46
+ return [...this.listeners];
47
+ }
48
+
49
+ toApi() {
50
+ return {
51
+ addListener: this.addListener,
52
+ removeListener: this.removeListener,
53
+ hasListener: this.hasListener,
54
+ };
55
+ }
56
+ }
@@ -0,0 +1,216 @@
1
+ import JSZip from "jszip";
2
+ import { crxToZip, generateExtensionId } from "./crx";
3
+ import { dbDelete, dbGet, dbGetAll, dbGetAllKeys, dbPut, EXT_FILES_STORE, EXT_STORAGE_STORE, EXT_STORE } from "./db";
4
+ import { readExtFileText, readExtFileURL } from "./fileStore";
5
+ import { startBackground, stopBackground } from "./background";
6
+ import { registerContentScripts, unregisterContentScripts } from "./contentScripts";
7
+ import { getDefaultIcon, resolveManifestI18n } from "./manifest";
8
+ import { recomputeStaticRules } from "./dnr";
9
+ import type { SapphireRegistry } from "./registry";
10
+ import type { ChromeManifest, ExtensionMeta, SapphireHostBindings } from "./types";
11
+
12
+ export interface InstalledExtensionSummary {
13
+ id: string;
14
+ name: string;
15
+ version: string | undefined;
16
+ enabled: boolean;
17
+ manifest: ChromeManifest;
18
+ iconUrl: string | null;
19
+ title: string | null;
20
+ badgeText: string;
21
+ badgeColor: string | null;
22
+ hasPopup: boolean;
23
+ }
24
+
25
+ async function loadMessages(extId: string, manifest: ChromeManifest): Promise<Record<string, { message: string }>> {
26
+ const defaultLocale = manifest.default_locale ?? "en";
27
+ const primary = await readExtFileText(extId, `_locales/${defaultLocale}/messages.json`);
28
+ if (primary) {
29
+ try {
30
+ return JSON.parse(primary);
31
+ } catch {
32
+ }
33
+ }
34
+ if (defaultLocale !== "en") {
35
+ const en = await readExtFileText(extId, "_locales/en/messages.json");
36
+ if (en) {
37
+ try {
38
+ return JSON.parse(en);
39
+ } catch {
40
+ }
41
+ }
42
+ }
43
+ return {};
44
+ }
45
+
46
+ export async function loadExtension(
47
+ meta: ExtensionMeta,
48
+ registry: SapphireRegistry,
49
+ host: SapphireHostBindings,
50
+ backgroundRoot: HTMLElement,
51
+ ): Promise<void> {
52
+ const ext = registry.createExtensionState(meta.id, meta.manifest, meta.enabled !== false, meta.installedAt, meta.filename);
53
+
54
+ ext.messages = await loadMessages(ext.id, ext.manifest);
55
+ resolveManifestI18n(ext.manifest, ext.messages);
56
+ registerContentScripts(ext, registry);
57
+
58
+ const defaultIconPath = getDefaultIcon(ext.manifest);
59
+ if (defaultIconPath) {
60
+ try {
61
+ ext.iconUrl = await readExtFileURL(ext.id, defaultIconPath);
62
+ } catch (e) {
63
+ console.warn(`[sapphire] failed to resolve default icon for ${ext.manifest.name}`, e);
64
+ }
65
+ }
66
+
67
+ const ruleResources = ext.manifest.declarative_net_request?.rule_resources ?? [];
68
+ for (const ruleSet of ruleResources) {
69
+ if (!ruleSet.path || !ruleSet.id) continue;
70
+ const rulesText = await readExtFileText(ext.id, ruleSet.path);
71
+ if (!rulesText) continue;
72
+ try {
73
+ ext.rulesetRules.set(ruleSet.id, JSON.parse(rulesText));
74
+ if (ruleSet.enabled !== false) ext.enabledRulesetIds.add(ruleSet.id);
75
+ } catch (e) {
76
+ console.warn(`[sapphire] failed to parse rule set ${ruleSet.path}`, e);
77
+ }
78
+ }
79
+ recomputeStaticRules(ext);
80
+
81
+ if (ext.enabled) {
82
+ try {
83
+ await startBackground(ext, registry, host, backgroundRoot);
84
+ } catch (e) {
85
+ console.error(`[sapphire] failed to start background for ${ext.manifest.name}`, e);
86
+ }
87
+ }
88
+
89
+ registry.notifyChange();
90
+ }
91
+
92
+ export async function loadStoredExtensions(registry: SapphireRegistry, host: SapphireHostBindings, backgroundRoot: HTMLElement): Promise<number> {
93
+ const stored = await dbGetAll<ExtensionMeta>(EXT_STORE);
94
+ for (const meta of stored) {
95
+ try {
96
+ await loadExtension(meta, registry, host, backgroundRoot);
97
+ } catch (e) {
98
+ console.error(`[sapphire] failed to load stored extension ${meta.id}`, e);
99
+ }
100
+ }
101
+ return stored.length;
102
+ }
103
+
104
+ export async function installExtension(
105
+ buffer: ArrayBuffer,
106
+ filename: string,
107
+ registry: SapphireRegistry,
108
+ host: SapphireHostBindings,
109
+ backgroundRoot: HTMLElement,
110
+ ): Promise<string> {
111
+ const zipBuffer = crxToZip(buffer);
112
+ const zip = await JSZip.loadAsync(zipBuffer);
113
+
114
+ const manifestFile = zip.file("manifest.json");
115
+ if (!manifestFile) throw new Error("no manifest.json found in extension");
116
+ const manifestText = await manifestFile.async("text");
117
+
118
+ let manifest: ChromeManifest;
119
+ try {
120
+ manifest = JSON.parse(manifestText);
121
+ } catch (e) {
122
+ throw new Error(`invalid manifest: ${(e as Error).message}`);
123
+ }
124
+
125
+ const extId = generateExtensionId(manifest.name + (manifest.version ?? ""));
126
+
127
+ const fileOps: Promise<void>[] = [];
128
+ zip.forEach((path, file) => {
129
+ if (file.dir) return;
130
+ fileOps.push(
131
+ file
132
+ .async("arraybuffer")
133
+ .then((ab) => dbPut(EXT_FILES_STORE, `${extId}/${path}`, ab))
134
+ .then(() => undefined)
135
+ .catch((e) => {
136
+ console.error(`[sapphire] failed to store file ${path} for ${manifest.name}`, e);
137
+ }),
138
+ );
139
+ });
140
+ await Promise.all(fileOps);
141
+
142
+ const meta: ExtensionMeta = {
143
+ id: extId,
144
+ manifest,
145
+ enabled: true,
146
+ installedAt: Date.now(),
147
+ filename,
148
+ fileList: [],
149
+ };
150
+ await dbPut(EXT_STORE, null, meta);
151
+ await loadExtension(meta, registry, host, backgroundRoot);
152
+ return extId;
153
+ }
154
+
155
+ export async function uninstallExtension(extId: string, registry: SapphireRegistry): Promise<void> {
156
+ const ext = registry.get(extId);
157
+ if (!ext) return;
158
+
159
+ stopBackground(ext);
160
+ unregisterContentScripts(extId, registry);
161
+ registry.remove(extId);
162
+
163
+ await dbDelete(EXT_STORE, extId);
164
+ const fileKeys = await dbGetAllKeys(EXT_FILES_STORE);
165
+ for (const k of fileKeys.filter((key) => typeof key === "string" && key.startsWith(`${extId}/`))) {
166
+ await dbDelete(EXT_FILES_STORE, k);
167
+ }
168
+ const storageKeys = await dbGetAllKeys(EXT_STORAGE_STORE);
169
+ for (const k of storageKeys.filter((key) => typeof key === "string" && key.startsWith(`${extId}/`))) {
170
+ await dbDelete(EXT_STORAGE_STORE, k);
171
+ }
172
+ registry.notifyChange();
173
+ }
174
+
175
+ export async function setExtensionEnabled(
176
+ extId: string,
177
+ enabled: boolean,
178
+ registry: SapphireRegistry,
179
+ host: SapphireHostBindings,
180
+ backgroundRoot: HTMLElement,
181
+ ): Promise<void> {
182
+ const ext = registry.get(extId);
183
+ if (!ext) return;
184
+ ext.enabled = enabled;
185
+ const stored = await dbGet<ExtensionMeta>(EXT_STORE, extId);
186
+ if (stored) {
187
+ stored.enabled = enabled;
188
+ await dbPut(EXT_STORE, null, stored);
189
+ }
190
+ if (enabled) {
191
+ await startBackground(ext, registry, host, backgroundRoot);
192
+ } else {
193
+ stopBackground(ext);
194
+ }
195
+ registry.notifyChange();
196
+ }
197
+
198
+ export function getInstalledExtensions(registry: SapphireRegistry): InstalledExtensionSummary[] {
199
+ return registry.list().map((ext) => ({
200
+ id: ext.id,
201
+ name: ext.manifest.name,
202
+ version: ext.manifest.version,
203
+ enabled: ext.enabled,
204
+ manifest: ext.manifest,
205
+ iconUrl: ext.iconUrl,
206
+ title: ext.title,
207
+ badgeText: ext.badgeText,
208
+ badgeColor: ext.badgeColor,
209
+ hasPopup: Boolean(
210
+ ext.popupPage ??
211
+ ext.manifest.action?.default_popup ??
212
+ ext.manifest.browser_action?.default_popup ??
213
+ ext.manifest.page_action?.default_popup,
214
+ ),
215
+ }));
216
+ }
@@ -0,0 +1,22 @@
1
+ import { dbGet } from "./db";
2
+ import { EXT_FILES_STORE } from "./db";
3
+ import { guessMime } from "./crx";
4
+
5
+ export async function readExtFile(extId: string, path: string): Promise<ArrayBuffer | null> {
6
+ const key = `${extId}/${path.replace(/^\//, "")}`;
7
+ const ab = await dbGet<ArrayBuffer>(EXT_FILES_STORE, key);
8
+ return ab ?? null;
9
+ }
10
+
11
+ export async function readExtFileText(extId: string, path: string): Promise<string | null> {
12
+ const ab = await readExtFile(extId, path);
13
+ if (!ab) return null;
14
+ return new TextDecoder().decode(ab);
15
+ }
16
+
17
+ export async function readExtFileURL(extId: string, path: string): Promise<string | null> {
18
+ const ab = await readExtFile(extId, path);
19
+ if (!ab) return null;
20
+ const mime = guessMime(path);
21
+ return URL.createObjectURL(new Blob([ab], { type: mime }));
22
+ }