refactor(extension): class-based command registry + modular src layout
Restructure the MV3 background worker from a monolithic core.ts/index.ts
into a class-based command architecture. Behavior is identical — the 83
registered commands dispatch byte-for-byte the same as before.
Structure
- One class per command group, each extending CommandGroup and exporting a
`commands` map keyed by the full command id ("tabs.close"). Groups:
Navigation, TabsMutation, TabsQuery, Groups, Windows, Dom (dom/extract/
page), BrowserData (storage/cookies), Session (session/clients + autosave
+ lazy-tab activation), Perf (perf + jobs.status/cancel), Extension.
- CommandRegistry merges the group maps (throws on duplicate ids), routes
background specs to JobManager and paginates array results via
makePagedData. JobManager owns the job map + lifecycle. NativeConnection
owns the native-port lifecycle and the inbound message router.
- index.ts is now thin wiring: JobManager -> ctx -> assembleRegistry ->
onActivated -> NativeConnection.start().
- Infra classes live in classes/ (PascalCase, file = class name); command
groups in commands/; shared helpers split out of core.ts into core/
(errors, throttle, scripting, tab-helpers, group-helpers, storage); all
types moved into types/ (json, jobs, session, tabs, messages,
command-args) behind a barrel.
DRY cleanup
- resolveTabUrl(tabId) and assertScriptableUrl(url, action) collapse the
tab/URL-guard boilerplate duplicated across dom.ts and browser-data.ts.
- processInBatches() centralizes the throttled, cancellable batch loop
shared by tabs.close, group.close and tabs.merge_windows.
- captureCurrentSession() dedups the snapshot-and-signature block shared by
session.save and the autosave path.
- DomArgs type alias replaces 21 inline ContentArgs & { tabId? } copies.
- Drop fetchTabHtml's redundant retry loop (executeScript already retries
transient frame/tab errors), a dead tabInfo import, and two stale
comments referencing a removed asArgs helper.
Type safety & tests
- Full noImplicitAny; no `any`/`unknown` annotations remain in src.
- JS unit-test harness using node --test + node:assert (zero new deps),
bundled via the existing esbuild. Covers JobManager retention/lifecycle
and the autosave listener-wiring/debounce with an in-memory chrome mock.
- The structural pytest checks track the new file homes and the centralized
processInBatches helper.
Verification: npm run check:extension green (tsc + esbuild 84.5kb +
node --check + 18 JS tests); uv run pytest -q -> 409 passed, 105 skipped.
No version bump.
This commit is contained in:
+159
-255
@@ -1,262 +1,166 @@
|
||||
// @ts-nocheck
|
||||
import { executeScript, getActiveTab, getAliases, getLargeOperationThrottle, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
export async function tabsList() {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const aliases = await getAliases();
|
||||
const tabs = [];
|
||||
for (const w of windows) {
|
||||
for (const t of w.tabs) {
|
||||
tabs.push({
|
||||
...tabInfo(t),
|
||||
windowAlias: aliases[t.windowId] || null,
|
||||
pinned: t.pinned,
|
||||
favIconUrl: t.favIconUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
import { asTabIds, getActiveTab, getLargeOperationThrottle, processInBatches, resolveTabForDirectAction, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core';
|
||||
import { CommandGroup } from '../classes/CommandGroup';
|
||||
import type { CommandEntry } from '../classes/CommandGroup';
|
||||
import type { TabsCloseArgs, TabsMoveArgs, TabIdArgs, TabsSortArgs, TabsMergeWindowsArgs, TabsScreenshotArgs } from '../types';
|
||||
|
||||
export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.close", async () => {
|
||||
let toClose = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
||||
updateJobProgress(__job, { phase: "closing tabs", current: 0, total: toClose.length });
|
||||
for (let i = 0; i < toClose.length; i += throttle.batchSize) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.remove(toClose.slice(i, i + throttle.batchSize));
|
||||
updateJobProgress(__job, { phase: "closing tabs", current: Math.min(i + throttle.batchSize, toClose.length), total: toClose.length });
|
||||
await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs);
|
||||
}
|
||||
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) {
|
||||
const moveProps = {};
|
||||
if (windowId != null) moveProps.windowId = windowId;
|
||||
|
||||
if (forward || backward) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
||||
else moveProps.index = Math.max(0, tab.index - 1);
|
||||
} else if (index != null) {
|
||||
moveProps.index = index;
|
||||
} else {
|
||||
moveProps.index = -1;
|
||||
}
|
||||
|
||||
await chrome.tabs.move(tabId, moveProps);
|
||||
if (groupId != null) {
|
||||
await chrome.tabs.group({ tabIds: [tabId], groupId });
|
||||
}
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function tabsActive({ tabId }) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
export async function tabsActiveInWindow({ windowId }) {
|
||||
const activeTabs = await chrome.tabs.query({ windowId, active: true });
|
||||
const tab = activeTabs[0];
|
||||
if (!tab) {
|
||||
throw new Error(`No active tab found for window ${windowId}`);
|
||||
}
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
export async function tabsStatus({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
return tabInfo(tab);
|
||||
}
|
||||
|
||||
export async function tabsFilter({ pattern }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function tabsCount({ pattern }) {
|
||||
const all = await chrome.tabs.query({});
|
||||
if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length;
|
||||
return all.length;
|
||||
}
|
||||
|
||||
export async function tabsQuery({ search }) {
|
||||
const q = search.toLowerCase();
|
||||
const all = await chrome.tabs.query({});
|
||||
return all.filter(t =>
|
||||
(t.url && t.url.toLowerCase().includes(q)) ||
|
||||
(t.title && t.title.toLowerCase().includes(q))
|
||||
).map(tabInfo);
|
||||
}
|
||||
|
||||
export async function tabsHtml({ tabId }) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||
if (isBrowserErrorUrl(tabUrl)) {
|
||||
return "";
|
||||
}
|
||||
if (!isScriptableUrl(tabUrl)) {
|
||||
throw new Error(`Cannot get HTML of ${tabUrl} — navigate to a regular web page first`);
|
||||
}
|
||||
try {
|
||||
const results = await executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.documentElement.outerHTML,
|
||||
});
|
||||
return results[0]?.result || "";
|
||||
} catch (e) {
|
||||
if (isErrorPageScriptError(e)) return "";
|
||||
const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id"));
|
||||
if (i < 2 && transient) {
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function tabsDedupe(args = {}) {
|
||||
return tabsClose({ ...args, duplicates: true });
|
||||
}
|
||||
|
||||
export async function tabsSort({ by, gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.sort", async () => {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
|
||||
for (const w of windows) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue;
|
||||
const throttle = await getLargeOperationThrottle(sorted.length, gentleMode);
|
||||
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsMergeWindows({ gentleMode, __job } = {}) {
|
||||
return runLargeOperation("tabs.merge_windows", async () => {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
||||
for (const w of all) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||
for (let i = 0; i < ids.length; i += throttle.batchSize) {
|
||||
throwIfJobCancelled(__job);
|
||||
const chunk = ids.slice(i, i + throttle.batchSize);
|
||||
await chrome.tabs.move(chunk, { windowId: current.id, index: -1 });
|
||||
moved += chunk.length;
|
||||
updateJobProgress(__job, { phase: "merging windows", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, throttle.batchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
export async function tabsPin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
export async function tabsUnpin({ tabId }) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
export async function tabsScreenshot({ tabId, format = "png", quality } = {}) {
|
||||
let windowId;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
windowId = tab.windowId;
|
||||
}
|
||||
const opts = { format };
|
||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
const deadline = Date.now() + timeout;
|
||||
const regex = new RegExp(pattern);
|
||||
let lastUrl = tab.url || tab.pendingUrl || "";
|
||||
let lastStatus = tab.status || "unknown";
|
||||
|
||||
const matches = (url) => {
|
||||
regex.lastIndex = 0;
|
||||
return Boolean(url && regex.test(url));
|
||||
export class TabsMutationCommands extends CommandGroup {
|
||||
readonly namespace = "tabs";
|
||||
readonly commands: Record<string, CommandEntry> = {
|
||||
"tabs.close": { background: true, run: (a: TabsCloseArgs) => this.tabsClose(a) },
|
||||
"tabs.move": (a: TabsMoveArgs) => this.tabsMove(a),
|
||||
"tabs.active": (a: TabIdArgs) => this.tabsActive(a),
|
||||
"tabs.dedupe": { background: true, run: (a: TabsCloseArgs) => this.tabsDedupe(a) },
|
||||
"tabs.sort": { background: true, run: (a: TabsSortArgs) => this.tabsSort(a) },
|
||||
"tabs.merge_windows": { background: true, run: (a: TabsMergeWindowsArgs) => this.tabsMergeWindows(a) },
|
||||
"tabs.mute": (a: TabIdArgs) => this.tabsMute(a),
|
||||
"tabs.unmute": (a: TabIdArgs) => this.tabsUnmute(a),
|
||||
"tabs.pin": (a: TabIdArgs) => this.tabsPin(a),
|
||||
"tabs.unpin": (a: TabIdArgs) => this.tabsUnpin(a),
|
||||
"tabs.screenshot": (a: TabsScreenshotArgs) => this.tabsScreenshot(a),
|
||||
};
|
||||
if (matches(lastUrl)) return tabInfo(tab);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const t = await chrome.tabs.get(tab.id);
|
||||
lastUrl = t.url || t.pendingUrl || "";
|
||||
lastStatus = t.status || "unknown";
|
||||
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
|
||||
if (isBrowserErrorUrl(t.url || "")) {
|
||||
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
private async tabsClose({ tabId, inactive, duplicates, gentleMode, __job }: TabsCloseArgs = {}) {
|
||||
return runLargeOperation("tabs.close", async () => {
|
||||
let toClose: number[] = [];
|
||||
if (duplicates) {
|
||||
const all = await chrome.tabs.query({});
|
||||
const seen = new Set();
|
||||
for (const t of all) {
|
||||
if (!t.url) continue;
|
||||
if (seen.has(t.url)) toClose.push(t.id);
|
||||
else seen.add(t.url);
|
||||
}
|
||||
} else if (inactive) {
|
||||
const all = await chrome.tabs.query({});
|
||||
toClose = all.filter(t => !t.active).map(t => t.id);
|
||||
} else if (tabId) {
|
||||
toClose = [tabId];
|
||||
}
|
||||
const throttle = await getLargeOperationThrottle(toClose.length, gentleMode);
|
||||
await processInBatches(toClose, throttle, batch => chrome.tabs.remove(batch), { job: __job, phase: "closing tabs" });
|
||||
return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible };
|
||||
});
|
||||
}
|
||||
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
|
||||
}
|
||||
|
||||
export async function tabsMute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||
await chrome.tabs.update(tab.id, { muted: true });
|
||||
return { tabId: tab.id, muted: true };
|
||||
}
|
||||
private async tabsMove({ tabId, groupId, windowId, index, forward, backward }: TabsMoveArgs) {
|
||||
const moveProps: Partial<chrome.tabs.MoveProperties> = {};
|
||||
if (windowId != null) moveProps.windowId = windowId;
|
||||
|
||||
export async function tabsUnmute({ tabId }) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
||||
await chrome.tabs.update(tab.id, { muted: false });
|
||||
return { tabId: tab.id, muted: false };
|
||||
}
|
||||
if (forward || backward) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal
|
||||
else moveProps.index = Math.max(0, tab.index - 1);
|
||||
} else if (index != null) {
|
||||
moveProps.index = index;
|
||||
} else {
|
||||
moveProps.index = -1;
|
||||
}
|
||||
|
||||
// `index` is always assigned by one of the branches above before this call.
|
||||
await chrome.tabs.move(tabId, moveProps as chrome.tabs.MoveProperties);
|
||||
if (groupId != null) {
|
||||
await chrome.tabs.group({ tabIds: asTabIds([tabId]), groupId });
|
||||
}
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
private async tabsActive({ tabId }: TabIdArgs) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
private async tabsDedupe(args: TabsCloseArgs = {}) {
|
||||
return this.tabsClose({ ...args, duplicates: true });
|
||||
}
|
||||
|
||||
private async tabsSort({ by, gentleMode, __job }: TabsSortArgs = {}) {
|
||||
return runLargeOperation("tabs.sort", async () => {
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs });
|
||||
for (const w of windows) {
|
||||
const sorted = [...w.tabs].sort((a, b) => {
|
||||
if (by === "title") return (a.title || "").localeCompare(b.title || "");
|
||||
if (by === "time") return a.id - b.id; // lower id = opened earlier
|
||||
// domain (default)
|
||||
const da = new URL(a.url || a.pendingUrl || "about:blank").hostname;
|
||||
const db = new URL(b.url || b.pendingUrl || "about:blank").hostname;
|
||||
return da.localeCompare(db);
|
||||
});
|
||||
if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue;
|
||||
const throttle = await getLargeOperationThrottle(sorted.length, gentleMode);
|
||||
const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
throwIfJobCancelled(__job);
|
||||
await chrome.tabs.move(sorted[i].id, { index: i });
|
||||
moved++;
|
||||
updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs });
|
||||
await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs);
|
||||
}
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
|
||||
return runLargeOperation("tabs.merge_windows", async () => {
|
||||
const current = await chrome.windows.getCurrent();
|
||||
const all = await chrome.windows.getAll({ populate: true });
|
||||
let moved = 0;
|
||||
const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0);
|
||||
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
||||
for (const w of all) {
|
||||
if (w.id === current.id) continue;
|
||||
const ids = w.tabs.map(t => t.id);
|
||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||
moved = await processInBatches(ids, throttle,
|
||||
batch => chrome.tabs.move(batch, { windowId: current.id, index: -1 }),
|
||||
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
|
||||
}
|
||||
return { moved };
|
||||
});
|
||||
}
|
||||
|
||||
private async tabsPin({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: true });
|
||||
return { tabId: tab.id, pinned: true };
|
||||
}
|
||||
|
||||
private async tabsUnpin({ tabId }: TabIdArgs) {
|
||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||
await chrome.tabs.update(tab.id, { pinned: false });
|
||||
return { tabId: tab.id, pinned: false };
|
||||
}
|
||||
|
||||
private async tabsScreenshot({ tabId, format = "png", quality }: TabsScreenshotArgs = {}) {
|
||||
let windowId: number | undefined;
|
||||
if (tabId) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
windowId = tab.windowId;
|
||||
} else {
|
||||
const tab = await getActiveTab();
|
||||
windowId = tab.windowId;
|
||||
}
|
||||
const opts: chrome.extensionTypes.ImageDetails = { format: format as chrome.extensionTypes.ImageFormat };
|
||||
if (format === "jpeg" && quality != null) opts.quality = quality;
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, opts);
|
||||
return { dataUrl, format };
|
||||
}
|
||||
|
||||
private async tabsMute({ tabId }: TabIdArgs) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "mute");
|
||||
await chrome.tabs.update(tab.id, { muted: true });
|
||||
return { tabId: tab.id, muted: true };
|
||||
}
|
||||
|
||||
private async tabsUnmute({ tabId }: TabIdArgs) {
|
||||
const tab = await resolveTabForDirectAction(tabId, "unmute");
|
||||
await chrome.tabs.update(tab.id, { muted: false });
|
||||
return { tabId: tab.id, muted: false };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user