This commit is contained in:
Tommy D. Rossi
2026-02-22 15:21:38 +01:00
parent e475c2f582
commit f87b0243cd
101 changed files with 14997 additions and 11339 deletions
+10 -1
View File
@@ -3,7 +3,16 @@
"name": "Playwriter",
"version": "0.0.71",
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"],
"permissions": [
"debugger",
"tabGroups",
"contextMenus",
"tabs",
"tabCapture",
"offscreen",
"identity",
"identity.email"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "background.js",
+33 -33
View File
@@ -1,35 +1,35 @@
{
"name": "mcp-extension",
"version": "0.0.71",
"description": "Playwright MCP Browser Extension",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/remorses/playwriter.git"
},
"homepage": "https://github.com/remorses/playwriter",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && vite build --config vite.config.mts && tsx scripts/download-prism.ts",
"reload": "bun run build && osascript -e 'tell application \"Google Chrome\" to open location \"chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid\"'",
"watch": "vite build --watch --config vite.config.mts",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/chrome": "^0.0.315",
"concurrently": "^8.2.2",
"typescript": "^5.8.2",
"vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1"
},
"dependencies": {
"playwriter": "workspace:*",
"zustand": "^5.0.8"
}
"name": "mcp-extension",
"version": "0.0.71",
"description": "Playwright MCP Browser Extension",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/remorses/playwriter.git"
},
"homepage": "https://github.com/remorses/playwriter",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && vite build --config vite.config.mts && tsx scripts/download-prism.ts",
"reload": "bun run build && osascript -e 'tell application \"Google Chrome\" to open location \"chrome://extensions/?id=pebbngnfojnignonigcnkdilknapkgid\"'",
"watch": "vite build --watch --config vite.config.mts",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/chrome": "^0.0.315",
"concurrently": "^8.2.2",
"typescript": "^5.8.2",
"vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1"
},
"dependencies": {
"playwriter": "workspace:*",
"zustand": "^5.0.8"
}
}
+5
View File
@@ -19,10 +19,12 @@ Essential for core functionality. This permission allows the extension to attach
**Note: This permission is automatically removed during production builds and is only included in test builds.**
The tabs permission is only needed during development/testing to:
- Access the URL property of tabs for test identification (finding tabs by URL pattern)
- Query all tabs with full information for test assertions
In production, the extension functions perfectly without the tabs permission because:
- Tab event listeners (onRemoved, onActivated, onUpdated) work without it
- chrome.tabs.create() and chrome.tabs.remove() work without it
- chrome.tabs.query() for active tab works without it
@@ -44,11 +46,13 @@ All extension code (JavaScript, HTML, CSS) is fully bundled within the extension
The extension establishes a WebSocket connection to `ws://localhost:19988` - a local server running on the user's own machine. This connection is used exclusively for **message passing** (sending and receiving JSON data), NOT code execution.
**What the WebSocket is used for:**
- Receiving CDP (Chrome DevTools Protocol) command messages in JSON format from local Playwright scripts
- Forwarding these command messages to attached browser tabs via the `chrome.debugger` API
- Sending CDP event messages back to the local Playwright scripts
**What it is NOT used for:**
- Downloading or executing JavaScript, WebAssembly, or any other executable code
- Connecting to external/remote servers (strictly localhost only)
- Loading remote configurations that modify extension behavior
@@ -66,6 +70,7 @@ This is functionally similar to Native Messaging but uses WebSockets for cross-p
## Screenshots Required
Need to provide at least one screenshot showing:
- Extension icon in toolbar (gray when disconnected, green when connected)
- Extension attached to a tab with Chrome's "debugging this browser" banner visible
- Welcome page or usage demonstration
+17 -13
View File
@@ -14,19 +14,23 @@ const files: [string, string][] = [
function download(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: ${res.statusCode}`))
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
res.on('end', () => {
fs.writeFileSync(dest, Buffer.concat(chunks))
resolve()
https
.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: ${res.statusCode}`))
return
}
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
res.on('end', () => {
fs.writeFileSync(dest, Buffer.concat(chunks))
resolve()
})
res.on('error', reject)
})
res.on('error', reject)
}).on('error', reject)
.on('error', reject)
})
}
@@ -34,7 +38,7 @@ async function main() {
await Promise.all(
files.map(([src, dest]) => {
return download(BASE + src, path.join(DEST, dest))
})
}),
)
console.log(`Downloaded ${files.length} Prism.js files to ${DEST}`)
}
+137 -90
View File
@@ -32,7 +32,6 @@ type ExtensionIdentity = {
id: string
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -118,19 +117,21 @@ function flushRecordingChunkBuffer(ws: WebSocket): void {
if (recordingChunkBuffer.length === 0) {
return
}
logger.debug(`Flushing ${recordingChunkBuffer.length} buffered recording chunks`)
while (recordingChunkBuffer.length > 0) {
const chunk = recordingChunkBuffer.shift()!
const { tabId, data, final } = chunk
// Send metadata message first
ws.send(JSON.stringify({
method: 'recordingData',
params: { tabId, final },
}))
ws.send(
JSON.stringify({
method: 'recordingData',
params: { tabId, final },
}),
)
// Then send binary data if not final
if (data && !final) {
const buffer = new Uint8Array(data)
@@ -168,7 +169,7 @@ class ConnectionManager {
setTimeout(() => {
reject(new Error('Connection timeout (global)'))
}, GLOBAL_TIMEOUT_MS)
})
}),
])
try {
@@ -232,10 +233,10 @@ class ConnectionManager {
settled = true
logger.debug('WebSocket connected')
clearTimeout(timeout)
// Flush any buffered recording chunks now that WebSocket is ready
flushRecordingChunkBuffer(socket)
resolve()
}
@@ -417,7 +418,9 @@ class ConnectionManager {
const mem = performance.memory
if (mem) {
const formatMB = (b: number) => (b / 1024 / 1024).toFixed(2) + 'MB'
logger.warn(`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`)
logger.warn(
`DISCONNECT MEMORY: used=${formatMB(mem.usedJSHeapSize)} total=${formatMB(mem.totalJSHeapSize)} limit=${formatMB(mem.jsHeapSizeLimit)}`,
)
}
} catch {}
logger.warn(`DISCONNECT: WS closed code=${code} reason=${reason || 'none'} stack=${getCallStack()}`)
@@ -484,12 +487,21 @@ class ConnectionManager {
// Slot is free when: no extension connected, OR connected but no active tabs.
if (store.getState().connectionState === 'extension-replaced') {
try {
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
const data = await response.json() as { connected: boolean; activeTargets: number }
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
})
const data = (await response.json()) as { connected: boolean; activeTargets: number }
const slotAvailable = !data.connected || data.activeTargets === 0
if (slotAvailable) {
store.setState({ connectionState: 'idle', errorText: undefined })
logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state')
logger.debug(
'Extension slot is free (connected:',
data.connected,
'activeTargets:',
data.activeTargets,
'), cleared error state',
)
} else {
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...')
}
@@ -500,26 +512,26 @@ class ConnectionManager {
continue
}
// Ensure tabs are in 'connecting' state when WS is not connected
// This handles edge cases where handleClose wasn't called or state got out of sync
const currentTabs = store.getState().tabs
const hasConnectedTabs = Array.from(currentTabs.values()).some((t) => t.state === 'connected')
if (hasConnectedTabs) {
store.setState((state) => {
const newTabs = new Map(state.tabs)
for (const [tabId, tab] of newTabs) {
if (tab.state === 'connected') {
newTabs.set(tabId, { ...tab, state: 'connecting' })
// Ensure tabs are in 'connecting' state when WS is not connected
// This handles edge cases where handleClose wasn't called or state got out of sync
const currentTabs = store.getState().tabs
const hasConnectedTabs = Array.from(currentTabs.values()).some((t) => t.state === 'connected')
if (hasConnectedTabs) {
store.setState((state) => {
const newTabs = new Map(state.tabs)
for (const [tabId, tab] of newTabs) {
if (tab.state === 'connected') {
newTabs.set(tabId, { ...tab, state: 'connecting' })
}
}
}
return { tabs: newTabs }
})
}
return { tabs: newTabs }
})
}
// Try to connect silently in background - don't show 'connecting' badge
// Individual tab states will show 'connecting' when user explicitly clicks
try {
await this.ensureConnection()
// Try to connect silently in background - don't show 'connecting' badge
// Individual tab states will show 'connecting' when user explicitly clicks
try {
await this.ensureConnection()
store.setState({ connectionState: 'connected' })
// Re-attach any tabs that were in 'connecting' state (from a previous disconnect)
@@ -777,8 +789,7 @@ function getTabByTargetId(targetId: string): { tabId: number; tab: TabInfo } | u
}
function emitChildDetachesForTab(tabId: number): void {
const childEntries = Array.from(childSessions.entries())
.filter(([_, parentTab]) => parentTab.tabId === tabId)
const childEntries = Array.from(childSessions.entries()).filter(([_, parentTab]) => parentTab.tabId === tabId)
childEntries.forEach(([childSessionId, parentTab]) => {
const childDetachParams: Protocol.Target.DetachedFromTargetEvent = parentTab.targetId
@@ -843,13 +854,15 @@ async function handleCommand(msg: ExtensionCommandMessage): Promise<any> {
.filter(([_, info]) => info.state === 'connected')
.map(([tabId]) => tabId)
await Promise.all(connectedTabIds.map(async (tabId) => {
try {
await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params)
} catch (error) {
logger.debug('Failed to set auto-attach for tab:', tabId, error)
}
}))
await Promise.all(
connectedTabIds.map(async (tabId) => {
try {
await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', params)
} catch (error) {
logger.debug('Failed to set auto-attach for tab:', tabId, error)
}
}),
)
return {}
}
@@ -1007,7 +1020,10 @@ type AttachTabResult = {
sessionId: string
}
async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {}): Promise<AttachTabResult> {
async function attachTab(
tabId: number,
{ skipAttachedEvent = false }: { skipAttachedEvent?: boolean } = {},
): Promise<AttachTabResult> {
const debuggee = { tabId }
let debuggerAttached = false
@@ -1045,7 +1061,12 @@ async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAtt
// Log error if URL is empty - this causes Playwright to create broken pages
if (!targetInfo.url || targetInfo.url === '' || targetInfo.url === ':') {
logger.error('WARNING: Target.attachedToTarget will be sent with empty URL! tabId:', tabId, 'targetInfo:', JSON.stringify(targetInfo))
logger.error(
'WARNING: Target.attachedToTarget will be sent with empty URL! tabId:',
tabId,
'targetInfo:',
JSON.stringify(targetInfo),
)
}
const attachOrder = nextSessionId
@@ -1076,7 +1097,18 @@ async function attachTab(tabId: number, { skipAttachedEvent = false }: { skipAtt
})
}
logger.debug('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId, 'url:', targetInfo.url, 'skipAttachedEvent:', skipAttachedEvent)
logger.debug(
'Tab attached successfully:',
tabId,
'sessionId:',
sessionId,
'targetId:',
targetInfo.targetId,
'url:',
targetInfo.url,
'skipAttachedEvent:',
skipAttachedEvent,
)
return { targetInfo, sessionId }
} catch (error) {
// Clean up debugger if we attached but failed later
@@ -1127,8 +1159,6 @@ function detachTab(tabId: number, shouldDetachDebugger: boolean): void {
}
}
async function connectTab(tabId: number): Promise<void> {
try {
logger.debug(`Starting connection to tab ${tabId}`)
@@ -1264,7 +1294,13 @@ function isRestrictedUrl(url: string | undefined): boolean {
return !OUR_EXTENSION_IDS.includes(extensionId)
}
const restrictedPrefixes = ['chrome://', 'devtools://', 'edge://', 'https://chrome.google.com/', 'https://chromewebstore.google.com/']
const restrictedPrefixes = [
'chrome://',
'devtools://',
'edge://',
'https://chrome.google.com/',
'https://chromewebstore.google.com/',
]
return restrictedPrefixes.some((prefix) => url.startsWith(prefix))
}
@@ -1434,14 +1470,17 @@ async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
resetDebugger()
connectionManager.maintainLoop()
chrome.contextMenus.remove('playwriter-pin-element').catch(() => {}).finally(() => {
chrome.contextMenus.create({
id: 'playwriter-pin-element',
title: 'Copy Playwriter Element Reference',
contexts: ['all'],
visible: false,
chrome.contextMenus
.remove('playwriter-pin-element')
.catch(() => {})
.finally(() => {
chrome.contextMenus.create({
id: 'playwriter-pin-element',
title: 'Copy Playwriter Element Reference',
contexts: ['all'],
visible: false,
})
})
})
function updateContextMenuVisibility(): void {
const { currentTabId, tabs } = store.getState()
@@ -1501,11 +1540,17 @@ function checkMemory(): void {
// Log if memory is high or growing rapidly
if (used > MEMORY_CRITICAL_THRESHOLD) {
logger.error(`MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`)
logger.error(
`MEMORY CRITICAL: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`,
)
} else if (used > MEMORY_WARNING_THRESHOLD) {
logger.warn(`MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`)
logger.warn(
`MEMORY WARNING: used=${formatMB(used)} total=${formatMB(total)} limit=${formatMB(limit)} growth=${formatMB(memoryDelta)} rate=${formatMB(growthRate)}/s`,
)
} else if (memoryDelta > MEMORY_GROWTH_THRESHOLD && timeDelta < 60000) {
logger.warn(`MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1000).toFixed(1)}s (used=${formatMB(used)})`)
logger.warn(
`MEMORY SPIKE: grew ${formatMB(memoryDelta)} in ${(timeDelta / 1000).toFixed(1)}s (used=${formatMB(used)})`,
)
}
lastMemoryUsage = used
@@ -1528,31 +1573,33 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
void updateIcons()
if (changeInfo.groupId !== undefined) {
// Queue tab group operations to serialize with syncTabGroup and disconnectEverything
tabGroupQueue = tabGroupQueue.then(async () => {
// Query for playwriter group by title - no stale cached ID
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
const groupId = existingGroups[0]?.id
if (groupId === undefined) {
return
}
const { tabs } = store.getState()
if (changeInfo.groupId === groupId) {
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
logger.debug('Tab manually added to playwriter group:', tabId)
await connectTab(tabId)
}
} else if (tabs.has(tabId)) {
const tabInfo = tabs.get(tabId)
if (tabInfo?.state === 'connecting') {
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
tabGroupQueue = tabGroupQueue
.then(async () => {
// Query for playwriter group by title - no stale cached ID
const existingGroups = await chrome.tabGroups.query({ title: 'playwriter' })
const groupId = existingGroups[0]?.id
if (groupId === undefined) {
return
}
logger.debug('Tab manually removed from playwriter group:', tabId)
await disconnectTab(tabId)
}
}).catch((e) => {
logger.debug('onTabUpdated handler error:', e)
})
const { tabs } = store.getState()
if (changeInfo.groupId === groupId) {
if (!tabs.has(tabId) && !isRestrictedUrl(tab.url)) {
logger.debug('Tab manually added to playwriter group:', tabId)
await connectTab(tabId)
}
} else if (tabs.has(tabId)) {
const tabInfo = tabs.get(tabId)
if (tabInfo?.state === 'connecting') {
logger.debug('Tab removed from group while connecting, ignoring:', tabId)
return
}
logger.debug('Tab manually removed from playwriter group:', tabId)
await disconnectTab(tabId)
}
})
.catch((e) => {
logger.debug('onTabUpdated handler error:', e)
})
}
})
@@ -1634,14 +1681,14 @@ void updateIcons()
chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
if (message.action === 'recordingChunk') {
const { tabId, data, final } = message
if (connectionManager.ws?.readyState === WebSocket.OPEN) {
// Send metadata message first
sendMessage({
method: 'recordingData',
params: { tabId, final },
})
// Then send binary data if not final
if (data && !final) {
const buffer = new Uint8Array(data)
@@ -1653,13 +1700,13 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
logger.debug(`Buffering recording chunk for tab ${tabId} (WebSocket not ready)`)
recordingChunkBuffer.push({ tabId, data, final })
}
return false // Sync response, no need to keep channel open
}
if (message.action === 'recordingCancelled') {
const { tabId } = message
getActiveRecordings().delete(tabId)
store.setState((state) => {
const newTabs = new Map(state.tabs)
@@ -1669,16 +1716,16 @@ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
}
return { tabs: newTabs }
})
if (connectionManager.ws?.readyState === WebSocket.OPEN) {
sendMessage({
method: 'recordingCancelled',
params: { tabId },
})
}
return false
}
return false
})
+71 -190
View File
@@ -93,10 +93,7 @@ declare namespace chrome {
* identity: chrome.ghostPublicAPI.NEW_TEMPORARY_IDENTITY
* }, (tabId) => console.log('Opened tab:', tabId))
*/
export function openTab(
params: OpenTabParams,
callback?: (tabId: number) => void
): Promise<number>
export function openTab(params: OpenTabParams, callback?: (tabId: number) => void): Promise<number>
}
// ============================================================================
@@ -151,107 +148,73 @@ declare namespace chrome {
}
// Proxy CRUD operations
export function add(
proxy: AddProxyParams,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function add(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function import_(
proxy: AddProxyParams,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function import_(proxy: AddProxyParams, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function remove(
proxy_id: string,
callback?: (success: boolean) => void
): Promise<boolean>
export function remove(proxy_id: string, callback?: (success: boolean) => void): Promise<boolean>
export function removeAll(callback?: (success: boolean) => void): Promise<boolean>
export function getList(callback?: (proxies: GhostProxy[]) => void): Promise<GhostProxy[]>
export function get(
proxy_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function get(proxy_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function move(
proxy_id: string,
new_index: number,
callback?: (success: boolean) => void
): Promise<boolean>
export function move(proxy_id: string, new_index: number, callback?: (success: boolean) => void): Promise<boolean>
export function modify(
proxy_id: string,
params: ModifyProxyParams,
callback?: (proxy: GhostProxy) => void
callback?: (proxy: GhostProxy) => void,
): Promise<GhostProxy>
// Set proxy at different levels
export function setProjectProxy(
proxy_id: string,
keep_overrides: boolean,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function setSessionProxy(
session_id: string,
proxy_id: string,
keep_overrides: boolean,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function setIdentityProxy(
identity_id: string,
proxy_id: string,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function setTabProxy(
tab_id: number,
proxy_id: string,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
// Get proxy at different levels
export function getProjectProxy(callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getSessionProxy(
session_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getSessionProxy(session_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getIdentityProxy(
identity_id: string,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getIdentityProxy(identity_id: string, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
export function getTabProxy(
tab_id: number,
callback?: (proxy: GhostProxy) => void
): Promise<GhostProxy>
export function getTabProxy(tab_id: number, callback?: (proxy: GhostProxy) => void): Promise<GhostProxy>
// Clear proxy at different levels
export function clearProjectProxy(
keep_overrides: boolean,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearProjectProxy(keep_overrides: boolean, callback?: (success: boolean) => void): Promise<boolean>
export function clearSessionProxy(
session_id: string,
keep_overrides: boolean,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function clearIdentityProxy(
identity_id: string,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearIdentityProxy(identity_id: string, callback?: (success: boolean) => void): Promise<boolean>
export function clearTabProxy(
tab_id: number,
callback?: (success: boolean) => void
): Promise<boolean>
export function clearTabProxy(tab_id: number, callback?: (success: boolean) => void): Promise<boolean>
// Events
export const onAdded: chrome.events.Event<(proxy: GhostProxy) => void>
@@ -259,15 +222,9 @@ declare namespace chrome {
export const onChanged: chrome.events.Event<(proxy: GhostProxy) => void>
export const onMoved: chrome.events.Event<(proxy: GhostProxy, old_index: number) => void>
export const onProjectProxyChanged: chrome.events.Event<(proxy: GhostProxy) => void>
export const onSessionProxyChanged: chrome.events.Event<
(session_id: string, proxy: GhostProxy) => void
>
export const onTabProxyChanged: chrome.events.Event<
(tab_id: number, proxy: GhostProxy) => void
>
export const onIdentityProxyChanged: chrome.events.Event<
(identity_id: string, proxy: GhostProxy) => void
>
export const onSessionProxyChanged: chrome.events.Event<(session_id: string, proxy: GhostProxy) => void>
export const onTabProxyChanged: chrome.events.Event<(tab_id: number, proxy: GhostProxy) => void>
export const onIdentityProxyChanged: chrome.events.Event<(identity_id: string, proxy: GhostProxy) => void>
}
// ============================================================================
@@ -405,42 +362,24 @@ declare namespace chrome {
}
// Project functions
export function getProjectsList(
callback?: (projects: GhostProject[]) => void
): Promise<GhostProject[]>
export function getProjectsList(callback?: (projects: GhostProject[]) => void): Promise<GhostProject[]>
export function getProject(
project_id: string,
callback?: (project: GhostProject) => void
): Promise<GhostProject>
export function getProject(project_id: string, callback?: (project: GhostProject) => void): Promise<GhostProject>
export function getActiveProject(
callback?: (project: GhostProject) => void
): Promise<GhostProject>
export function getActiveProject(callback?: (project: GhostProject) => void): Promise<GhostProject>
export function addProject(
project: AddProjectDetails,
callback?: () => void
): Promise<void>
export function addProject(project: AddProjectDetails, callback?: () => void): Promise<void>
export function removeProject(project_id: string, callback?: () => void): Promise<void>
export function moveProject(
project_id: string,
new_index: number,
callback?: () => void
): Promise<void>
export function moveProject(project_id: string, new_index: number, callback?: () => void): Promise<void>
export function renameProject(
project_id: string,
project_name: string,
callback?: () => void
): Promise<void>
export function renameProject(project_id: string, project_name: string, callback?: () => void): Promise<void>
export function setProjectDescription(
project_id: string,
project_description: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function lockProject(project_id: string, callback?: () => void): Promise<void>
@@ -448,141 +387,115 @@ declare namespace chrome {
export function openProject(project_id: string, callback?: () => void): Promise<void>
// Archived projects
export function getArchivedProjects(
callback?: (projects: ArchivedProject[]) => void
): Promise<ArchivedProject[]>
export function getArchivedProjects(callback?: (projects: ArchivedProject[]) => void): Promise<ArchivedProject[]>
export function archiveProject(project_id: string, callback?: () => void): Promise<void>
export function restoreArchivedProject(
project_id: string,
callback?: () => void
): Promise<void>
export function restoreArchivedProject(project_id: string, callback?: () => void): Promise<void>
export function deleteArchivedProject(
project_id: string,
callback?: () => void
): Promise<void>
export function deleteArchivedProject(project_id: string, callback?: () => void): Promise<void>
// Session functions
export function getSessionsList(
project_id: string,
callback?: (sessions: GhostSession[]) => void
callback?: (sessions: GhostSession[]) => void,
): Promise<GhostSession[]>
export function getSession(
project_id: string,
session_id: string,
callback?: (session: GhostSession) => void
callback?: (session: GhostSession) => void,
): Promise<GhostSession>
export function renameSession(
project_id: string,
session_id: string,
session_name: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function changeSessionColor(
project_id: string,
session_id: string,
session_color: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function clearSessionData(
project_id: string,
session_id: string,
type: ClearSessionDataType,
callback?: () => void
callback?: () => void,
): Promise<void>
// Identity functions
export function getIdentitiesList(
callback?: (identities: GhostIdentity[]) => void
): Promise<GhostIdentity[]>
export function getIdentitiesList(callback?: (identities: GhostIdentity[]) => void): Promise<GhostIdentity[]>
export function sortIdentitiesList(
condition: IdentitySortCondition,
desc: boolean,
callback?: (identities: GhostIdentity[]) => void
callback?: (identities: GhostIdentity[]) => void,
): Promise<GhostIdentity[]>
export function getIdentity(
identity_id: string,
callback?: (identity: GhostIdentity) => void
callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity>
export function addIdentity(
identity: AddIdentityDetails,
callback?: (identity: GhostIdentity) => void
callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity>
export function removeIdentity(identity_id: string, callback?: () => void): Promise<void>
export function moveIdentity(
identity_id: string,
new_index: number,
callback?: () => void
): Promise<void>
export function moveIdentity(identity_id: string, new_index: number, callback?: () => void): Promise<void>
export function renameIdentity(
identity_id: string,
identity_name: string,
callback?: () => void
): Promise<void>
export function renameIdentity(identity_id: string, identity_name: string, callback?: () => void): Promise<void>
export function changeIdentityColor(
identity_id: string,
identity_color: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityTag(
identity_id: string,
identity_tag: string,
callback?: () => void
): Promise<void>
export function setIdentityTag(identity_id: string, identity_tag: string, callback?: () => void): Promise<void>
export function setIdentityDescription(
identity_id: string,
identity_description: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityDedication(
identity_id: string,
identity_dedication: string,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityDedicationIsStrict(
identity_id: string,
identity_dedication_is_strict: boolean,
callback?: () => void
callback?: () => void,
): Promise<void>
export function setIdentityUserAgent(
identity_id: string,
user_agent: string,
callback?: () => void
): Promise<void>
export function setIdentityUserAgent(identity_id: string, user_agent: string, callback?: () => void): Promise<void>
export function resetIdentity(
identity_id: string,
callback?: (identity: GhostIdentity) => void
callback?: (identity: GhostIdentity) => void,
): Promise<GhostIdentity>
export function clearIdentityData(
identity_id: string,
type: ClearIdentityDataType,
callback?: () => void
callback?: () => void,
): Promise<void>
export function getIdentityTabsList(
project_id: string,
identity_id: string,
callback?: (tabs: GhostTab[]) => void
callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]>
/** Opens a new tab in a new identity */
@@ -594,110 +507,80 @@ declare namespace chrome {
// Window functions
export function getWindowsList(
project_id: string,
callback?: (windows: GhostWindow[]) => void
callback?: (windows: GhostWindow[]) => void,
): Promise<GhostWindow[]>
export function getWindowTabsList(
project_id: string,
window_id: number,
callback?: (tabs: GhostTab[]) => void
callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]>
export function addWindow(window: AddWindowDetails, callback?: () => void): Promise<void>
export function removeWindow(
project_id: string,
window_id: number,
callback?: () => void
): Promise<void>
export function removeWindow(project_id: string, window_id: number, callback?: () => void): Promise<void>
// Tab functions
export function getSessionTabsList(
project_id: string,
session_id: string,
callback?: (tabs: GhostTab[]) => void
callback?: (tabs: GhostTab[]) => void,
): Promise<GhostTab[]>
export function getTab(
project_id: string,
tab_id: number,
callback?: (tab: GhostTab) => void
): Promise<GhostTab>
export function getTab(project_id: string, tab_id: number, callback?: (tab: GhostTab) => void): Promise<GhostTab>
export function addTab(tab: AddTabDetails, callback?: () => void): Promise<void>
export function removeTab(
project_id: string,
tab_id: number,
callback?: () => void
): Promise<void>
export function removeTab(project_id: string, tab_id: number, callback?: () => void): Promise<void>
export function updateTab(
project_id: string,
tab_id: number,
tab_info: TabInfo,
callback?: () => void
callback?: () => void,
): Promise<void>
// Multi-extension options
export function isMultiExtensionEnabled(
callback?: (enabled: boolean) => void
): Promise<boolean>
export function isMultiExtensionEnabled(callback?: (enabled: boolean) => void): Promise<boolean>
export function getMultiExtensionOptions(
identity_id: string,
callback?: (options: GhostMultiExtensionOption[]) => void
callback?: (options: GhostMultiExtensionOption[]) => void,
): Promise<GhostMultiExtensionOption[]>
export function setMultiExtensionOption(
identity_id: string,
id: string,
value: number,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
export function clearMultiExtensionOptions(
identity_id: string,
callback?: (success: boolean) => void
callback?: (success: boolean) => void,
): Promise<boolean>
// Events
export const onProjectWillOpen: chrome.events.Event<
(project_id: string, first_time: boolean) => void
>
export const onProjectOpened: chrome.events.Event<
(project_id: string, first_time: boolean) => void
>
export const onProjectWillOpen: chrome.events.Event<(project_id: string, first_time: boolean) => void>
export const onProjectOpened: chrome.events.Event<(project_id: string, first_time: boolean) => void>
export const onProjectClosed: chrome.events.Event<(project_id: string) => void>
export const onProjectAdded: chrome.events.Event<(project: GhostProject) => void>
export const onProjectRemoved: chrome.events.Event<(project_id: string) => void>
export const onProjectNameChanged: chrome.events.Event<
(project_id: string, new_name: string) => void
>
export const onProjectDescriptionChanged: chrome.events.Event<
(project_id: string, description: string) => void
>
export const onProjectLockStateChanged: chrome.events.Event<
(project_id: string, locked: boolean) => void
>
export const onProjectNameChanged: chrome.events.Event<(project_id: string, new_name: string) => void>
export const onProjectDescriptionChanged: chrome.events.Event<(project_id: string, description: string) => void>
export const onProjectLockStateChanged: chrome.events.Event<(project_id: string, locked: boolean) => void>
export const onIdentityAdded: chrome.events.Event<(identity: GhostIdentity) => void>
export const onIdentityRemoved: chrome.events.Event<(identity_id: string) => void>
export const onIdentityNameChanged: chrome.events.Event<
(identity_id: string, identity_name: string) => void
>
export const onIdentityColorChanged: chrome.events.Event<
(identity_id: string, identity_color: string) => void
>
export const onIdentityNameChanged: chrome.events.Event<(identity_id: string, identity_name: string) => void>
export const onIdentityColorChanged: chrome.events.Event<(identity_id: string, identity_color: string) => void>
export const onIdentityUserAgentChanged: chrome.events.Event<
(identity_id: string, identity_user_agent: string) => void
>
export const onIdentitiesChanged: chrome.events.Event<() => void>
export const onSessionAdded: chrome.events.Event<(session: GhostSession) => void>
export const onSessionRemoved: chrome.events.Event<
(project_id: string, session_id: string) => void
>
export const onSessionRemoved: chrome.events.Event<(project_id: string, session_id: string) => void>
export const onSessionNameChanged: chrome.events.Event<
(project_id: string, session_id: string, new_name: string) => void
>
@@ -712,9 +595,7 @@ declare namespace chrome {
export const onTabUpdated: chrome.events.Event<(project_id: string, tab_id: number) => void>
export const onWindowAdded: chrome.events.Event<(window: GhostWindow) => void>
export const onWindowRemoved: chrome.events.Event<
(project_id: string, window_id: number) => void
>
export const onWindowRemoved: chrome.events.Event<(project_id: string, window_id: number) => void>
}
// ============================================================================
+31 -27
View File
@@ -55,24 +55,28 @@ export type OffscreenMessage =
| OffscreenCancelRecordingMessage
// Offscreen document response types
export type OffscreenStartRecordingResult = {
success: true
tabId: number
startedAt: number
mimeType: string
} | {
success: false
error: string
}
export type OffscreenStartRecordingResult =
| {
success: true
tabId: number
startedAt: number
mimeType: string
}
| {
success: false
error: string
}
export type OffscreenStopRecordingResult = {
success: true
tabId: number
duration: number
} | {
success: false
error: string
}
export type OffscreenStopRecordingResult =
| {
success: true
tabId: number
duration: number
}
| {
success: false
error: string
}
export interface OffscreenIsRecordingResult {
isRecording: boolean
@@ -80,13 +84,15 @@ export interface OffscreenIsRecordingResult {
startedAt?: number
}
export type OffscreenCancelRecordingResult = {
success: true
tabId: number
} | {
success: false
error: string
}
export type OffscreenCancelRecordingResult =
| {
success: true
tabId: number
}
| {
success: false
error: string
}
// Messages sent FROM offscreen TO background
export interface OffscreenRecordingChunkMessage {
@@ -101,6 +107,4 @@ export interface OffscreenRecordingCancelledMessage {
tabId: number
}
export type OffscreenOutgoingMessage =
| OffscreenRecordingChunkMessage
| OffscreenRecordingCancelledMessage
export type OffscreenOutgoingMessage = OffscreenRecordingChunkMessage | OffscreenRecordingCancelledMessage
+7 -7
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>Playwriter Offscreen</title>
</head>
<body>
<script src="./offscreen.ts" type="module"></script>
</body>
<head>
<title>Playwriter Offscreen</title>
</head>
<body>
<script src="./offscreen.ts" type="module"></script>
</body>
</html>
+22 -12
View File
@@ -61,7 +61,11 @@ interface OffscreenRecordingState {
// Map of tabId -> recording state for concurrent recording support
const recordings = new Map<number, OffscreenRecordingState>()
type OffscreenResult = OffscreenStartRecordingResult | OffscreenStopRecordingResult | OffscreenIsRecordingResult | OffscreenCancelRecordingResult
type OffscreenResult =
| OffscreenStartRecordingResult
| OffscreenStopRecordingResult
| OffscreenIsRecordingResult
| OffscreenCancelRecordingResult
chrome.runtime.onMessage.addListener((message: OffscreenMessage, _sender, sendResponse) => {
handleMessage(message).then(sendResponse)
@@ -93,12 +97,14 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
try {
// Build Chrome-specific tabCapture constraints
// These use Chrome's proprietary API that TypeScript doesn't have built-in types for
const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio ? {
mandatory: {
chromeMediaSource: 'tab',
chromeMediaSourceId: params.streamId,
}
} : false
const audioConstraints: ChromeTabCaptureAudioConstraints | false = params.audio
? {
mandatory: {
chromeMediaSource: 'tab',
chromeMediaSourceId: params.streamId,
},
}
: false
const videoConstraints: ChromeTabCaptureVideoConstraints = {
mandatory: {
@@ -106,7 +112,7 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
chromeMediaSourceId: params.streamId,
minFrameRate: params.frameRate || 30,
maxFrameRate: params.frameRate || 30,
}
},
}
// Get media stream from the streamId provided by tabCapture.getMediaStreamId
@@ -160,13 +166,13 @@ async function handleStartRecording(params: OffscreenStartRecordingMessage): Pro
const timeout = setTimeout(() => {
reject(new Error('MediaRecorder failed to start within 5 seconds'))
}, 5000)
recorder.onstart = () => {
clearTimeout(timeout)
console.log(`MediaRecorder started for tab ${tabId}`)
resolve()
}
// Start with 1 second chunks
recorder.start(1000)
})
@@ -206,7 +212,9 @@ async function handleStopRecording(params: OffscreenStopRecordingMessage): Promi
})
// Stop all tracks
stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() })
stream.getTracks().forEach((track: MediaStreamTrack) => {
track.stop()
})
const duration = Date.now() - startedAt
@@ -260,7 +268,9 @@ function handleCancelRecordingForTab(tabId: number): OffscreenCancelRecordingRes
if (recorder.state !== 'inactive') {
recorder.stop()
}
stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() })
stream.getTracks().forEach((track: MediaStreamTrack) => {
track.stop()
})
chrome.runtime.sendMessage({
action: 'recordingCancelled',
+11 -8
View File
@@ -92,7 +92,10 @@ function updateTabRecordingState(tabId: number, isRecording: boolean): void {
export async function handleStartRecording(params: StartRecordingParams): Promise<StartRecordingResult> {
const tabId = resolveTabIdFromSessionId(params.sessionId)
if (!tabId) {
return { success: false, error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.' }
return {
success: false,
error: 'No connected tab found for recording. Click the Playwriter extension icon on the tab you want to record.',
}
}
if (activeRecordings.has(tabId)) {
@@ -133,7 +136,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...')
// Send message to offscreen document to start recording
const result = await chrome.runtime.sendMessage({
const result = (await chrome.runtime.sendMessage({
action: 'startRecording',
tabId,
streamId,
@@ -141,7 +144,7 @@ export async function handleStartRecording(params: StartRecordingParams): Promis
videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000,
audioBitsPerSecond: params.audioBitsPerSecond ?? 128000,
audio: params.audio ?? false,
}) as OffscreenStartRecordingResult
})) as OffscreenStartRecordingResult
if (!result.success) {
return { success: false, error: result.error || 'Failed to start recording in offscreen document' }
@@ -179,16 +182,16 @@ export async function handleStopRecording(params: StopRecordingParams): Promise<
try {
// Send message to offscreen document to stop recording - include tabId for concurrent support
const result = await chrome.runtime.sendMessage({
const result = (await chrome.runtime.sendMessage({
action: 'stopRecording',
tabId,
}) as OffscreenStopRecordingResult
})) as OffscreenStopRecordingResult
if (!result.success) {
return { success: false, error: result.error || 'Failed to stop recording in offscreen document' }
}
const duration = result.duration || (Date.now() - recording.startedAt)
const duration = result.duration || Date.now() - recording.startedAt
// Clean up
activeRecordings.delete(tabId)
@@ -216,10 +219,10 @@ export async function handleIsRecording(params: IsRecordingParams): Promise<IsRe
// Check with offscreen document for actual recording state - include tabId for concurrent support
try {
const result = await chrome.runtime.sendMessage({
const result = (await chrome.runtime.sendMessage({
action: 'isRecording',
tabId,
}) as OffscreenIsRecordingResult
})) as OffscreenIsRecordingResult
return {
isRecording: result.isRecording,
+1 -1
View File
@@ -18,7 +18,7 @@ export interface ExtensionState {
errorText: string | undefined
}
/**
/**
* Recording state - stored in service worker to track active recordings.
* The actual MediaRecorder/MediaStream live in the offscreen document.
*/
+23 -24
View File
@@ -1,29 +1,27 @@
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { defineConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Bundle the playwriter package version into the extension so it can report
// which playwriter version it was built against. CLI/MCP use this to warn
// when the extension is outdated.
const playwriterPkg = JSON.parse(
readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8')
);
const playwriterPkg = JSON.parse(readFileSync(resolve(__dirname, '../playwriter/package.json'), 'utf-8'))
const defineEnv: Record<string, string> = {
'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'),
'__PLAYWRITER_VERSION__': JSON.stringify(playwriterPkg.version),
};
__PLAYWRITER_VERSION__: JSON.stringify(playwriterPkg.version),
}
if (process.env.TESTING) {
defineEnv['import.meta.env.TESTING'] = 'true';
defineEnv['import.meta.env.TESTING'] = 'true'
}
// Allow tests to build per-port extension outputs to avoid parallel run conflicts.
const outDir = process.env.PLAYWRITER_EXTENSION_DIST || 'dist';
const outDir = process.env.PLAYWRITER_EXTENSION_DIST || 'dist'
export default defineConfig({
plugins: [
@@ -31,33 +29,34 @@ export default defineConfig({
targets: [
{
src: resolve(__dirname, 'icons/*'),
dest: 'icons'
dest: 'icons',
},
{
src: resolve(__dirname, 'manifest.json'),
dest: '.',
transform: (content) => {
const manifest = JSON.parse(content);
const manifest = JSON.parse(content)
// Only include tabs permission during testing
if (process.env.TESTING) {
if (!manifest.permissions.includes('tabs')) {
manifest.permissions.push('tabs');
manifest.permissions.push('tabs')
}
}
// Inject key for stable extension ID in dev/test builds (not production)
// This ensures all developers get the same extension ID: pebbngnfojnignonigcnkdilknapkgid
if (!process.env.PRODUCTION) {
manifest.key = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCJoq5UYhOo5x8s50pVBUHjQ8idyUHnZFDj1JspWJPe6kvM7RFIaE/y5WTAH05kuK0R7v/ipcGA4ywA5wKdPKHZzkl5xstlNPj0Ivu4CqLobU7eY5G3k3Gq7wql2pbwb/A8Nat4VLbfBjQLA6TGWd3LQOHS6M0B3AvrtEw7DLDUdGKh4SCLewCbdlDIzpXQwKOzrRPyLFBwj9eEeITy5aNwJ9r9JMNBvACVZiRCHsGI6DufU+OiIO232l/8OoNNt6kdTMyNgiqOogFApXPJwREUwZHGqjXD3s6bXiBIQtwkNyZfemHKkxj6g/fhCV2EMgTY6+ikQEY1gEJMrRVmcYQIDAQAB';
manifest.key =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwCJoq5UYhOo5x8s50pVBUHjQ8idyUHnZFDj1JspWJPe6kvM7RFIaE/y5WTAH05kuK0R7v/ipcGA4ywA5wKdPKHZzkl5xstlNPj0Ivu4CqLobU7eY5G3k3Gq7wql2pbwb/A8Nat4VLbfBjQLA6TGWd3LQOHS6M0B3AvrtEw7DLDUdGKh4SCLewCbdlDIzpXQwKOzrRPyLFBwj9eEeITy5aNwJ9r9JMNBvACVZiRCHsGI6DufU+OiIO232l/8OoNNt6kdTMyNgiqOogFApXPJwREUwZHGqjXD3s6bXiBIQtwkNyZfemHKkxj6g/fhCV2EMgTY6+ikQEY1gEJMrRVmcYQIDAQAB'
}
return JSON.stringify(manifest, null, 2);
}
return JSON.stringify(manifest, null, 2)
},
},
]
})
],
}),
],
build: {
@@ -76,5 +75,5 @@ export default defineConfig({
},
},
},
define: defineEnv
});
define: defineEnv,
})