SuperCharge Claude Code v1.0.0 - Complete Customization Package
Features: - 30+ Custom Skills (cognitive, development, UI/UX, autonomous agents) - RalphLoop autonomous agent integration - Multi-AI consultation (Qwen) - Agent management system with sync capabilities - Custom hooks for session management - MCP servers integration - Plugin marketplace setup - Comprehensive installation script Components: - Skills: always-use-superpowers, ralph, brainstorming, ui-ux-pro-max, etc. - Agents: 100+ agents across engineering, marketing, product, etc. - Hooks: session-start-superpowers, qwen-consult, ralph-auto-trigger - Commands: /brainstorm, /write-plan, /execute-plan - MCP Servers: zai-mcp-server, web-search-prime, web-reader, zread - Binaries: ralphloop wrapper Installation: ./supercharge.sh
This commit is contained in:
211
skills/dev-browser/extension/__tests__/CDPRouter.test.ts
Normal file
211
skills/dev-browser/extension/__tests__/CDPRouter.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fakeBrowser } from "wxt/testing";
|
||||
import { CDPRouter } from "../services/CDPRouter";
|
||||
import { TabManager } from "../services/TabManager";
|
||||
import type { Logger } from "../utils/logger";
|
||||
import type { ExtensionCommandMessage } from "../utils/types";
|
||||
|
||||
// Mock chrome.debugger since fakeBrowser doesn't include it
|
||||
const mockDebuggerSendCommand = vi.fn();
|
||||
|
||||
vi.stubGlobal("chrome", {
|
||||
...fakeBrowser,
|
||||
debugger: {
|
||||
sendCommand: mockDebuggerSendCommand,
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
onEvent: { addListener: vi.fn(), hasListener: vi.fn() },
|
||||
onDetach: { addListener: vi.fn(), hasListener: vi.fn() },
|
||||
getTargets: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
describe("CDPRouter", () => {
|
||||
let cdpRouter: CDPRouter;
|
||||
let tabManager: TabManager;
|
||||
let mockLogger: Logger;
|
||||
let mockSendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
mockDebuggerSendCommand.mockReset();
|
||||
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
mockSendMessage = vi.fn();
|
||||
|
||||
tabManager = new TabManager({
|
||||
logger: mockLogger,
|
||||
sendMessage: mockSendMessage,
|
||||
});
|
||||
|
||||
cdpRouter = new CDPRouter({
|
||||
logger: mockLogger,
|
||||
tabManager,
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommand", () => {
|
||||
it("should return early for non-forwardCDPCommand methods", async () => {
|
||||
const msg = {
|
||||
id: 1,
|
||||
method: "someOtherMethod" as const,
|
||||
params: { method: "Test.method" },
|
||||
};
|
||||
|
||||
// @ts-expect-error - testing invalid method
|
||||
const result = await cdpRouter.handleCommand(msg);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw error when no tab found for command", async () => {
|
||||
const msg: ExtensionCommandMessage = {
|
||||
id: 1,
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Page.navigate",
|
||||
sessionId: "unknown-session",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(cdpRouter.handleCommand(msg)).rejects.toThrow(
|
||||
"No tab found for method Page.navigate"
|
||||
);
|
||||
});
|
||||
|
||||
it("should find tab by sessionId", async () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
mockDebuggerSendCommand.mockResolvedValue({ result: "ok" });
|
||||
|
||||
const msg: ExtensionCommandMessage = {
|
||||
id: 1,
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Page.navigate",
|
||||
sessionId: "session-1",
|
||||
params: { url: "https://example.com" },
|
||||
},
|
||||
};
|
||||
|
||||
await cdpRouter.handleCommand(msg);
|
||||
|
||||
expect(mockDebuggerSendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 123, sessionId: undefined },
|
||||
"Page.navigate",
|
||||
{ url: "https://example.com" }
|
||||
);
|
||||
});
|
||||
|
||||
it("should find tab via child session", async () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "parent-session",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
tabManager.trackChildSession("child-session", 123);
|
||||
|
||||
mockDebuggerSendCommand.mockResolvedValue({});
|
||||
|
||||
const msg: ExtensionCommandMessage = {
|
||||
id: 1,
|
||||
method: "forwardCDPCommand",
|
||||
params: {
|
||||
method: "Runtime.evaluate",
|
||||
sessionId: "child-session",
|
||||
},
|
||||
};
|
||||
|
||||
await cdpRouter.handleCommand(msg);
|
||||
|
||||
expect(mockDebuggerSendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 123, sessionId: "child-session" },
|
||||
"Runtime.evaluate",
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDebuggerEvent", () => {
|
||||
it("should forward CDP events to relay", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent(
|
||||
{ tabId: 123 },
|
||||
"Page.loadEventFired",
|
||||
{ timestamp: 12345 },
|
||||
sendMessage
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
method: "Page.loadEventFired",
|
||||
params: { timestamp: 12345 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should track child sessions on Target.attachedToTarget", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent(
|
||||
{ tabId: 123 },
|
||||
"Target.attachedToTarget",
|
||||
{ sessionId: "new-child-session", targetInfo: {} },
|
||||
sendMessage
|
||||
);
|
||||
|
||||
expect(tabManager.getParentTabId("new-child-session")).toBe(123);
|
||||
});
|
||||
|
||||
it("should untrack child sessions on Target.detachedFromTarget", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
tabManager.trackChildSession("child-session", 123);
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent(
|
||||
{ tabId: 123 },
|
||||
"Target.detachedFromTarget",
|
||||
{ sessionId: "child-session" },
|
||||
sendMessage
|
||||
);
|
||||
|
||||
expect(tabManager.getParentTabId("child-session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should ignore events for unknown tabs", () => {
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
cdpRouter.handleDebuggerEvent({ tabId: 999 }, "Page.loadEventFired", {}, sendMessage);
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
45
skills/dev-browser/extension/__tests__/StateManager.test.ts
Normal file
45
skills/dev-browser/extension/__tests__/StateManager.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { fakeBrowser } from "wxt/testing";
|
||||
import { StateManager } from "../services/StateManager";
|
||||
|
||||
describe("StateManager", () => {
|
||||
let stateManager: StateManager;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
stateManager = new StateManager();
|
||||
});
|
||||
|
||||
describe("getState", () => {
|
||||
it("should return default inactive state when no stored state", async () => {
|
||||
const state = await stateManager.getState();
|
||||
expect(state).toEqual({ isActive: false });
|
||||
});
|
||||
|
||||
it("should return stored state when available", async () => {
|
||||
await fakeBrowser.storage.local.set({
|
||||
devBrowserActiveState: { isActive: true },
|
||||
});
|
||||
|
||||
const state = await stateManager.getState();
|
||||
expect(state).toEqual({ isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setState", () => {
|
||||
it("should persist state to storage", async () => {
|
||||
await stateManager.setState({ isActive: true });
|
||||
|
||||
const stored = await fakeBrowser.storage.local.get("devBrowserActiveState");
|
||||
expect(stored.devBrowserActiveState).toEqual({ isActive: true });
|
||||
});
|
||||
|
||||
it("should update state from active to inactive", async () => {
|
||||
await stateManager.setState({ isActive: true });
|
||||
await stateManager.setState({ isActive: false });
|
||||
|
||||
const state = await stateManager.getState();
|
||||
expect(state).toEqual({ isActive: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
170
skills/dev-browser/extension/__tests__/TabManager.test.ts
Normal file
170
skills/dev-browser/extension/__tests__/TabManager.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fakeBrowser } from "wxt/testing";
|
||||
import { TabManager } from "../services/TabManager";
|
||||
import type { Logger } from "../utils/logger";
|
||||
|
||||
describe("TabManager", () => {
|
||||
let tabManager: TabManager;
|
||||
let mockLogger: Logger;
|
||||
let mockSendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeBrowser.reset();
|
||||
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
mockSendMessage = vi.fn();
|
||||
|
||||
tabManager = new TabManager({
|
||||
logger: mockLogger,
|
||||
sendMessage: mockSendMessage,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBySessionId", () => {
|
||||
it("should return undefined when no tabs exist", () => {
|
||||
const result = tabManager.getBySessionId("session-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should find tab by session ID", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const result = tabManager.getBySessionId("session-1");
|
||||
expect(result).toEqual({
|
||||
tabId: 123,
|
||||
tab: {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getByTargetId", () => {
|
||||
it("should return undefined when no tabs exist", () => {
|
||||
const result = tabManager.getByTargetId("target-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should find tab by target ID", () => {
|
||||
tabManager.set(456, {
|
||||
sessionId: "session-2",
|
||||
targetId: "target-2",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
const result = tabManager.getByTargetId("target-2");
|
||||
expect(result).toEqual({
|
||||
tabId: 456,
|
||||
tab: {
|
||||
sessionId: "session-2",
|
||||
targetId: "target-2",
|
||||
state: "connected",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("child sessions", () => {
|
||||
it("should track child sessions", () => {
|
||||
tabManager.trackChildSession("child-session-1", 123);
|
||||
expect(tabManager.getParentTabId("child-session-1")).toBe(123);
|
||||
});
|
||||
|
||||
it("should untrack child sessions", () => {
|
||||
tabManager.trackChildSession("child-session-1", 123);
|
||||
tabManager.untrackChildSession("child-session-1");
|
||||
expect(tabManager.getParentTabId("child-session-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("set/get/has", () => {
|
||||
it("should set and get tab info", () => {
|
||||
tabManager.set(789, { state: "connecting" });
|
||||
expect(tabManager.get(789)).toEqual({ state: "connecting" });
|
||||
expect(tabManager.has(789)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return undefined for unknown tabs", () => {
|
||||
expect(tabManager.get(999)).toBeUndefined();
|
||||
expect(tabManager.has(999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detach", () => {
|
||||
it("should send detached event and remove tab", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
tabManager.detach(123, false);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1", targetId: "target-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(tabManager.has(123)).toBe(false);
|
||||
});
|
||||
|
||||
it("should clean up child sessions when detaching", () => {
|
||||
tabManager.set(123, {
|
||||
sessionId: "session-1",
|
||||
targetId: "target-1",
|
||||
state: "connected",
|
||||
});
|
||||
tabManager.trackChildSession("child-1", 123);
|
||||
tabManager.trackChildSession("child-2", 123);
|
||||
|
||||
tabManager.detach(123, false);
|
||||
|
||||
expect(tabManager.getParentTabId("child-1")).toBeUndefined();
|
||||
expect(tabManager.getParentTabId("child-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should do nothing for unknown tabs", () => {
|
||||
tabManager.detach(999, false);
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear all tabs and child sessions", () => {
|
||||
tabManager.set(1, { state: "connected" });
|
||||
tabManager.set(2, { state: "connected" });
|
||||
tabManager.trackChildSession("child-1", 1);
|
||||
|
||||
tabManager.clear();
|
||||
|
||||
expect(tabManager.has(1)).toBe(false);
|
||||
expect(tabManager.has(2)).toBe(false);
|
||||
expect(tabManager.getParentTabId("child-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllTabIds", () => {
|
||||
it("should return all tab IDs", () => {
|
||||
tabManager.set(1, { state: "connected" });
|
||||
tabManager.set(2, { state: "connecting" });
|
||||
tabManager.set(3, { state: "error" });
|
||||
|
||||
const ids = tabManager.getAllTabIds();
|
||||
expect(ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
skills/dev-browser/extension/__tests__/logger.test.ts
Normal file
119
skills/dev-browser/extension/__tests__/logger.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { createLogger } from "../utils/logger";
|
||||
|
||||
describe("createLogger", () => {
|
||||
let mockSendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSendMessage = vi.fn();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("log", () => {
|
||||
it("should log to console and send message", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log("test message", 123);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("[dev-browser]", "test message", 123);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["test message", "123"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("debug", () => {
|
||||
it("should debug to console and send message", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.debug("debug info");
|
||||
|
||||
expect(console.debug).toHaveBeenCalledWith("[dev-browser]", "debug info");
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "debug",
|
||||
args: ["debug info"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error", () => {
|
||||
it("should error to console and send message", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.error("error occurred");
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith("[dev-browser]", "error occurred");
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "error",
|
||||
args: ["error occurred"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("argument formatting", () => {
|
||||
it("should format undefined as string", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log(undefined);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["undefined"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should format null as string", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log(null);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["null"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should JSON stringify objects", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
logger.log({ key: "value" });
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ['{"key":"value"}'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle circular objects gracefully", () => {
|
||||
const logger = createLogger(mockSendMessage);
|
||||
const circular: Record<string, unknown> = { a: 1 };
|
||||
circular.self = circular;
|
||||
|
||||
logger.log(circular);
|
||||
|
||||
// Should fall back to String() when JSON.stringify fails
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
method: "log",
|
||||
params: {
|
||||
level: "log",
|
||||
args: ["[object Object]"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
174
skills/dev-browser/extension/entrypoints/background.ts
Normal file
174
skills/dev-browser/extension/entrypoints/background.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* dev-browser Chrome Extension Background Script
|
||||
*
|
||||
* This extension connects to the dev-browser relay server and allows
|
||||
* Playwright automation of the user's existing browser tabs.
|
||||
*/
|
||||
|
||||
import { createLogger } from "../utils/logger";
|
||||
import { TabManager } from "../services/TabManager";
|
||||
import { ConnectionManager } from "../services/ConnectionManager";
|
||||
import { CDPRouter } from "../services/CDPRouter";
|
||||
import { StateManager } from "../services/StateManager";
|
||||
import type { PopupMessage, StateResponse } from "../utils/types";
|
||||
|
||||
export default defineBackground(() => {
|
||||
// Create connection manager first (needed for sendMessage)
|
||||
let connectionManager: ConnectionManager;
|
||||
|
||||
// Create logger with sendMessage function
|
||||
const logger = createLogger((msg) => connectionManager?.send(msg));
|
||||
|
||||
// Create state manager for persistence
|
||||
const stateManager = new StateManager();
|
||||
|
||||
// Create tab manager
|
||||
const tabManager = new TabManager({
|
||||
logger,
|
||||
sendMessage: (msg) => connectionManager.send(msg),
|
||||
});
|
||||
|
||||
// Create CDP router
|
||||
const cdpRouter = new CDPRouter({
|
||||
logger,
|
||||
tabManager,
|
||||
});
|
||||
|
||||
// Create connection manager
|
||||
connectionManager = new ConnectionManager({
|
||||
logger,
|
||||
onMessage: (msg) => cdpRouter.handleCommand(msg),
|
||||
onDisconnect: () => tabManager.detachAll(),
|
||||
});
|
||||
|
||||
// Keep-alive alarm name for Chrome Alarms API
|
||||
const KEEPALIVE_ALARM = "keepAlive";
|
||||
|
||||
// Update badge to show active/inactive state
|
||||
function updateBadge(isActive: boolean): void {
|
||||
chrome.action.setBadgeText({ text: isActive ? "ON" : "" });
|
||||
chrome.action.setBadgeBackgroundColor({ color: "#4CAF50" });
|
||||
}
|
||||
|
||||
// Handle state changes
|
||||
async function handleStateChange(isActive: boolean): Promise<void> {
|
||||
await stateManager.setState({ isActive });
|
||||
if (isActive) {
|
||||
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
||||
connectionManager.startMaintaining();
|
||||
} else {
|
||||
chrome.alarms.clear(KEEPALIVE_ALARM);
|
||||
connectionManager.disconnect();
|
||||
}
|
||||
updateBadge(isActive);
|
||||
}
|
||||
|
||||
// Handle debugger events
|
||||
function onDebuggerEvent(
|
||||
source: chrome.debugger.DebuggerSession,
|
||||
method: string,
|
||||
params: unknown
|
||||
): void {
|
||||
cdpRouter.handleDebuggerEvent(source, method, params, (msg) => connectionManager.send(msg));
|
||||
}
|
||||
|
||||
function onDebuggerDetach(
|
||||
source: chrome.debugger.Debuggee,
|
||||
reason: `${chrome.debugger.DetachReason}`
|
||||
): void {
|
||||
const tabId = source.tabId;
|
||||
if (!tabId) return;
|
||||
|
||||
logger.debug(`Debugger detached for tab ${tabId}: ${reason}`);
|
||||
tabManager.handleDebuggerDetach(tabId);
|
||||
}
|
||||
|
||||
// Handle messages from popup
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(
|
||||
message: PopupMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: StateResponse) => void
|
||||
) => {
|
||||
if (message.type === "getState") {
|
||||
(async () => {
|
||||
const state = await stateManager.getState();
|
||||
const isConnected = await connectionManager.checkConnection();
|
||||
sendResponse({
|
||||
isActive: state.isActive,
|
||||
isConnected,
|
||||
});
|
||||
})();
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
if (message.type === "setState") {
|
||||
(async () => {
|
||||
await handleStateChange(message.isActive);
|
||||
const state = await stateManager.getState();
|
||||
const isConnected = await connectionManager.checkConnection();
|
||||
sendResponse({
|
||||
isActive: state.isActive,
|
||||
isConnected,
|
||||
});
|
||||
})();
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// Set up event listeners
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (tabManager.has(tabId)) {
|
||||
logger.debug("Tab closed:", tabId);
|
||||
tabManager.detach(tabId, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Register debugger event listeners
|
||||
chrome.debugger.onEvent.addListener(onDebuggerEvent);
|
||||
chrome.debugger.onDetach.addListener(onDebuggerDetach);
|
||||
|
||||
// Reset any stale debugger connections on startup
|
||||
chrome.debugger.getTargets().then((targets) => {
|
||||
const attached = targets.filter((t) => t.tabId && t.attached);
|
||||
if (attached.length > 0) {
|
||||
logger.log(`Detaching ${attached.length} stale debugger connections`);
|
||||
for (const target of attached) {
|
||||
chrome.debugger.detach({ tabId: target.tabId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.log("Extension initialized");
|
||||
|
||||
// Initialize from stored state
|
||||
stateManager.getState().then((state) => {
|
||||
updateBadge(state.isActive);
|
||||
if (state.isActive) {
|
||||
// Create keep-alive alarm only when extension is active
|
||||
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
|
||||
connectionManager.startMaintaining();
|
||||
}
|
||||
});
|
||||
|
||||
// Set up Chrome Alarms keep-alive listener
|
||||
// This ensures the connection is maintained even after service worker unloads
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === KEEPALIVE_ALARM) {
|
||||
const state = await stateManager.getState();
|
||||
|
||||
if (state.isActive) {
|
||||
const isConnected = connectionManager.isConnected();
|
||||
|
||||
if (!isConnected) {
|
||||
logger.debug("Keep-alive: Connection lost, restarting...");
|
||||
connectionManager.startMaintaining();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
23
skills/dev-browser/extension/entrypoints/popup/index.html
Normal file
23
skills/dev-browser/extension/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dev Browser</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup">
|
||||
<h1>Dev Browser</h1>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="active-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span id="status-text">Inactive</span>
|
||||
</div>
|
||||
<p id="connection-status" class="connection-status"></p>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
skills/dev-browser/extension/entrypoints/popup/main.ts
Normal file
52
skills/dev-browser/extension/entrypoints/popup/main.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { GetStateMessage, SetStateMessage, StateResponse } from "../../utils/types";
|
||||
|
||||
const toggle = document.getElementById("active-toggle") as HTMLInputElement;
|
||||
const statusText = document.getElementById("status-text") as HTMLSpanElement;
|
||||
const connectionStatus = document.getElementById("connection-status") as HTMLParagraphElement;
|
||||
|
||||
function updateUI(state: StateResponse): void {
|
||||
toggle.checked = state.isActive;
|
||||
statusText.textContent = state.isActive ? "Active" : "Inactive";
|
||||
|
||||
if (state.isActive) {
|
||||
connectionStatus.textContent = state.isConnected ? "Connected to relay" : "Connecting...";
|
||||
connectionStatus.className = state.isConnected
|
||||
? "connection-status connected"
|
||||
: "connection-status connecting";
|
||||
} else {
|
||||
connectionStatus.textContent = "";
|
||||
connectionStatus.className = "connection-status";
|
||||
}
|
||||
}
|
||||
|
||||
function refreshState(): void {
|
||||
chrome.runtime.sendMessage<GetStateMessage, StateResponse>({ type: "getState" }, (response) => {
|
||||
if (response) {
|
||||
updateUI(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load initial state
|
||||
refreshState();
|
||||
|
||||
// Poll for state updates while popup is open
|
||||
const pollInterval = setInterval(refreshState, 1000);
|
||||
|
||||
// Clean up on popup close
|
||||
window.addEventListener("unload", () => {
|
||||
clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
// Handle toggle changes
|
||||
toggle.addEventListener("change", () => {
|
||||
const isActive = toggle.checked;
|
||||
chrome.runtime.sendMessage<SetStateMessage, StateResponse>(
|
||||
{ type: "setState", isActive },
|
||||
(response) => {
|
||||
if (response) {
|
||||
updateUI(response);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
96
skills/dev-browser/extension/entrypoints/popup/style.css
Normal file
96
skills/dev-browser/extension/entrypoints/popup/style.css
Normal file
@@ -0,0 +1,96 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.popup {
|
||||
width: 200px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#status-text {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
.connection-status {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.connection-status.connecting {
|
||||
color: #ff9800;
|
||||
}
|
||||
5902
skills/dev-browser/extension/package-lock.json
generated
Normal file
5902
skills/dev-browser/extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
skills/dev-browser/extension/package.json
Normal file
21
skills/dev-browser/extension/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "dev-browser-extension",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt --browser firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build --browser firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip --browser firefox",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.32",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"wxt": "^0.20.0"
|
||||
}
|
||||
}
|
||||
BIN
skills/dev-browser/extension/public/icons/icon-128.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
skills/dev-browser/extension/public/icons/icon-16.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
BIN
skills/dev-browser/extension/public/icons/icon-32.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
skills/dev-browser/extension/public/icons/icon-48.png
Normal file
BIN
skills/dev-browser/extension/public/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
152
skills/dev-browser/extension/scripts/generate-icons.mjs
Normal file
152
skills/dev-browser/extension/scripts/generate-icons.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Generate simple placeholder icons for the extension
|
||||
* Usage: node scripts/generate-icons.mjs
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Minimal PNG generator (creates simple colored squares)
|
||||
function createPng(size, r, g, b) {
|
||||
// PNG header
|
||||
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
// IHDR chunk
|
||||
const ihdrData = Buffer.alloc(13);
|
||||
ihdrData.writeUInt32BE(size, 0); // width
|
||||
ihdrData.writeUInt32BE(size, 4); // height
|
||||
ihdrData.writeUInt8(8, 8); // bit depth
|
||||
ihdrData.writeUInt8(2, 9); // color type (RGB)
|
||||
ihdrData.writeUInt8(0, 10); // compression
|
||||
ihdrData.writeUInt8(0, 11); // filter
|
||||
ihdrData.writeUInt8(0, 12); // interlace
|
||||
|
||||
const ihdr = createChunk("IHDR", ihdrData);
|
||||
|
||||
// IDAT chunk (image data)
|
||||
const rawData = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
rawData.push(0); // filter byte
|
||||
for (let x = 0; x < size; x++) {
|
||||
// Create a circle
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = size / 2 - 1;
|
||||
const dist = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2);
|
||||
|
||||
if (dist <= radius) {
|
||||
// Inside circle - use the color
|
||||
rawData.push(r, g, b);
|
||||
} else {
|
||||
// Outside circle - transparent (white for simplicity)
|
||||
rawData.push(255, 255, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use zlib-less compression (store method)
|
||||
const compressed = deflateStore(Buffer.from(rawData));
|
||||
const idat = createChunk("IDAT", compressed);
|
||||
|
||||
// IEND chunk
|
||||
const iend = createChunk("IEND", Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([signature, ihdr, idat, iend]);
|
||||
}
|
||||
|
||||
function createChunk(type, data) {
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
|
||||
const typeBuffer = Buffer.from(type);
|
||||
const crc = crc32(Buffer.concat([typeBuffer, data]));
|
||||
|
||||
const crcBuffer = Buffer.alloc(4);
|
||||
crcBuffer.writeUInt32BE(crc >>> 0);
|
||||
|
||||
return Buffer.concat([length, typeBuffer, data, crcBuffer]);
|
||||
}
|
||||
|
||||
// Simple deflate store (no compression)
|
||||
function deflateStore(data) {
|
||||
const blocks = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < data.length) {
|
||||
const remaining = data.length - offset;
|
||||
const blockSize = Math.min(65535, remaining);
|
||||
const isLast = offset + blockSize >= data.length;
|
||||
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUInt8(isLast ? 1 : 0, 0);
|
||||
header.writeUInt16LE(blockSize, 1);
|
||||
header.writeUInt16LE(blockSize ^ 0xffff, 3);
|
||||
|
||||
blocks.push(header);
|
||||
blocks.push(data.subarray(offset, offset + blockSize));
|
||||
offset += blockSize;
|
||||
}
|
||||
|
||||
// Zlib header
|
||||
const zlibHeader = Buffer.from([0x78, 0x01]);
|
||||
|
||||
// Adler32 checksum
|
||||
const adler = adler32(data);
|
||||
const adlerBuffer = Buffer.alloc(4);
|
||||
adlerBuffer.writeUInt32BE(adler);
|
||||
|
||||
return Buffer.concat([zlibHeader, ...blocks, adlerBuffer]);
|
||||
}
|
||||
|
||||
function adler32(data) {
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
a = (a + data[i]) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return ((b << 16) | a) >>> 0; // Ensure unsigned
|
||||
}
|
||||
|
||||
// CRC32 lookup table
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
crcTable[i] = c;
|
||||
}
|
||||
|
||||
function crc32(data) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = crcTable[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return crc ^ 0xffffffff;
|
||||
}
|
||||
|
||||
// Generate icons
|
||||
const sizes = [16, 32, 48, 128];
|
||||
const colors = {
|
||||
black: [26, 26, 26],
|
||||
gray: [156, 163, 175],
|
||||
green: [34, 197, 94],
|
||||
};
|
||||
|
||||
const iconsDir = join(__dirname, "..", "public", "icons");
|
||||
mkdirSync(iconsDir, { recursive: true });
|
||||
|
||||
for (const [name, [r, g, b]] of Object.entries(colors)) {
|
||||
for (const size of sizes) {
|
||||
const png = createPng(size, r, g, b);
|
||||
const filename = join(iconsDir, `icon-${name}-${size}.png`);
|
||||
writeFileSync(filename, png);
|
||||
console.log(`Created ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
211
skills/dev-browser/extension/services/CDPRouter.ts
Normal file
211
skills/dev-browser/extension/services/CDPRouter.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* CDPRouter - Routes CDP commands to the correct tab.
|
||||
*/
|
||||
|
||||
import type { Logger } from "../utils/logger";
|
||||
import type { TabManager } from "./TabManager";
|
||||
import type { ExtensionCommandMessage, TabInfo } from "../utils/types";
|
||||
|
||||
export interface CDPRouterDeps {
|
||||
logger: Logger;
|
||||
tabManager: TabManager;
|
||||
}
|
||||
|
||||
export class CDPRouter {
|
||||
private logger: Logger;
|
||||
private tabManager: TabManager;
|
||||
private devBrowserGroupId: number | null = null;
|
||||
|
||||
constructor(deps: CDPRouterDeps) {
|
||||
this.logger = deps.logger;
|
||||
this.tabManager = deps.tabManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the "Dev Browser" tab group, returning its ID.
|
||||
*/
|
||||
private async getOrCreateDevBrowserGroup(tabId: number): Promise<number> {
|
||||
// If we have a cached group ID, verify it still exists
|
||||
if (this.devBrowserGroupId !== null) {
|
||||
try {
|
||||
await chrome.tabGroups.get(this.devBrowserGroupId);
|
||||
// Group exists, add tab to it
|
||||
await chrome.tabs.group({ tabIds: [tabId], groupId: this.devBrowserGroupId });
|
||||
return this.devBrowserGroupId;
|
||||
} catch {
|
||||
// Group no longer exists, reset cache
|
||||
this.devBrowserGroupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new group with this tab
|
||||
const groupId = await chrome.tabs.group({ tabIds: [tabId] });
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: "Dev Browser",
|
||||
color: "blue",
|
||||
});
|
||||
this.devBrowserGroupId = groupId;
|
||||
return groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming CDP command from the relay.
|
||||
*/
|
||||
async handleCommand(msg: ExtensionCommandMessage): Promise<unknown> {
|
||||
if (msg.method !== "forwardCDPCommand") return;
|
||||
|
||||
let targetTabId: number | undefined;
|
||||
let targetTab: TabInfo | undefined;
|
||||
|
||||
// Find target tab by sessionId
|
||||
if (msg.params.sessionId) {
|
||||
const found = this.tabManager.getBySessionId(msg.params.sessionId);
|
||||
if (found) {
|
||||
targetTabId = found.tabId;
|
||||
targetTab = found.tab;
|
||||
}
|
||||
}
|
||||
|
||||
// Check child sessions (iframes, workers)
|
||||
if (!targetTab && msg.params.sessionId) {
|
||||
const parentTabId = this.tabManager.getParentTabId(msg.params.sessionId);
|
||||
if (parentTabId) {
|
||||
targetTabId = parentTabId;
|
||||
targetTab = this.tabManager.get(parentTabId);
|
||||
this.logger.debug(
|
||||
"Found parent tab for child session:",
|
||||
msg.params.sessionId,
|
||||
"tabId:",
|
||||
parentTabId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Find by targetId in params
|
||||
if (
|
||||
!targetTab &&
|
||||
msg.params.params &&
|
||||
typeof msg.params.params === "object" &&
|
||||
"targetId" in msg.params.params
|
||||
) {
|
||||
const found = this.tabManager.getByTargetId(msg.params.params.targetId as string);
|
||||
if (found) {
|
||||
targetTabId = found.tabId;
|
||||
targetTab = found.tab;
|
||||
}
|
||||
}
|
||||
|
||||
const debuggee = targetTabId ? { tabId: targetTabId } : undefined;
|
||||
|
||||
// Handle special commands
|
||||
switch (msg.params.method) {
|
||||
case "Runtime.enable": {
|
||||
if (!debuggee) {
|
||||
throw new Error(
|
||||
`No debuggee found for Runtime.enable (sessionId: ${msg.params.sessionId})`
|
||||
);
|
||||
}
|
||||
// Disable and re-enable to reset state
|
||||
try {
|
||||
await chrome.debugger.sendCommand(debuggee, "Runtime.disable");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return await chrome.debugger.sendCommand(debuggee, "Runtime.enable", msg.params.params);
|
||||
}
|
||||
|
||||
case "Target.createTarget": {
|
||||
const url = (msg.params.params?.url as string) || "about:blank";
|
||||
this.logger.debug("Creating new tab with URL:", url);
|
||||
const tab = await chrome.tabs.create({ url, active: false });
|
||||
if (!tab.id) throw new Error("Failed to create tab");
|
||||
|
||||
// Add tab to "Dev Browser" group
|
||||
await this.getOrCreateDevBrowserGroup(tab.id);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const targetInfo = await this.tabManager.attach(tab.id);
|
||||
return { targetId: targetInfo.targetId };
|
||||
}
|
||||
|
||||
case "Target.closeTarget": {
|
||||
if (!targetTabId) {
|
||||
this.logger.log(`Target not found: ${msg.params.params?.targetId}`);
|
||||
return { success: false };
|
||||
}
|
||||
await chrome.tabs.remove(targetTabId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case "Target.activateTarget": {
|
||||
if (!targetTabId) {
|
||||
this.logger.log(`Target not found for activation: ${msg.params.params?.targetId}`);
|
||||
return {};
|
||||
}
|
||||
await chrome.tabs.update(targetTabId, { active: true });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!debuggee || !targetTab) {
|
||||
throw new Error(
|
||||
`No tab found for method ${msg.params.method} sessionId: ${msg.params.sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("CDP command:", msg.params.method, "for tab:", targetTabId);
|
||||
|
||||
const debuggerSession: chrome.debugger.DebuggerSession = {
|
||||
...debuggee,
|
||||
sessionId: msg.params.sessionId !== targetTab.sessionId ? msg.params.sessionId : undefined,
|
||||
};
|
||||
|
||||
return await chrome.debugger.sendCommand(debuggerSession, msg.params.method, msg.params.params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debugger events from Chrome.
|
||||
*/
|
||||
handleDebuggerEvent(
|
||||
source: chrome.debugger.DebuggerSession,
|
||||
method: string,
|
||||
params: unknown,
|
||||
sendMessage: (msg: unknown) => void
|
||||
): void {
|
||||
const tab = source.tabId ? this.tabManager.get(source.tabId) : undefined;
|
||||
if (!tab) return;
|
||||
|
||||
this.logger.debug("Forwarding CDP event:", method, "from tab:", source.tabId);
|
||||
|
||||
// Track child sessions
|
||||
if (
|
||||
method === "Target.attachedToTarget" &&
|
||||
params &&
|
||||
typeof params === "object" &&
|
||||
"sessionId" in params
|
||||
) {
|
||||
const sessionId = (params as { sessionId: string }).sessionId;
|
||||
this.tabManager.trackChildSession(sessionId, source.tabId!);
|
||||
}
|
||||
|
||||
if (
|
||||
method === "Target.detachedFromTarget" &&
|
||||
params &&
|
||||
typeof params === "object" &&
|
||||
"sessionId" in params
|
||||
) {
|
||||
const sessionId = (params as { sessionId: string }).sessionId;
|
||||
this.tabManager.untrackChildSession(sessionId);
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
sessionId: source.sessionId || tab.sessionId,
|
||||
method,
|
||||
params,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
214
skills/dev-browser/extension/services/ConnectionManager.ts
Normal file
214
skills/dev-browser/extension/services/ConnectionManager.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* ConnectionManager - Manages WebSocket connection to relay server.
|
||||
*/
|
||||
|
||||
import type { Logger } from "../utils/logger";
|
||||
import type { ExtensionCommandMessage, ExtensionResponseMessage } from "../utils/types";
|
||||
|
||||
const RELAY_URL = "ws://localhost:9222/extension";
|
||||
const RECONNECT_INTERVAL = 3000;
|
||||
|
||||
export interface ConnectionManagerDeps {
|
||||
logger: Logger;
|
||||
onMessage: (message: ExtensionCommandMessage) => Promise<unknown>;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export class ConnectionManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldMaintain = false;
|
||||
private logger: Logger;
|
||||
private onMessage: (message: ExtensionCommandMessage) => Promise<unknown>;
|
||||
private onDisconnect: () => void;
|
||||
|
||||
constructor(deps: ConnectionManagerDeps) {
|
||||
this.logger = deps.logger;
|
||||
this.onMessage = deps.onMessage;
|
||||
this.onDisconnect = deps.onDisconnect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebSocket is open (may be stale if server crashed).
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate connection by checking if server is reachable.
|
||||
* More reliable than isConnected() as it detects server crashes.
|
||||
*/
|
||||
async checkConnection(): Promise<boolean> {
|
||||
if (!this.isConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify server is actually reachable
|
||||
try {
|
||||
const response = await fetch("http://localhost:9222", {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
// Server unreachable - close stale socket
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
this.onDisconnect();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the relay server.
|
||||
*/
|
||||
send(message: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.debug("Error sending message:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start maintaining connection (auto-reconnect).
|
||||
*/
|
||||
startMaintaining(): void {
|
||||
this.shouldMaintain = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.tryConnect().catch(() => {});
|
||||
this.reconnectTimer = setTimeout(() => this.startMaintaining(), RECONNECT_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop connection maintenance.
|
||||
*/
|
||||
stopMaintaining(): void {
|
||||
this.shouldMaintain = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from relay and stop maintaining connection.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.stopMaintaining();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.onDisconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure connection is established, waiting if needed.
|
||||
*/
|
||||
async ensureConnected(): Promise<void> {
|
||||
if (this.isConnected()) return;
|
||||
|
||||
await this.tryConnect();
|
||||
|
||||
if (!this.isConnected()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await this.tryConnect();
|
||||
}
|
||||
|
||||
if (!this.isConnected()) {
|
||||
throw new Error("Could not connect to relay server");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to connect to relay server once.
|
||||
*/
|
||||
private async tryConnect(): Promise<void> {
|
||||
if (this.isConnected()) return;
|
||||
|
||||
// Check if server is available
|
||||
try {
|
||||
await fetch("http://localhost:9222", { method: "HEAD" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug("Connecting to relay server...");
|
||||
const socket = new WebSocket(RELAY_URL);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Connection timeout"));
|
||||
}, 5000);
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket closed: ${event.reason || event.code}`));
|
||||
};
|
||||
});
|
||||
|
||||
this.ws = socket;
|
||||
this.setupSocketHandlers(socket);
|
||||
this.logger.log("Connected to relay server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up WebSocket event handlers.
|
||||
*/
|
||||
private setupSocketHandlers(socket: WebSocket): void {
|
||||
socket.onmessage = async (event: MessageEvent) => {
|
||||
let message: ExtensionCommandMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (error) {
|
||||
this.logger.debug("Error parsing message:", error);
|
||||
this.send({
|
||||
error: { code: -32700, message: "Parse error" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ExtensionResponseMessage = { id: message.id };
|
||||
try {
|
||||
response.result = await this.onMessage(message);
|
||||
} catch (error) {
|
||||
this.logger.debug("Error handling command:", error);
|
||||
response.error = (error as Error).message;
|
||||
}
|
||||
this.send(response);
|
||||
};
|
||||
|
||||
socket.onclose = (event: CloseEvent) => {
|
||||
this.logger.debug("Connection closed:", event.code, event.reason);
|
||||
this.ws = null;
|
||||
this.onDisconnect();
|
||||
if (this.shouldMaintain) {
|
||||
this.startMaintaining();
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (event: Event) => {
|
||||
this.logger.debug("WebSocket error:", event);
|
||||
};
|
||||
}
|
||||
}
|
||||
28
skills/dev-browser/extension/services/StateManager.ts
Normal file
28
skills/dev-browser/extension/services/StateManager.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* StateManager - Manages extension active/inactive state with persistence.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "devBrowserActiveState";
|
||||
|
||||
export interface ExtensionState {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export class StateManager {
|
||||
/**
|
||||
* Get the current extension state.
|
||||
* Defaults to inactive if no state is stored.
|
||||
*/
|
||||
async getState(): Promise<ExtensionState> {
|
||||
const result = await chrome.storage.local.get(STORAGE_KEY);
|
||||
const state = result[STORAGE_KEY] as ExtensionState | undefined;
|
||||
return state ?? { isActive: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the extension state.
|
||||
*/
|
||||
async setState(state: ExtensionState): Promise<void> {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: state });
|
||||
}
|
||||
}
|
||||
218
skills/dev-browser/extension/services/TabManager.ts
Normal file
218
skills/dev-browser/extension/services/TabManager.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* TabManager - Manages tab state and debugger attachment.
|
||||
*/
|
||||
|
||||
import type { TabInfo, TargetInfo } from "../utils/types";
|
||||
import type { Logger } from "../utils/logger";
|
||||
|
||||
export type SendMessageFn = (message: unknown) => void;
|
||||
|
||||
export interface TabManagerDeps {
|
||||
logger: Logger;
|
||||
sendMessage: SendMessageFn;
|
||||
}
|
||||
|
||||
export class TabManager {
|
||||
private tabs = new Map<number, TabInfo>();
|
||||
private childSessions = new Map<string, number>(); // sessionId -> parentTabId
|
||||
private nextSessionId = 1;
|
||||
private logger: Logger;
|
||||
private sendMessage: SendMessageFn;
|
||||
|
||||
constructor(deps: TabManagerDeps) {
|
||||
this.logger = deps.logger;
|
||||
this.sendMessage = deps.sendMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab info by session ID.
|
||||
*/
|
||||
getBySessionId(sessionId: string): { tabId: number; tab: TabInfo } | undefined {
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
if (tab.sessionId === sessionId) {
|
||||
return { tabId, tab };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab info by target ID.
|
||||
*/
|
||||
getByTargetId(targetId: string): { tabId: number; tab: TabInfo } | undefined {
|
||||
for (const [tabId, tab] of this.tabs) {
|
||||
if (tab.targetId === targetId) {
|
||||
return { tabId, tab };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parent tab ID for a child session (iframe, worker).
|
||||
*/
|
||||
getParentTabId(sessionId: string): number | undefined {
|
||||
return this.childSessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab info by tab ID.
|
||||
*/
|
||||
get(tabId: number): TabInfo | undefined {
|
||||
return this.tabs.get(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tab is tracked.
|
||||
*/
|
||||
has(tabId: number): boolean {
|
||||
return this.tabs.has(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tab info (used for intermediate states like "connecting").
|
||||
*/
|
||||
set(tabId: number, info: TabInfo): void {
|
||||
this.tabs.set(tabId, info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a child session (iframe, worker).
|
||||
*/
|
||||
trackChildSession(sessionId: string, parentTabId: number): void {
|
||||
this.logger.debug("Child target attached:", sessionId, "for tab:", parentTabId);
|
||||
this.childSessions.set(sessionId, parentTabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrack a child session.
|
||||
*/
|
||||
untrackChildSession(sessionId: string): void {
|
||||
this.logger.debug("Child target detached:", sessionId);
|
||||
this.childSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach debugger to a tab and register it.
|
||||
*/
|
||||
async attach(tabId: number): Promise<TargetInfo> {
|
||||
const debuggee = { tabId };
|
||||
|
||||
this.logger.debug("Attaching debugger to tab:", tabId);
|
||||
await chrome.debugger.attach(debuggee, "1.3");
|
||||
|
||||
const result = (await chrome.debugger.sendCommand(debuggee, "Target.getTargetInfo")) as {
|
||||
targetInfo: TargetInfo;
|
||||
};
|
||||
|
||||
const targetInfo = result.targetInfo;
|
||||
const sessionId = `pw-tab-${this.nextSessionId++}`;
|
||||
|
||||
this.tabs.set(tabId, {
|
||||
sessionId,
|
||||
targetId: targetInfo.targetId,
|
||||
state: "connected",
|
||||
});
|
||||
|
||||
// Notify relay of new target
|
||||
this.sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: { ...targetInfo, attached: true },
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log("Tab attached:", tabId, "sessionId:", sessionId, "url:", targetInfo.url);
|
||||
return targetInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a tab and clean up.
|
||||
*/
|
||||
detach(tabId: number, shouldDetachDebugger: boolean): void {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
this.logger.debug("Detaching tab:", tabId);
|
||||
|
||||
this.sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: tab.sessionId, targetId: tab.targetId },
|
||||
},
|
||||
});
|
||||
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// Clean up child sessions
|
||||
for (const [childSessionId, parentTabId] of this.childSessions) {
|
||||
if (parentTabId === tabId) {
|
||||
this.childSessions.delete(childSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDetachDebugger) {
|
||||
chrome.debugger.detach({ tabId }).catch((err) => {
|
||||
this.logger.debug("Error detaching debugger:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debugger detach event from Chrome.
|
||||
*/
|
||||
handleDebuggerDetach(tabId: number): void {
|
||||
if (!this.tabs.has(tabId)) return;
|
||||
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab) {
|
||||
this.sendMessage({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: tab.sessionId, targetId: tab.targetId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up child sessions
|
||||
for (const [childSessionId, parentTabId] of this.childSessions) {
|
||||
if (parentTabId === tabId) {
|
||||
this.childSessions.delete(childSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
this.tabs.delete(tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tabs and child sessions.
|
||||
*/
|
||||
clear(): void {
|
||||
this.tabs.clear();
|
||||
this.childSessions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach all tabs (used on disconnect).
|
||||
*/
|
||||
detachAll(): void {
|
||||
for (const tabId of this.tabs.keys()) {
|
||||
chrome.debugger.detach({ tabId }).catch(() => {});
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tab IDs.
|
||||
*/
|
||||
getAllTabIds(): number[] {
|
||||
return Array.from(this.tabs.keys());
|
||||
}
|
||||
}
|
||||
3
skills/dev-browser/extension/tsconfig.json
Normal file
3
skills/dev-browser/extension/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.wxt/tsconfig.json"
|
||||
}
|
||||
63
skills/dev-browser/extension/utils/logger.ts
Normal file
63
skills/dev-browser/extension/utils/logger.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Logger utility for the dev-browser extension.
|
||||
* Logs to console and optionally sends to relay server.
|
||||
*/
|
||||
|
||||
export type LogLevel = "log" | "debug" | "error";
|
||||
|
||||
export interface LogMessage {
|
||||
method: "log";
|
||||
params: {
|
||||
level: LogLevel;
|
||||
args: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type SendMessageFn = (message: unknown) => void;
|
||||
|
||||
/**
|
||||
* Creates a logger instance that logs to console and sends to relay.
|
||||
*/
|
||||
export function createLogger(sendMessage: SendMessageFn) {
|
||||
function formatArgs(args: unknown[]): string[] {
|
||||
return args.map((arg) => {
|
||||
if (arg === undefined) return "undefined";
|
||||
if (arg === null) return "null";
|
||||
if (typeof arg === "object") {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
});
|
||||
}
|
||||
|
||||
function sendLog(level: LogLevel, args: unknown[]): void {
|
||||
sendMessage({
|
||||
method: "log",
|
||||
params: {
|
||||
level,
|
||||
args: formatArgs(args),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
log: (...args: unknown[]) => {
|
||||
console.log("[dev-browser]", ...args);
|
||||
sendLog("log", args);
|
||||
},
|
||||
debug: (...args: unknown[]) => {
|
||||
console.debug("[dev-browser]", ...args);
|
||||
sendLog("debug", args);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
console.error("[dev-browser]", ...args);
|
||||
sendLog("error", args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
94
skills/dev-browser/extension/utils/types.ts
Normal file
94
skills/dev-browser/extension/utils/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Types for extension-relay communication
|
||||
*/
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "error";
|
||||
|
||||
export type TabState = "connecting" | "connected" | "error";
|
||||
|
||||
export interface TabInfo {
|
||||
sessionId?: string;
|
||||
targetId?: string;
|
||||
state: TabState;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
tabs: Map<number, TabInfo>;
|
||||
connectionState: ConnectionState;
|
||||
currentTabId?: number;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
// Messages from relay to extension
|
||||
export interface ExtensionCommandMessage {
|
||||
id: number;
|
||||
method: "forwardCDPCommand";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Messages from extension to relay (responses)
|
||||
export interface ExtensionResponseMessage {
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Messages from extension to relay (events)
|
||||
export interface ExtensionEventMessage {
|
||||
method: "forwardCDPEvent";
|
||||
params: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Log message from extension to relay
|
||||
export interface ExtensionLogMessage {
|
||||
method: "log";
|
||||
params: {
|
||||
level: string;
|
||||
args: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ExtensionMessage =
|
||||
| ExtensionResponseMessage
|
||||
| ExtensionEventMessage
|
||||
| ExtensionLogMessage;
|
||||
|
||||
// Chrome debugger target info
|
||||
export interface TargetInfo {
|
||||
targetId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
// Popup <-> Background messaging
|
||||
export interface GetStateMessage {
|
||||
type: "getState";
|
||||
}
|
||||
|
||||
export interface SetStateMessage {
|
||||
type: "setState";
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface StateResponse {
|
||||
isActive: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export type PopupMessage = GetStateMessage | SetStateMessage;
|
||||
10
skills/dev-browser/extension/vitest.config.ts
Normal file
10
skills/dev-browser/extension/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { WxtVitest } from "wxt/testing";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [WxtVitest()],
|
||||
test: {
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
16
skills/dev-browser/extension/wxt.config.ts
Normal file
16
skills/dev-browser/extension/wxt.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "wxt";
|
||||
|
||||
export default defineConfig({
|
||||
manifest: {
|
||||
name: "dev-browser",
|
||||
description: "Connect your browser to dev-browser for Playwright automation",
|
||||
permissions: ["debugger", "tabGroups", "storage", "alarms"],
|
||||
host_permissions: ["<all_urls>"],
|
||||
icons: {
|
||||
16: "icons/icon-16.png",
|
||||
32: "icons/icon-32.png",
|
||||
48: "icons/icon-48.png",
|
||||
128: "icons/icon-128.png",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user