import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; import { Script, ScriptDAO } from "@App/app/repo/scripts"; import { ExtMessageSender, GetSender, Group, MessageSend } from "@Packages/message/server"; import { ValueService } from "@App/app/service/service_worker/value"; import PermissionVerify, { ConfirmParam } from "./permission_verify"; import { connect, sendMessage } from "@Packages/message/client"; import Cache, { incr } from "@App/app/cache"; import EventEmitter from "eventemitter3"; import { MessageQueue } from "@Packages/message/message_queue"; import { RuntimeService } from "./runtime"; import { getIcon, isFirefox } from "@App/pkg/utils/utils"; import { MockMessageConnect } from "@Packages/message/mock_message"; import i18next, { i18nName } from "@App/locales/locales"; import { SystemConfig } from "@App/pkg/config/config"; import FileSystemFactory from "@Packages/filesystem/factory"; import FileSystem from "@Packages/filesystem/filesystem"; import { isWarpTokenError } from "@Packages/filesystem/error"; import { joinPath } from "@Packages/filesystem/utils"; // GMApi,处理脚本的GM API调用请求 export type MessageRequest = { uuid: string; // 脚本id api: string; runFlag: string; params: any[]; }; export type Request = MessageRequest & { script: Script; }; export const unsafeHeaders: { [key: string]: boolean } = { // 部分浏览器中并未允许 "user-agent": true, // 这两个是前缀 "proxy-": true, "sec-": true, // cookie已经特殊处理 cookie: true, "accept-charset": true, "accept-encoding": true, "access-control-request-headers": true, "access-control-request-method": true, connection: true, "content-length": true, date: true, dnt: true, expect: true, "feature-policy": true, host: true, "keep-alive": true, origin: true, referer: true, te: true, trailer: true, "transfer-encoding": true, upgrade: true, via: true, }; type NotificationData = { uuid: string; details: GMTypes.NotificationDetails; sender: ExtMessageSender; }; export type Api = (request: Request, con: GetSender) => Promise; export default class GMApi { logger: Logger; scriptDAO: ScriptDAO = new ScriptDAO(); constructor( private systemConfig: SystemConfig, private permissionVerify: PermissionVerify, private group: Group, private send: MessageSend, private mq: MessageQueue, private value: ValueService, private runtime: RuntimeService ) { this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" }); } async handlerRequest(data: MessageRequest, sender: GetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); const api = PermissionVerify.apis.get(data.api); if (!api) { throw new Error("gm api is not found"); } const req = await this.parseRequest(data); try { await this.permissionVerify.verify(req, api); } catch (e) { this.logger.error("verify error", { api: data.api }, Logger.E(e)); throw e; } return api.api.call(this, req, sender); } // 解析请求 async parseRequest(data: MessageRequest): Promise { const script = await this.scriptDAO.get(data.uuid); if (!script) { throw new Error("script is not found"); } const req: Request = data; req.script = script; return req; } @PermissionVerify.API({ confirm(request: Request) { if (request.params[0] === "store") { return Promise.resolve(true); } const detail = request.params[1]; if (!detail.url && !detail.domain) { return Promise.reject(new Error("there must be one of url or domain")); } let url: URL = {}; if (detail.url) { url = new URL(detail.url); } else { url.host = detail.domain || ""; url.hostname = detail.domain || ""; } let flag = false; if (request.script.metadata.connect) { const { connect } = request.script.metadata; for (let i = 0; i < connect.length; i += 1) { if (url.hostname.endsWith(connect[i])) { flag = true; break; } } } if (!flag) { return Promise.reject(new Error("hostname must be in the definition of connect")); } const metadata: { [key: string]: string } = {}; metadata[i18next.t("script_name")] = i18nName(request.script); metadata[i18next.t("request_domain")] = url.host; return Promise.resolve({ permission: "cookie", permissionValue: url.host, title: i18next.t("access_cookie_content")!, metadata, describe: i18next.t("confirm_script_operation")!, permissionContent: i18next.t("cookie_domain")!, uuid: "", }); }, }) async GM_cookie(request: Request, sender: GetSender) { const param = request.params; if (param.length !== 2) { throw new Error("there must be two parameters"); } const detail = request.params[1]; // url或者域名不能为空 if (detail.url) { detail.url = detail.url.trim(); } if (detail.domain) { detail.domain = detail.domain.trim(); } if (!detail.url && !detail.domain) { throw new Error("there must be one of url or domain"); } // 处理tab的storeid let tabId = sender.getExtMessageSender().tabId; let storeId: string | undefined; if (tabId !== -1) { const stores = await chrome.cookies.getAllCookieStores(); const store = stores.find((val) => val.tabIds.includes(tabId)); if (store) { storeId = store.id; } } switch (param[0]) { case "list": { return chrome.cookies.getAll({ domain: detail.domain, name: detail.name, path: detail.path, secure: detail.secure, session: detail.session, url: detail.url, storeId: storeId, }); } case "delete": { if (!detail.url || !detail.name) { throw new Error("delete operation must have url and name"); } await chrome.cookies.remove({ name: detail.name, url: detail.url, storeId: storeId, }); break; } case "set": { if (!detail.url || !detail.name) { throw new Error("set operation must have name and value"); } await chrome.cookies.set({ url: detail.url, name: detail.name, domain: detail.domain, value: detail.value, expirationDate: detail.expirationDate, path: detail.path, httpOnly: detail.httpOnly, secure: detail.secure, storeId: storeId, }); break; } default: { throw new Error("action can only be: get, set, delete, store"); } } } @PermissionVerify.API() GM_log(request: Request): Promise { const message = request.params[0]; const level = request.params[1] || "info"; const labels = request.params[2] || {}; LoggerCore.logger(labels).log(level, message, { uuid: request.uuid, name: request.script.name, component: "GM_log", }); return Promise.resolve(true); } @PermissionVerify.API() async GM_setValue(request: Request, sender: GetSender) { if (!request.params || request.params.length !== 2) { throw new Error("param is failed"); } const [key, value] = request.params; await this.value.setValue(request.script.uuid, key, value, { runFlag: request.runFlag, tabId: sender.getSender().tab?.id, }); } @PermissionVerify.API() CAT_userConfig(request: Request) { chrome.tabs.create({ url: `/src/options.html#/?userConfig=${request.uuid}`, active: true, }); } @PermissionVerify.API({ confirm: (request: Request) => { const [action, details] = request.params; if (action === "config") { return Promise.resolve(true); } const dir = details.baseDir ? details.baseDir : request.script.uuid; const metadata: { [key: string]: string } = {}; metadata[i18next.t("script_name")] = i18nName(request.script); return Promise.resolve({ permission: "file_storage", permissionValue: dir, title: i18next.t("script_operation_title"), metadata, describe: i18next.t("script_operation_description", { dir }), wildcard: false, permissionContent: i18next.t("script_permission_content"), } as ConfirmParam); }, }) async CAT_fileStorage(request: Request, sender: GetSender): Promise<{ action: string; data: any } | boolean> { const [action, details] = request.params; if (action === "config") { chrome.tabs.create({ url: `/src/options.html#/setting`, active: true, }); return true; } const fsConfig = await this.systemConfig.getCatFileStorage(); if (fsConfig.status === "unset") { return { action: "error", data: { code: 1, error: "file storage is unset" } }; } if (fsConfig.status === "error") { return { action: "error", data: { code: 2, error: "file storage is error" } }; } let fs: FileSystem; const baseDir = `ScriptCat/app/${details.baseDir ? details.baseDir : request.script.uuid}`; try { fs = await FileSystemFactory.create(fsConfig.filesystem, fsConfig.params[fsConfig.filesystem]); await FileSystemFactory.mkdirAll(fs, baseDir); fs = await fs.openDir(baseDir); } catch (e: any) { if (isWarpTokenError(e)) { fsConfig.status = "error"; this.systemConfig.setCatFileStorage(fsConfig); return { action: "error", data: { code: 2, error: e.error.message } }; } return { action: "error", data: { code: 8, error: e.message } }; } switch (action) { case "list": try { const list = await fs.list(); list.forEach((file) => { (file).absPath = file.path; file.path = joinPath(file.path.substring(file.path.indexOf(baseDir) + baseDir.length)); }); return { action: "onload", data: list }; } catch (e: any) { return { action: "error", data: { code: 3, error: e.message } }; } case "upload": try { const w = await fs.create(details.path); await w.write(await (await fetch(details.data)).blob()); return { action: "onload", data: true }; } catch (e: any) { return { action: "error", data: { code: 4, error: e.message } }; } case "download": try { const info = details.file; fs = await fs.openDir(`${info.path}`); const r = await fs.open({ fsid: (info).fsid, name: info.name, path: info.absPath, size: info.size, digest: info.digest, createtime: info.createtime, updatetime: info.updatetime, }); const blob = await r.read("blob"); const url = URL.createObjectURL(blob); setTimeout(() => { URL.revokeObjectURL(url); }, 6000); return { action: "onload", data: url }; } catch (e: any) { return { action: "error", data: { code: 5, error: e.message } }; } break; case "delete": try { await fs.delete(`${details.path}`); return { action: "onload", data: true }; } catch (e: any) { return { action: "error", data: { code: 6, error: e.message } }; } default: throw new Error("action is not supported"); } } // 根据header生成dnr规则 async buildDNRRule(reqeustId: number, params: GMSend.XHRDetails): Promise<{ [key: string]: string }> { // 检查是否有unsafe header,有则生成dnr规则 const headers = params.headers; if (!headers) { return {}; } const requestHeaders = [ { header: "X-Scriptcat-GM-XHR-Request-Id", operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE, }, ] as chrome.declarativeNetRequest.ModifyHeaderInfo[]; Object.keys(headers).forEach((key) => { const lowKey = key.toLowerCase(); if (headers[key]) { if (unsafeHeaders[lowKey] || lowKey.startsWith("sec-") || lowKey.startsWith("proxy-")) { if (headers[key]) { requestHeaders.push({ header: key, operation: chrome.declarativeNetRequest.HeaderOperation.SET, value: headers[key], }); } delete headers[key]; } } else { requestHeaders.push({ header: key, operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE, }); delete headers[key]; } }); // 判断是否是anonymous if (params.anonymous) { // 如果是anonymous,并且有cookie,则设置为自定义的cookie if (params.cookie) { requestHeaders.push({ header: "cookie", operation: chrome.declarativeNetRequest.HeaderOperation.SET, value: params.cookie, }); } else { // 否则删除cookie requestHeaders.push({ header: "cookie", operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE, }); } } else if (params.cookie) { // 否则正常携带cookie header headers["cookie"] = params.cookie; } const ruleId = reqeustId; const rule = {} as chrome.declarativeNetRequest.Rule; rule.id = ruleId; rule.action = { type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS, requestHeaders: requestHeaders, }; rule.priority = 1; const tabs = await chrome.tabs.query({}); const excludedTabIds: number[] = []; tabs.forEach((tab) => { if (tab.id) { excludedTabIds.push(tab.id); } }); rule.condition = { resourceTypes: [chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST], urlFilter: params.url, requestMethods: [(params.method || "GET").toLowerCase() as chrome.declarativeNetRequest.RequestMethod], excludedTabIds: excludedTabIds, }; await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [ruleId], addRules: [rule], }); return headers; } gmXhrHeadersReceived: EventEmitter = new EventEmitter(); dealFetch(config: GMSend.XHRDetails, response: Response, readyState: 0 | 1 | 2 | 3 | 4) { let respHeader = ""; response.headers.forEach((value, key) => { respHeader += `${key}: ${value}\n`; }); const respond: GMTypes.XHRResponse = { finalUrl: response.url || config.url, readyState, status: response.status, statusText: response.statusText, responseHeaders: respHeader, responseType: config.responseType, }; return respond; } CAT_fetch(config: GMSend.XHRDetails, con: GetSender, resultParam: { requestId: number; responseHeader: string }) { const { url } = config; let connect = con.getConnect(); return fetch(url, { method: config.method || "GET", body: config.data, headers: config.headers, }).then((resp) => { const send = this.dealFetch(config, resp, 1); const reader = resp.body?.getReader(); if (!reader) { throw new Error("read is not found"); } const _this = this; reader.read().then(function read({ done, value }) { if (done) { const data = _this.dealFetch(config, resp, 4); data.responseHeaders = resultParam.responseHeader || data.responseHeaders; connect.sendMessage({ action: "onreadystatechange", data: data, }); connect.sendMessage({ action: "onload", data: data, }); connect.sendMessage({ action: "onloadend", data: data, }); } else { connect.sendMessage({ action: "onstream", data: Array.from(value), }); reader.read().then(read); } }); send.responseHeaders = resultParam.responseHeader || send.responseHeaders; connect.sendMessage({ action: "onloadstart", data: send, }); send.readyState = 2; connect.sendMessage({ action: "onreadystatechange", data: send, }); }); } @PermissionVerify.API({ confirm: async (request: Request) => { const config = request.params[0]; const url = new URL(config.url); if (request.script.metadata.connect) { const { connect } = request.script.metadata; for (let i = 0; i < connect.length; i += 1) { if (url.hostname.endsWith(connect[i])) { return Promise.resolve(true); } } } const metadata: { [key: string]: string } = {}; metadata[i18next.t("script_name")] = i18nName(request.script); metadata[i18next.t("request_domain")] = url.hostname; metadata[i18next.t("request_url")] = config.url; return Promise.resolve({ permission: "cors", permissionValue: url.hostname, title: i18next.t("script_accessing_cross_origin_resource"), metadata, describe: i18next.t("confirm_operation_description"), wildcard: true, permissionContent: i18next.t("domain"), } as ConfirmParam); }, }) async GM_xmlhttpRequest(request: Request, sender: GetSender) { if (request.params.length === 0) { throw new Error("param is failed"); } const params = request.params[0] as GMSend.XHRDetails; // 先处理unsafe hearder // 关联自己生成的请求id与chrome.webRequest的请求id const requestId = 10000 + (await incr(Cache.getInstance(), "gmXhrRequestId", 1)); // 添加请求header if (!params.headers) { params.headers = {}; } params.headers["X-Scriptcat-GM-XHR-Request-Id"] = requestId.toString(); params.headers = await this.buildDNRRule(requestId, request.params[0]); let resultParam = { requestId, responseHeader: "", }; // 等待response this.gmXhrHeadersReceived.addListener( "headersReceived:" + requestId, (details: chrome.webRequest.WebResponseHeadersDetails) => { details.responseHeaders?.forEach((header) => { resultParam.responseHeader += header.name + ": " + header.value + "\n"; }); this.gmXhrHeadersReceived.removeAllListeners("headersReceived:" + requestId); } ); if (params.responseType === "stream" || params.fetch || params.redirect) { // 只有fetch支持ReadableStream、redirect这些,直接使用fetch return this.CAT_fetch(params, sender, resultParam); } // 再发送到offscreen, 处理请求 const offscreenCon = await connect(this.send, "offscreen/gmApi/xmlHttpRequest", request.params[0]); offscreenCon.onMessage((msg: { action: string; data: any }) => { // 发送到content // 替换msg.data.responseHeaders msg.data.responseHeaders = resultParam.responseHeader || msg.data.responseHeaders; sender.getConnect().sendMessage(msg); }); sender.getConnect().onDisconnect(() => { // 关闭连接 offscreenCon.disconnect(); }); } @PermissionVerify.API() GM_registerMenuCommand(request: Request, sender: GetSender) { const [id, name, accessKey] = request.params; // 触发菜单注册, 在popup中处理 this.mq.emit("registerMenuCommand", { uuid: request.script.uuid, id: id, name: name, accessKey: accessKey, tabId: sender.getSender().tab?.id || -1, frameId: sender.getSender().frameId, documentId: sender.getSender().documentId, }); } @PermissionVerify.API() GM_unregisterMenuCommand(request: Request, sender: GetSender) { const [id] = request.params; // 触发菜单取消注册, 在popup中处理 this.mq.emit("unregisterMenuCommand", { uuid: request.script.uuid, id: id, tabId: sender.getSender().tab?.id || -1, frameId: sender.getSender().frameId, }); } @PermissionVerify.API({}) async GM_openInTab(request: Request, sender: GetSender) { const url = request.params[0]; const options = request.params[1] || {}; if (options.useOpen === true) { // 发送给offscreen页面处理 const ok = await sendMessage(this.send, "offscreen/gmApi/openInTab", { url }); if (ok) { // 由于window.open强制在前台打开标签,因此获取状态为{ active:true }的标签即为新标签 const [tab] = await chrome.tabs.query({ active: true }); await Cache.getInstance().set(`GM_openInTab:${tab.id}`, { uuid: request.uuid, sender: sender.getExtMessageSender(), }); return tab.id; } else { // 当新tab被浏览器阻止时window.open()会返回null 视为已经关闭 // 似乎在Firefox中禁止在background页面使用window.open(),强制返回null return false; } } else { const tab = await chrome.tabs.create({ url, active: options.active }); await Cache.getInstance().set(`GM_openInTab:${tab.id}`, { uuid: request.uuid, sender: sender.getExtMessageSender(), }); return tab.id; } } @PermissionVerify.API({ link: "GM_openInTab", }) async GM_closeInTab(request: Request): Promise { try { await chrome.tabs.remove(request.params[0]); } catch (e) { this.logger.error("GM_closeInTab", Logger.E(e)); } return Promise.resolve(true); } @PermissionVerify.API({}) GM_getTab(request: Request, sender: GetSender) { return Cache.getInstance() .tx(`GM_getTab:${request.uuid}`, async (tabData: { [key: number]: any }) => { return tabData || {}; }) .then((data) => { return data[sender.getExtMessageSender().tabId]; }); } @PermissionVerify.API() GM_saveTab(request: Request, sender: GetSender) { const data = request.params[0]; const tabId = sender.getExtMessageSender().tabId; return Cache.getInstance() .tx(`GM_getTab:${request.uuid}`, (tabData: { [key: number]: any }) => { tabData = tabData || {}; tabData[tabId] = data; return Promise.resolve(tabData); }) .then(() => true); } @PermissionVerify.API() GM_getTabs(request: Request) { return Cache.getInstance().tx(`GM_getTab:${request.uuid}`, async (tabData: { [key: number]: any }) => { return tabData || {}; }); } @PermissionVerify.API({}) GM_notification(request: Request, sender: GetSender) { if (request.params.length === 0) { throw new Error("param is failed"); } const details: GMTypes.NotificationDetails = request.params[0]; const options: chrome.notifications.NotificationOptions = { title: details.title || "ScriptCat", message: details.text || "无消息内容", iconUrl: details.image || getIcon(request.script) || chrome.runtime.getURL("assets/logo.png"), type: isFirefox() || details.progress === undefined ? "basic" : "progress", }; if (!isFirefox()) { options.silent = details.silent; options.buttons = details.buttons; } options.progress = options.progress && parseInt(details.progress as any, 10); return new Promise((resolve) => { chrome.notifications.create(options, (notificationId) => { Cache.getInstance().set(`GM_notification:${notificationId}`, { uuid: request.script.uuid, details: details, sender: sender.getExtMessageSender(), }); if (details.timeout) { setTimeout(() => { chrome.notifications.clear(notificationId); Cache.getInstance().del(`GM_notification:${notificationId}`); }, details.timeout); } resolve(notificationId); }); }); } @PermissionVerify.API({ link: "GM_notification", }) GM_closeNotification(request: Request) { if (request.params.length === 0) { throw new Error("param is failed"); } const [notificationId] = request.params; Cache.getInstance().del(`GM_notification:${notificationId}`); chrome.notifications.clear(notificationId); } @PermissionVerify.API({ link: "GM_notification", }) GM_updateNotification(request: Request) { if (isFirefox()) { throw new Error("firefox does not support this method"); } const id = request.params[0]; const details: GMTypes.NotificationDetails = request.params[1]; const options: chrome.notifications.NotificationOptions = { title: details.title, message: details.text, iconUrl: details.image, type: details.progress === undefined ? "basic" : "progress", silent: details.silent, progress: details.progress && parseInt(details.progress as any, 10), }; chrome.notifications.update(id, options); } @PermissionVerify.API() async GM_download(request: Request, sender: GetSender) { const params = request.params[0]; // blob本地文件直接下载 if (params.url.startsWith("blob:")) { chrome.downloads.download( { url: params.url, saveAs: params.saveAs, filename: params.name, }, () => { sender.getConnect().sendMessage({ event: "onload" }); } ); return; } // 使用xhr下载blob,再使用download api创建下载 const EE = new EventEmitter(); const mockConnect = new MockMessageConnect(EE); EE.addListener("message", (data: any) => { const xhr = data.data; const respond: any = { finalUrl: xhr.url, readyState: xhr.readyState, status: xhr.status, statusText: xhr.statusText, responseHeaders: xhr.responseHeaders, }; switch (data.action) { case "onload": sender.getConnect().sendMessage({ action: "onload", data: respond, }); chrome.downloads.download({ url: xhr.response, saveAs: params.saveAs, filename: params.name, }); break; case "onerror": sender.getConnect().sendMessage({ action: "onerror", data: respond, }); break; case "onprogress": respond.done = xhr.DONE; respond.lengthComputable = xhr.lengthComputable; respond.loaded = xhr.loaded; respond.total = xhr.total; respond.totalSize = xhr.total; sender.getConnect().sendMessage({ action: "onprogress", data: respond, }); break; case "ontimeout": sender.getConnect().sendMessage({ action: "ontimeout", }); break; } }); // 处理参数问题 request.params[0] = { method: params.method || "GET", url: params.url, headers: params.headers, timeout: params.timeout, cookie: params.cookie, anonymous: params.anonymous, responseType: "blob", } as GMSend.XHRDetails; return this.GM_xmlhttpRequest(request, new GetSender(mockConnect)); } @PermissionVerify.API() async GM_setClipboard(request: Request) { let [data, type] = request.params; type = type || "text/plain"; await sendMessage(this.send, "offscreen/gmApi/setClipboard", { data, type }); } handlerNotification() { const send = async (event: string, notificationId: string, params?: any) => { const ret = (await Cache.getInstance().get(`GM_notification:${notificationId}`)) as NotificationData; if (ret) { this.runtime.emitEventToTab(ret.sender, { event: "GM_notification", eventId: notificationId, uuid: ret.uuid, data: { event, params, }, }); } }; chrome.notifications.onClosed.addListener((notificationId, byUser) => { send("close", notificationId, { byUser, }); Cache.getInstance().del(`GM_notification:${notificationId}`); }); chrome.notifications.onClicked.addListener((notificationId) => { send("click", notificationId); }); chrome.notifications.onButtonClicked.addListener((notificationId, index) => { send("buttonClick", notificationId, { index: index, }); }); } // 处理GM_xmlhttpRequest请求 handlerGmXhr() { chrome.webRequest.onBeforeSendHeaders.addListener( (details) => { if (details.tabId === -1) { // 判断是否存在X-Scriptcat-GM-XHR-Request-Id // 讲请求id与chrome.webRequest的请求id关联 if (details.requestHeaders) { const requestId = details.requestHeaders.find((header) => header.name === "X-Scriptcat-GM-XHR-Request-Id"); if (requestId) { Cache.getInstance().set("gmXhrRequest:" + details.requestId, requestId.value); } } } }, { urls: [""], types: ["xmlhttprequest"], }, ["requestHeaders", "extraHeaders"] ); chrome.webRequest.onHeadersReceived.addListener( (details) => { if (details.tabId === -1) { // 判断请求是否与gmXhrRequest关联 Cache.getInstance() .get("gmXhrRequest:" + details.requestId) .then((requestId) => { if (requestId) { this.gmXhrHeadersReceived.emit("headersReceived:" + requestId, details); // 删除关联与DNR Cache.getInstance().del("gmXhrRequest:" + details.requestId); chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [parseInt(requestId)], }); } }); } }, { urls: [""], types: ["xmlhttprequest"], }, ["responseHeaders", "extraHeaders"] ); } start() { this.group.on("gmApi", this.handlerRequest.bind(this)); this.handlerGmXhr(); this.handlerNotification(); chrome.tabs.onRemoved.addListener(async (tabId) => { // 处理GM_openInTab关闭事件 const sender = (await Cache.getInstance().get(`GM_openInTab:${tabId}`)) as { uuid: string; sender: ExtMessageSender; }; if (sender) { this.runtime.emitEventToTab(sender.sender, { event: "GM_openInTab", eventId: tabId.toString(), uuid: sender.uuid, data: { event: "onclose", tabId: tabId, }, }); Cache.getInstance().del(`GM_openInTab:${tabId}`); } }); } }