@midra/webext 0.0.0-stage → 0.0.1
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 +279 -2
- package/dist/index.d.ts +899 -0
- package/dist/index.js +738 -0
- package/package.json +45 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
//#region src/paths.ts
|
|
2
|
+
const LINKED_TAB_ID_NAME = "_webext_linked_tab_id";
|
|
3
|
+
const POPOUT_NAME = "_webext_popout";
|
|
4
|
+
const ambiguousRelativePath = /^(?:\/|[^/]*:)/;
|
|
5
|
+
function isExtensionUrl(url, extensionUrl) {
|
|
6
|
+
return url.protocol === extensionUrl.protocol && url.host === extensionUrl.host;
|
|
7
|
+
}
|
|
8
|
+
/** 外部URLを拒否し、拡張ルートを基準にページのURLを解決する。 */
|
|
9
|
+
function resolveExtensionPath(path, extensionUrl) {
|
|
10
|
+
if (typeof path !== "string" || !path.trim()) throw new TypeError("A non-empty extension page path is required");
|
|
11
|
+
const target = new URL(path, extensionUrl);
|
|
12
|
+
if (!isExtensionUrl(target, extensionUrl)) throw new TypeError("Path must point to a page in this extension");
|
|
13
|
+
return target;
|
|
14
|
+
}
|
|
15
|
+
function normalizeExtensionPath(path, extensionUrl) {
|
|
16
|
+
const target = resolveExtensionPath(path, extensionUrl);
|
|
17
|
+
const relativePath = target.pathname.slice(1);
|
|
18
|
+
return `${ambiguousRelativePath.test(relativePath) ? `.${target.pathname}` : relativePath}${target.search}${target.hash}`;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/context.ts
|
|
22
|
+
function detectContext(api, url, extensionPage, extensionUrl) {
|
|
23
|
+
if (typeof ServiceWorkerGlobalScope !== "undefined" && globalThis instanceof ServiceWorkerGlobalScope) return "background";
|
|
24
|
+
if (typeof window !== "undefined" && api.extension?.getBackgroundPage?.() === window) return "background";
|
|
25
|
+
if (url && !extensionPage) return "content-script";
|
|
26
|
+
if (!extensionPage) return null;
|
|
27
|
+
const manifest = api.runtime.getManifest();
|
|
28
|
+
const matches = (path) => !!path && url?.pathname === new URL(path, extensionUrl).pathname;
|
|
29
|
+
if (matches(manifest.action?.default_popup)) return "popup";
|
|
30
|
+
if (matches(manifest.side_panel?.default_path ?? manifest.sidebar_action?.default_panel)) return "sidepanel";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function createContext(api, extensionUrl, options) {
|
|
34
|
+
const href = options.url ?? globalThis.location?.href;
|
|
35
|
+
const url = href ? new URL(href) : void 0;
|
|
36
|
+
const extensionPage = !!url && isExtensionUrl(url, extensionUrl);
|
|
37
|
+
const rawTabId = extensionPage ? url?.searchParams.get(LINKED_TAB_ID_NAME) : null;
|
|
38
|
+
const parsed = rawTabId?.trim() ? Number(rawTabId) : NaN;
|
|
39
|
+
const linkedTabId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
LINKED_TAB_ID_NAME,
|
|
42
|
+
browser: options.browser ?? (extensionUrl.protocol === "moz-extension:" ? "firefox" : extensionUrl.protocol === "chrome-extension:" ? "chrome" : "unknown"),
|
|
43
|
+
type: options.context === void 0 ? detectContext(api, url, extensionPage, extensionUrl) : options.context,
|
|
44
|
+
linkedTabId,
|
|
45
|
+
isPopout: extensionPage && (url?.searchParams.get("_webext_popout") === "1" || linkedTabId !== null)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/facade.ts
|
|
50
|
+
/** グローバルAPIを変更せず、ネイティブメソッドの呼び出し元を維持する。 */
|
|
51
|
+
function facade(native, extra) {
|
|
52
|
+
const target = Object.assign(Object.create(null), extra);
|
|
53
|
+
const bound = /* @__PURE__ */ new Map();
|
|
54
|
+
return new Proxy(target, {
|
|
55
|
+
get(target, key, receiver) {
|
|
56
|
+
if (Object.hasOwn(target, key)) return Reflect.get(target, key, receiver);
|
|
57
|
+
const value = Reflect.get(native, key, native);
|
|
58
|
+
if (typeof value !== "function") return value;
|
|
59
|
+
let entry = bound.get(key);
|
|
60
|
+
if (!entry || entry.original !== value) {
|
|
61
|
+
entry = {
|
|
62
|
+
original: value,
|
|
63
|
+
value: value.bind(native)
|
|
64
|
+
};
|
|
65
|
+
bound.set(key, entry);
|
|
66
|
+
}
|
|
67
|
+
return entry.value;
|
|
68
|
+
},
|
|
69
|
+
has(target, key) {
|
|
70
|
+
return Reflect.has(target, key) || Reflect.has(native, key);
|
|
71
|
+
},
|
|
72
|
+
ownKeys(target) {
|
|
73
|
+
return [.../* @__PURE__ */ new Set([...Reflect.ownKeys(native), ...Reflect.ownKeys(target)])];
|
|
74
|
+
},
|
|
75
|
+
getOwnPropertyDescriptor(target, key) {
|
|
76
|
+
return Reflect.getOwnPropertyDescriptor(target, key) ?? (Reflect.has(native, key) ? {
|
|
77
|
+
configurable: true,
|
|
78
|
+
enumerable: true,
|
|
79
|
+
get: () => Reflect.get(native, key, native)
|
|
80
|
+
} : void 0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** 初回のアクセスまで初期化を遅延し、初期化後と同じプロパティ参照を提供する。 */
|
|
85
|
+
function lazyFacade(initialize) {
|
|
86
|
+
let instance;
|
|
87
|
+
const getInstance = () => instance ??= initialize();
|
|
88
|
+
return new Proxy({}, {
|
|
89
|
+
get(_target, key) {
|
|
90
|
+
return Reflect.get(getInstance(), key);
|
|
91
|
+
},
|
|
92
|
+
set(_target, key, value) {
|
|
93
|
+
return Reflect.set(getInstance(), key, value);
|
|
94
|
+
},
|
|
95
|
+
has(_target, key) {
|
|
96
|
+
return Reflect.has(getInstance(), key);
|
|
97
|
+
},
|
|
98
|
+
ownKeys() {
|
|
99
|
+
return Reflect.ownKeys(getInstance());
|
|
100
|
+
},
|
|
101
|
+
getOwnPropertyDescriptor(_target, key) {
|
|
102
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(getInstance(), key);
|
|
103
|
+
return descriptor && {
|
|
104
|
+
...descriptor,
|
|
105
|
+
configurable: true
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/errors.ts
|
|
112
|
+
/**
|
|
113
|
+
* 現在のブラウザ・実行コンテキストに、操作に必要なAPIや機能がない場合のエラー。
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* 権限不足などのネイティブエラーを一律にこの型へ変換するものではありません。
|
|
117
|
+
* `StorageHelpers.watch()` / `MessageChannel.handle()` では同期的に送出し、`MessageChannel.send()` など
|
|
118
|
+
* Promiseを返す操作では拒否理由になります。捕捉方法は呼び出すAPIに従ってください。
|
|
119
|
+
*/
|
|
120
|
+
var UnsupportedOperationError = class extends Error {
|
|
121
|
+
operation;
|
|
122
|
+
/**
|
|
123
|
+
* 利用できない操作名を含むエラーを作成します。
|
|
124
|
+
* @param operation - ライブラリ内の操作識別子(例: `storage.watch`)。
|
|
125
|
+
*/
|
|
126
|
+
constructor(operation) {
|
|
127
|
+
super(`webext: ${operation} is unavailable in this browser or context`);
|
|
128
|
+
this.operation = operation;
|
|
129
|
+
this.name = "UnsupportedOperationError";
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* メッセージの応答待機が送信側の期限を超過した場合のエラー。
|
|
134
|
+
*
|
|
135
|
+
* @remarks
|
|
136
|
+
* `channel.send()` のPromiseの拒否理由です。受信側の処理やネイティブ通信が停止したことは意味しません。
|
|
137
|
+
* 不正な待機上限の指定はこの型ではなく `TypeError` になります。
|
|
138
|
+
*/
|
|
139
|
+
var MessageTimeoutError = class extends Error {
|
|
140
|
+
timeoutMs;
|
|
141
|
+
/**
|
|
142
|
+
* 応答待機の上限を含むエラーを作成します。
|
|
143
|
+
* @param timeoutMs - 超過した待機上限(ミリ秒)。このコンストラクター自体は値を検証しません。
|
|
144
|
+
*/
|
|
145
|
+
constructor(timeoutMs) {
|
|
146
|
+
super(`webext: message did not respond within ${timeoutMs}ms`);
|
|
147
|
+
this.timeoutMs = timeoutMs;
|
|
148
|
+
this.name = "MessageTimeoutError";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* 受信側のハンドラーの失敗を送信側へ伝えるエラー。
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* ハンドラーの同期例外・Promiseの拒否・応答のJSON化失敗は、`channel.send()` のPromiseをこの型で拒否します。
|
|
156
|
+
* `name` は `RemoteError`、元の名前は `remoteName`、失敗内容は継承した `message` に保持します。
|
|
157
|
+
* 元の例外オブジェクト・スタック・独自プロパティは転送しません。Error以外の例外値は名前を `Error` として
|
|
158
|
+
* 文字列化し、文字列化も失敗した場合は `Error` / `Remote handler failed` にフォールバックします。
|
|
159
|
+
*/
|
|
160
|
+
var RemoteError = class extends Error {
|
|
161
|
+
remoteName;
|
|
162
|
+
/**
|
|
163
|
+
* 受信側から転送された名前とメッセージでエラーを作成します。
|
|
164
|
+
* @param message - 受信側のエラーメッセージ、または例外値を文字列化した内容。
|
|
165
|
+
* @param remoteName - 受信側の例外名。名前を取得できない場合は `Error`。
|
|
166
|
+
*/
|
|
167
|
+
constructor(message, remoteName) {
|
|
168
|
+
super(message);
|
|
169
|
+
this.remoteName = remoteName;
|
|
170
|
+
this.name = "RemoteError";
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* タブIDが非負の安全な整数であることだけを検証します。
|
|
175
|
+
* @param id - 検証するタブID。タブの存在やアクセス可否は確認しません。
|
|
176
|
+
* @returns 戻り値はありません。
|
|
177
|
+
* @throws {TypeError} 非負の安全な整数でなければ同期的に送出します。
|
|
178
|
+
* @internal
|
|
179
|
+
*/
|
|
180
|
+
function assertTabId(id) {
|
|
181
|
+
if (!Number.isSafeInteger(id) || id < 0) throw new TypeError("Invalid tab ID");
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/messaging/serialization.ts
|
|
185
|
+
/** JSON に限定し、Chrome と Firefox のペイロードの扱いを統一します。 */
|
|
186
|
+
function encode(value) {
|
|
187
|
+
if (value === void 0) return null;
|
|
188
|
+
const snapshots = /* @__PURE__ */ new WeakMap();
|
|
189
|
+
const visit = (item, ancestors) => {
|
|
190
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return item;
|
|
191
|
+
if (typeof item === "number" && Number.isFinite(item)) return item;
|
|
192
|
+
if (typeof item !== "object" || item === null) throw new TypeError("Messages must contain JSON-compatible values");
|
|
193
|
+
if (ancestors.has(item)) throw new TypeError("Messages must not contain cycles");
|
|
194
|
+
const cached = snapshots.get(item);
|
|
195
|
+
if (cached !== void 0) return cached;
|
|
196
|
+
const isArray = Array.isArray(item);
|
|
197
|
+
const prototype = Object.getPrototypeOf(item);
|
|
198
|
+
if (!isArray && prototype !== Object.prototype && prototype !== null) throw new TypeError("Messages must use plain objects and arrays");
|
|
199
|
+
ancestors.add(item);
|
|
200
|
+
let snapshot;
|
|
201
|
+
if (isArray) {
|
|
202
|
+
const array = [];
|
|
203
|
+
const length = item.length;
|
|
204
|
+
for (let index = 0; index < length; index++) array.push(index in item ? visit(item[index], ancestors) : null);
|
|
205
|
+
snapshot = array;
|
|
206
|
+
} else {
|
|
207
|
+
const object = Object.create(null);
|
|
208
|
+
for (const key of Object.keys(item)) {
|
|
209
|
+
const child = item[key];
|
|
210
|
+
if (child !== void 0) object[key] = visit(child, ancestors);
|
|
211
|
+
}
|
|
212
|
+
snapshot = object;
|
|
213
|
+
}
|
|
214
|
+
ancestors.delete(item);
|
|
215
|
+
snapshots.set(item, snapshot);
|
|
216
|
+
return snapshot;
|
|
217
|
+
};
|
|
218
|
+
return JSON.parse(JSON.stringify(visit(value, /* @__PURE__ */ new Set())));
|
|
219
|
+
}
|
|
220
|
+
function normalizeError(error) {
|
|
221
|
+
try {
|
|
222
|
+
return error instanceof Error ? {
|
|
223
|
+
name: String(error.name),
|
|
224
|
+
message: String(error.message)
|
|
225
|
+
} : {
|
|
226
|
+
name: "Error",
|
|
227
|
+
message: String(error)
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return {
|
|
231
|
+
name: "Error",
|
|
232
|
+
message: "Remote handler failed"
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/messaging/transport.ts
|
|
238
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
239
|
+
async function sendMessage(api, name, type, request, options = {}) {
|
|
240
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
241
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) throw new TypeError(`timeoutMs must be positive, finite and at most ${MAX_TIMEOUT_MS}`);
|
|
242
|
+
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
243
|
+
if (options.tabId !== void 0) assertTabId(options.tabId);
|
|
244
|
+
if (options.tabId === void 0 && (options.frameId !== void 0 || options.documentId !== void 0)) throw new TypeError("frameId/documentId requires tabId");
|
|
245
|
+
const message = {
|
|
246
|
+
__webext_rpc__: 1,
|
|
247
|
+
channel: name,
|
|
248
|
+
type,
|
|
249
|
+
payload: encode(request),
|
|
250
|
+
empty: request === void 0
|
|
251
|
+
};
|
|
252
|
+
const send = () => {
|
|
253
|
+
if (options.tabId === void 0) return api.runtime.sendMessage(message);
|
|
254
|
+
if (!api.tabs?.sendMessage) throw new UnsupportedOperationError("messaging.send to tab");
|
|
255
|
+
const target = {
|
|
256
|
+
...options.frameId === void 0 ? {} : { frameId: options.frameId },
|
|
257
|
+
...options.documentId === void 0 ? {} : { documentId: options.documentId }
|
|
258
|
+
};
|
|
259
|
+
return api.tabs.sendMessage(options.tabId, message, target);
|
|
260
|
+
};
|
|
261
|
+
let timer;
|
|
262
|
+
let onAbort;
|
|
263
|
+
try {
|
|
264
|
+
const response = await Promise.race([send(), new Promise((_resolve, reject) => {
|
|
265
|
+
timer = setTimeout(() => reject(new MessageTimeoutError(timeoutMs)), timeoutMs);
|
|
266
|
+
if (options.signal) {
|
|
267
|
+
onAbort = () => reject(options.signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
268
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
269
|
+
if (options.signal.aborted) onAbort();
|
|
270
|
+
}
|
|
271
|
+
})]);
|
|
272
|
+
if (response?.__webext_rpc__ !== 1 || typeof response.ok !== "boolean") throw new Error(`No compatible response for ${name}:${type}`);
|
|
273
|
+
if (!response.ok) throw new RemoteError(response.error?.message ?? "Remote handler failed", response.error?.name ?? "Error");
|
|
274
|
+
return response.empty ? void 0 : response.value;
|
|
275
|
+
} finally {
|
|
276
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
277
|
+
if (onAbort) options.signal?.removeEventListener("abort", onAbort);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/messaging/index.ts
|
|
282
|
+
function createMessaging(api) {
|
|
283
|
+
const channels = /* @__PURE__ */ new Map();
|
|
284
|
+
const routes = /* @__PURE__ */ new Map();
|
|
285
|
+
let listening = false;
|
|
286
|
+
let factoryDisposed = false;
|
|
287
|
+
const listener = (message, sender, sendResponse) => {
|
|
288
|
+
if (!sendResponse || !message || typeof message !== "object") return false;
|
|
289
|
+
const envelope = message;
|
|
290
|
+
if (envelope.__webext_rpc__ !== 1 || typeof envelope.channel !== "string" || typeof envelope.type !== "string" || sender.id !== api.runtime.id) return false;
|
|
291
|
+
const handler = routes.get(envelope.channel)?.get(envelope.type);
|
|
292
|
+
if (!handler) return false;
|
|
293
|
+
(async () => {
|
|
294
|
+
try {
|
|
295
|
+
const result = await handler(envelope.empty === true ? void 0 : envelope.payload, sender);
|
|
296
|
+
sendResponse({
|
|
297
|
+
__webext_rpc__: 1,
|
|
298
|
+
ok: true,
|
|
299
|
+
value: encode(result),
|
|
300
|
+
empty: result === void 0
|
|
301
|
+
});
|
|
302
|
+
} catch (error) {
|
|
303
|
+
sendResponse({
|
|
304
|
+
__webext_rpc__: 1,
|
|
305
|
+
ok: false,
|
|
306
|
+
error: normalizeError(error)
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
})();
|
|
310
|
+
return true;
|
|
311
|
+
};
|
|
312
|
+
const removeRoute = (name, handlers) => {
|
|
313
|
+
if (routes.get(name) !== handlers) return;
|
|
314
|
+
routes.delete(name);
|
|
315
|
+
if (!routes.size && listening) {
|
|
316
|
+
api.runtime.onMessage.removeListener(listener);
|
|
317
|
+
listening = false;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
channel(name) {
|
|
322
|
+
if (factoryDisposed) throw new Error("Messaging is disposed");
|
|
323
|
+
if (!name.trim()) throw new TypeError("Message channel name must not be empty");
|
|
324
|
+
const cached = channels.get(name);
|
|
325
|
+
if (cached) return cached;
|
|
326
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
327
|
+
let disposed = false;
|
|
328
|
+
const channel = {
|
|
329
|
+
send(type, request, options = {}) {
|
|
330
|
+
if (disposed) return Promise.reject(/* @__PURE__ */ new Error("Message channel is disposed"));
|
|
331
|
+
return sendMessage(api, name, type, request, options);
|
|
332
|
+
},
|
|
333
|
+
handle(type, handler) {
|
|
334
|
+
if (disposed) throw new Error("Message channel is disposed");
|
|
335
|
+
if (handlers.has(type)) throw new Error(`A handler for ${name}:${type} is already registered`);
|
|
336
|
+
if (!api.runtime.onMessage) throw new UnsupportedOperationError("messaging.handle");
|
|
337
|
+
handlers.set(type, handler);
|
|
338
|
+
routes.set(name, handlers);
|
|
339
|
+
if (!listening) {
|
|
340
|
+
api.runtime.onMessage.addListener(listener);
|
|
341
|
+
listening = true;
|
|
342
|
+
}
|
|
343
|
+
let stopped = false;
|
|
344
|
+
return () => {
|
|
345
|
+
if (stopped) return;
|
|
346
|
+
stopped = true;
|
|
347
|
+
if (handlers.get(type) === handler) handlers.delete(type);
|
|
348
|
+
if (!handlers.size) removeRoute(name, handlers);
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
dispose() {
|
|
352
|
+
if (disposed) return;
|
|
353
|
+
removeRoute(name, handlers);
|
|
354
|
+
handlers.clear();
|
|
355
|
+
disposed = true;
|
|
356
|
+
channels.delete(name);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
channels.set(name, channel);
|
|
360
|
+
return channel;
|
|
361
|
+
},
|
|
362
|
+
dispose() {
|
|
363
|
+
if (factoryDisposed) return;
|
|
364
|
+
factoryDisposed = true;
|
|
365
|
+
for (const channel of channels.values()) channel.dispose();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/popout.ts
|
|
371
|
+
function createPopout(api, extensionUrl, getTargetId) {
|
|
372
|
+
async function resolveTabId(options) {
|
|
373
|
+
if (options.tabId === null) return null;
|
|
374
|
+
if (options.tabId !== void 0) {
|
|
375
|
+
assertTabId(options.tabId);
|
|
376
|
+
return options.tabId;
|
|
377
|
+
}
|
|
378
|
+
return await getTargetId() ?? null;
|
|
379
|
+
}
|
|
380
|
+
async function openPopout(path, options = {}) {
|
|
381
|
+
if (!api.windows) throw new UnsupportedOperationError("popout windows");
|
|
382
|
+
const target = resolveExtensionPath(path, extensionUrl);
|
|
383
|
+
const { tabId: _requestedTabId, ...createData } = options;
|
|
384
|
+
const tabId = await resolveTabId(options);
|
|
385
|
+
target.searchParams.set(POPOUT_NAME, "1");
|
|
386
|
+
target.searchParams.delete(LINKED_TAB_ID_NAME);
|
|
387
|
+
if (tabId !== null) target.searchParams.set(LINKED_TAB_ID_NAME, String(tabId));
|
|
388
|
+
return api.windows.create({
|
|
389
|
+
...createData,
|
|
390
|
+
type: "popup",
|
|
391
|
+
url: target.href
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
resolveTabId,
|
|
396
|
+
openPopout
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/disposables.ts
|
|
401
|
+
/** 登録した解除処理を一度だけ実行し、不要になった参照を取り除く。 */
|
|
402
|
+
function createDisposables() {
|
|
403
|
+
const disposers = /* @__PURE__ */ new Set();
|
|
404
|
+
return {
|
|
405
|
+
add(cleanup) {
|
|
406
|
+
let active = true;
|
|
407
|
+
const dispose = () => {
|
|
408
|
+
if (!active) return;
|
|
409
|
+
active = false;
|
|
410
|
+
disposers.delete(dispose);
|
|
411
|
+
cleanup();
|
|
412
|
+
};
|
|
413
|
+
disposers.add(dispose);
|
|
414
|
+
return dispose;
|
|
415
|
+
},
|
|
416
|
+
dispose() {
|
|
417
|
+
for (const dispose of [...disposers]) dispose();
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/side.ts
|
|
423
|
+
const WINDOW_ID_CURRENT = -2;
|
|
424
|
+
function createSide(api, helpers) {
|
|
425
|
+
const panel = api.sidePanel;
|
|
426
|
+
const sidebar = panel ? void 0 : api.sidebarAction;
|
|
427
|
+
const disposers = createDisposables();
|
|
428
|
+
const capabilities = Object.freeze({
|
|
429
|
+
open: !!(panel?.open || sidebar?.open),
|
|
430
|
+
close: !!(panel?.close || sidebar?.close),
|
|
431
|
+
path: !!(panel?.getOptions && panel.setOptions || sidebar),
|
|
432
|
+
isOpen: sidebar?.isOpen ? "native" : panel && api.runtime?.getContexts && api.windows?.getCurrent ? "document" : false,
|
|
433
|
+
targetedOpen: !!panel?.open,
|
|
434
|
+
actionClick: !!api.action?.onClicked && !!(panel?.open || sidebar?.open)
|
|
435
|
+
});
|
|
436
|
+
function validateTarget(target) {
|
|
437
|
+
if (target.tabId !== void 0) assertTabId(target.tabId);
|
|
438
|
+
if (target.windowId !== void 0 && (!Number.isSafeInteger(target.windowId) || target.windowId < 0 && target.windowId !== WINDOW_ID_CURRENT)) throw new TypeError("Invalid window ID");
|
|
439
|
+
}
|
|
440
|
+
function requireCurrentWindow(target) {
|
|
441
|
+
if (target.tabId !== void 0 || target.windowId !== void 0 && target.windowId !== WINDOW_ID_CURRENT) throw new UnsupportedOperationError("side explicit open/close target");
|
|
442
|
+
}
|
|
443
|
+
function chromeTarget(target) {
|
|
444
|
+
return target.tabId !== void 0 || target.windowId !== void 0 ? { ...target } : { windowId: WINDOW_ID_CURRENT };
|
|
445
|
+
}
|
|
446
|
+
async function getPath(target = {}) {
|
|
447
|
+
validateTarget(target);
|
|
448
|
+
const path = panel?.getOptions ? (await panel.getOptions({ ...target })).path : sidebar ? await sidebar.getPanel({ ...target }) : void 0;
|
|
449
|
+
if (!panel?.getOptions && !sidebar) throw new UnsupportedOperationError("side.getPath");
|
|
450
|
+
return path ? helpers.normalizePath(path) : void 0;
|
|
451
|
+
}
|
|
452
|
+
const side = {
|
|
453
|
+
available: capabilities.open,
|
|
454
|
+
capabilities,
|
|
455
|
+
async open(target = {}) {
|
|
456
|
+
validateTarget(target);
|
|
457
|
+
if (panel?.open) return panel.open(chromeTarget(target));
|
|
458
|
+
if (sidebar) {
|
|
459
|
+
requireCurrentWindow(target);
|
|
460
|
+
return sidebar.open();
|
|
461
|
+
}
|
|
462
|
+
throw new UnsupportedOperationError("side.open");
|
|
463
|
+
},
|
|
464
|
+
async close(target = {}) {
|
|
465
|
+
validateTarget(target);
|
|
466
|
+
if (panel?.close) {
|
|
467
|
+
const options = chromeTarget(target);
|
|
468
|
+
if (options.windowId === WINDOW_ID_CURRENT) {
|
|
469
|
+
if (!api.windows?.getCurrent) throw new UnsupportedOperationError("side.close current window");
|
|
470
|
+
const windowId = (await api.windows.getCurrent()).id;
|
|
471
|
+
if (windowId === void 0) throw new Error("No current window is available");
|
|
472
|
+
options.windowId = windowId;
|
|
473
|
+
}
|
|
474
|
+
return panel.close(options);
|
|
475
|
+
}
|
|
476
|
+
if (sidebar) {
|
|
477
|
+
requireCurrentWindow(target);
|
|
478
|
+
return sidebar.close();
|
|
479
|
+
}
|
|
480
|
+
throw new UnsupportedOperationError("side.close");
|
|
481
|
+
},
|
|
482
|
+
getPath,
|
|
483
|
+
async setPath(path, target = {}) {
|
|
484
|
+
validateTarget(target);
|
|
485
|
+
const normalized = helpers.normalizePath(path);
|
|
486
|
+
if (panel?.setOptions) return panel.setOptions({
|
|
487
|
+
...target,
|
|
488
|
+
path: normalized
|
|
489
|
+
});
|
|
490
|
+
if (sidebar) return sidebar.setPanel({
|
|
491
|
+
...target,
|
|
492
|
+
panel: `/${normalized}`
|
|
493
|
+
});
|
|
494
|
+
throw new UnsupportedOperationError("side.setPath");
|
|
495
|
+
},
|
|
496
|
+
async isOpen(target = {}) {
|
|
497
|
+
validateTarget(target);
|
|
498
|
+
if (sidebar?.isOpen) return sidebar.isOpen({ ...target });
|
|
499
|
+
if (panel && api.runtime?.getContexts && api.windows?.getCurrent) {
|
|
500
|
+
const windowId = target.windowId === void 0 || target.windowId === WINDOW_ID_CURRENT ? (await api.windows.getCurrent()).id : target.windowId;
|
|
501
|
+
if (windowId === void 0) return false;
|
|
502
|
+
return (await api.runtime.getContexts({
|
|
503
|
+
contextTypes: ["SIDE_PANEL"],
|
|
504
|
+
windowIds: [windowId]
|
|
505
|
+
})).length > 0;
|
|
506
|
+
}
|
|
507
|
+
throw new UnsupportedOperationError("side.isOpen");
|
|
508
|
+
},
|
|
509
|
+
async openPopout(options = {}) {
|
|
510
|
+
const tabId = await helpers.resolveTabId(options);
|
|
511
|
+
const path = await getPath(tabId == null ? {} : { tabId });
|
|
512
|
+
if (!path) throw new Error("No side panel path is configured");
|
|
513
|
+
return helpers.openPopout(path, {
|
|
514
|
+
...options,
|
|
515
|
+
tabId
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
bindActionClick(onError) {
|
|
519
|
+
if (!capabilities.actionClick) throw new UnsupportedOperationError("side.bindActionClick");
|
|
520
|
+
const listener = (tab) => {
|
|
521
|
+
side.open(panel && tab.windowId !== void 0 ? { windowId: tab.windowId } : {}).catch(onError);
|
|
522
|
+
};
|
|
523
|
+
api.action.onClicked.addListener(listener);
|
|
524
|
+
return disposers.add(() => api.action.onClicked.removeListener(listener));
|
|
525
|
+
},
|
|
526
|
+
dispose() {
|
|
527
|
+
disposers.dispose();
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
return side;
|
|
531
|
+
}
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/storage.ts
|
|
534
|
+
const encoder = new TextEncoder();
|
|
535
|
+
function estimateBytes(values) {
|
|
536
|
+
let bytes = 0;
|
|
537
|
+
for (const key of Object.keys(values)) {
|
|
538
|
+
bytes += encoder.encode(key).byteLength;
|
|
539
|
+
bytes += encoder.encode(JSON.stringify(values[key])).byteLength;
|
|
540
|
+
}
|
|
541
|
+
return bytes;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* ネイティブAPIを変更せず、存在する領域にヘルパーを追加します。
|
|
545
|
+
* @param storage - ラップするネイティブストレージAPI。
|
|
546
|
+
* @returns 監視の解除処理を管理するストレージラッパー。
|
|
547
|
+
* @internal
|
|
548
|
+
*/
|
|
549
|
+
function createStorage(storage) {
|
|
550
|
+
const areas = {};
|
|
551
|
+
const disposers = createDisposables();
|
|
552
|
+
for (const name of [
|
|
553
|
+
"local",
|
|
554
|
+
"sync",
|
|
555
|
+
"managed",
|
|
556
|
+
"session"
|
|
557
|
+
]) {
|
|
558
|
+
const area = storage[name];
|
|
559
|
+
if (!area) continue;
|
|
560
|
+
areas[name] = facade(area, {
|
|
561
|
+
capabilities: Object.freeze({
|
|
562
|
+
getKeys: area.getKeys ? "native" : "polyfilled",
|
|
563
|
+
getBytesInUse: area.getBytesInUse ? "native" : "estimated"
|
|
564
|
+
}),
|
|
565
|
+
async getKeys() {
|
|
566
|
+
return area.getKeys ? area.getKeys() : Object.keys(await area.get(null));
|
|
567
|
+
},
|
|
568
|
+
async getBytesInUse(keys = null) {
|
|
569
|
+
if (area.getBytesInUse) return area.getBytesInUse(keys);
|
|
570
|
+
return estimateBytes(await area.get(keys));
|
|
571
|
+
},
|
|
572
|
+
async getValue(key, defaultValue) {
|
|
573
|
+
const values = await area.get(key);
|
|
574
|
+
return Object.hasOwn(values, key) ? values[key] : defaultValue;
|
|
575
|
+
},
|
|
576
|
+
async setValue(key, value) {
|
|
577
|
+
await area.set({ [key]: value });
|
|
578
|
+
},
|
|
579
|
+
watch(key, listener) {
|
|
580
|
+
if (!storage.onChanged) throw new UnsupportedOperationError("storage.watch");
|
|
581
|
+
const onChanged = (changes, areaName) => {
|
|
582
|
+
if (areaName !== name || !Object.hasOwn(changes, key)) return;
|
|
583
|
+
const change = changes[key];
|
|
584
|
+
listener(change.newValue, change.oldValue);
|
|
585
|
+
};
|
|
586
|
+
storage.onChanged.addListener(onChanged);
|
|
587
|
+
return disposers.add(() => storage.onChanged.removeListener(onChanged));
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return facade(storage, {
|
|
592
|
+
...areas,
|
|
593
|
+
dispose() {
|
|
594
|
+
disposers.dispose();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/tabs.ts
|
|
600
|
+
function createTabs(api, context, messaging) {
|
|
601
|
+
const native = api.tabs;
|
|
602
|
+
let internal;
|
|
603
|
+
let initialized = false;
|
|
604
|
+
const bridge = () => internal ??= messaging.channel("@midra/webext/internal");
|
|
605
|
+
async function queryActive(windowId) {
|
|
606
|
+
if (!native) throw new UnsupportedOperationError("background tab bridge");
|
|
607
|
+
return (await native.query(windowId === void 0 ? {
|
|
608
|
+
active: true,
|
|
609
|
+
currentWindow: true
|
|
610
|
+
} : {
|
|
611
|
+
active: true,
|
|
612
|
+
windowId
|
|
613
|
+
}))[0];
|
|
614
|
+
}
|
|
615
|
+
function initialize() {
|
|
616
|
+
if (initialized || context.type !== "background") return;
|
|
617
|
+
if (!native) throw new UnsupportedOperationError("background tab bridge");
|
|
618
|
+
bridge().handle("selfTab", (_request, sender) => sender.tab);
|
|
619
|
+
bridge().handle("activeTab", (_request, sender) => queryActive(sender.tab?.windowId));
|
|
620
|
+
initialized = true;
|
|
621
|
+
}
|
|
622
|
+
async function getCurrentActive() {
|
|
623
|
+
return native ? queryActive() : bridge().send("activeTab", null);
|
|
624
|
+
}
|
|
625
|
+
async function getSelf() {
|
|
626
|
+
if (context.type === "content-script" || !native) return bridge().send("selfTab", null);
|
|
627
|
+
return native.getCurrent?.();
|
|
628
|
+
}
|
|
629
|
+
async function getTarget() {
|
|
630
|
+
if (context.linkedTabId !== null) {
|
|
631
|
+
if (!native) throw new UnsupportedOperationError("tabs.get linked target");
|
|
632
|
+
return native.get(context.linkedTabId);
|
|
633
|
+
}
|
|
634
|
+
return context.type === "content-script" ? getSelf() : getCurrentActive();
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
tabs: facade(native ?? {}, {
|
|
638
|
+
available: !!native,
|
|
639
|
+
getCurrentActive,
|
|
640
|
+
async getCurrentActiveId() {
|
|
641
|
+
return (await getCurrentActive())?.id;
|
|
642
|
+
},
|
|
643
|
+
getTarget,
|
|
644
|
+
async getTargetId() {
|
|
645
|
+
return (await getTarget())?.id;
|
|
646
|
+
},
|
|
647
|
+
getSelf,
|
|
648
|
+
async getSelfId() {
|
|
649
|
+
return (await getSelf())?.id;
|
|
650
|
+
}
|
|
651
|
+
}),
|
|
652
|
+
initialize
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/core.ts
|
|
657
|
+
function currentApi() {
|
|
658
|
+
return globalThis.browser?.runtime?.id ? globalThis.browser : globalThis.chrome;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* ネイティブAPIを変更せず、独立した共通APIのインスタンスを作成します。
|
|
662
|
+
*
|
|
663
|
+
* @param options - APIの注入と実行環境の上書き設定。
|
|
664
|
+
* @returns 実行環境・共通ヘルパー・解除処理を持つインスタンス。
|
|
665
|
+
* @remarks
|
|
666
|
+
* backgroundとして作成する場合、内部タブ取得ブリッジはこの呼び出し中に同期登録されます。
|
|
667
|
+
* 権限取得、manifestの変換、ネイティブAPI全体のPromise化は行いません。
|
|
668
|
+
* @throws {Error} 拡張APIがない場合や、初期化に必要なネイティブAPIが失敗した場合。同期例外です。
|
|
669
|
+
* @throws {TypeError} 上書きURLなどをURLとして解析できない場合。同期例外です。
|
|
670
|
+
* @example
|
|
671
|
+
* ```ts
|
|
672
|
+
* const webext = createWebExt({ context: 'sidepanel' })
|
|
673
|
+
* const tab = await webext.tabs.getTarget()
|
|
674
|
+
* ```
|
|
675
|
+
* @see https://developer.chrome.com/docs/extensions/reference/api/runtime#method-getURL
|
|
676
|
+
* @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/events
|
|
677
|
+
*/
|
|
678
|
+
function createWebExt(options = {}) {
|
|
679
|
+
const native = options.api ?? currentApi();
|
|
680
|
+
const api = native;
|
|
681
|
+
if (!api?.runtime?.getManifest) throw new Error("webext requires a WebExtension context or an injected API");
|
|
682
|
+
const extensionUrl = new URL(api.runtime.getURL("/"));
|
|
683
|
+
const context = createContext(api, extensionUrl, options);
|
|
684
|
+
const messaging = createMessaging(api);
|
|
685
|
+
const { tabs, initialize } = createTabs(api, context, messaging);
|
|
686
|
+
const { resolveTabId, openPopout } = createPopout(api, extensionUrl, () => tabs.getTargetId());
|
|
687
|
+
const action = api.action && facade(api.action, { async openPopout(options = {}) {
|
|
688
|
+
const tabId = await resolveTabId(options);
|
|
689
|
+
const path = await api.action.getPopup(tabId === null ? {} : { tabId });
|
|
690
|
+
if (!path) throw new Error("No action popup path is configured");
|
|
691
|
+
return openPopout(path, {
|
|
692
|
+
...options,
|
|
693
|
+
tabId
|
|
694
|
+
});
|
|
695
|
+
} });
|
|
696
|
+
const side = createSide(api, {
|
|
697
|
+
resolveTabId,
|
|
698
|
+
normalizePath: (path) => normalizeExtensionPath(path, extensionUrl),
|
|
699
|
+
openPopout
|
|
700
|
+
});
|
|
701
|
+
const storage = api.storage && createStorage(api.storage);
|
|
702
|
+
const instance = facade(api, {
|
|
703
|
+
native,
|
|
704
|
+
context,
|
|
705
|
+
tabs,
|
|
706
|
+
action,
|
|
707
|
+
side,
|
|
708
|
+
storage,
|
|
709
|
+
menus: api.menus ?? api.contextMenus,
|
|
710
|
+
messaging,
|
|
711
|
+
initialize,
|
|
712
|
+
dispose() {
|
|
713
|
+
side.dispose();
|
|
714
|
+
storage?.dispose();
|
|
715
|
+
messaging.dispose();
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
initialize();
|
|
719
|
+
return instance;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* 初回のプロパティアクセスで初期化する、既定の共通APIインスタンス。
|
|
723
|
+
*
|
|
724
|
+
* @remarks
|
|
725
|
+
* モジュールのインポートだけでは拡張APIへアクセスしません。プロパティの読み取りや列挙などは
|
|
726
|
+
* 初期化を発生させるため、拡張環境が必要です。backgroundのトップレベルでは
|
|
727
|
+
* `webext.initialize()` を同期的に呼び、内部ブリッジを登録してください。
|
|
728
|
+
* 設定の注入や破棄後の新しいインスタンスには {@link createWebExt} を使用します。
|
|
729
|
+
* @example
|
|
730
|
+
* ```ts
|
|
731
|
+
* import { webext } from '@midra/webext'
|
|
732
|
+
* webext.initialize()
|
|
733
|
+
* ```
|
|
734
|
+
* @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/events
|
|
735
|
+
*/
|
|
736
|
+
const webext = lazyFacade(() => createWebExt());
|
|
737
|
+
//#endregion
|
|
738
|
+
export { MessageTimeoutError, RemoteError, UnsupportedOperationError, createWebExt, webext };
|