format
This commit is contained in:
+10
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
|
||||
Vendored
+71
-190
@@ -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>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user