Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9deb748
feat: add remote access via Hub + WebRTC P2P
jsyqrt May 17, 2026
fdd4d45
feat: remote access improvements - auth, TURN, relay-first transport
jsyqrt May 17, 2026
eb0c614
refactor: switch to P2P-first transport, relay as fallback
jsyqrt May 17, 2026
f20bee3
feat: show connected peers list with transport type in remote access …
jsyqrt May 17, 2026
2ea5abd
fix: add message chunking for large P2P DataChannel responses
jsyqrt May 17, 2026
3898dc0
fix: fix TURN server config - RelayType not exported from node-datach…
jsyqrt May 18, 2026
680b70f
feat: redesign settings navigation - desktop sidebar hidden, mobile d…
jsyqrt May 18, 2026
2c07f32
fix: optimize mobile settings page and navigation
jsyqrt May 18, 2026
5c82f08
feat: mobile navigation optimization
jsyqrt May 18, 2026
860bab0
feat: add unread message system, 3-layer mobile team nav, and global …
jsyqrt May 18, 2026
694c5e1
feat: auto-click Chrome "Allow remote debugging" dialog for browser a…
jsyqrt May 19, 2026
a54dd93
fix: overhaul browser automation logic — remove blind clicks, add sma…
jsyqrt May 19, 2026
7289c05
fix: extend MCP initialize/tools/list timeout to 60s for chrome-devto…
jsyqrt May 19, 2026
226f420
fix: keep peer session alive on ICE failure, relay-capable ping/pong
jsyqrt May 20, 2026
4b32993
fix: ping timeout killing P2P connections immediately after open
jsyqrt May 20, 2026
10bc60f
fix: handle stream error in proxy to close remote stream properly
jsyqrt May 20, 2026
fe538f3
fix: prevent markdown image flickering during streaming re-renders
jsyqrt May 20, 2026
384cf9a
feat: add Chrome extension for browser automation with dynamic bridge…
jsyqrt May 20, 2026
b0c47ce
fix: resolve 3 lint errors (eqeqeq, consistent-type-imports, prefer-c…
jsyqrt May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ COPY packages/gui/package.json packages/gui/
COPY packages/a2a/package.json packages/a2a/
COPY packages/cli/package.json packages/cli/
COPY packages/web-ui/package.json packages/web-ui/
COPY packages/chrome-extension/package.json packages/chrome-extension/
RUN pnpm install --frozen-lockfile || pnpm install

# ── Stage 2: Build all packages ─────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
"esbuild",
"node-datachannel"
]
}
}
Binary file added packages/chrome-extension/icons/icon128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/chrome-extension/icons/icon16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/chrome-extension/icons/icon48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions packages/chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Markus Browser Automation",
"description": "Enables Markus AI agents to automate browser tasks without the remote debugging dialog.",
"version": "1.0.0",
"permissions": ["debugger", "tabs", "activeTab", "scripting"],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background.js",
"type": "module"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png"
},
"default_title": "Markus Browser Automation",
"default_popup": "popup.html"
}
}
138 changes: 138 additions & 0 deletions packages/chrome-extension/pack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Pack the Chrome extension into a zip file ready for distribution.
* Includes only the files needed to load the extension in Chrome.
*
* Output: dist/markus-browser-extension.zip
*/
import { createWriteStream, readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, relative } from 'node:path';
import { createDeflateRaw } from 'node:zlib';

const ROOT = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
const OUT = join(ROOT, 'dist', 'markus-browser-extension.zip');

const FILES = [
'manifest.json',
'popup.html',
'popup.js',
'dist/background.js',
'icons/icon16.png',
'icons/icon48.png',
'icons/icon128.png',
];

// Verify all files exist
for (const f of FILES) {
if (!existsSync(join(ROOT, f))) {
console.error(`Missing file: ${f}. Run "pnpm run build" first.`);
process.exit(1);
}
}

// Minimal zip writer (no external deps)
class ZipWriter {
constructor(outPath) {
this.entries = [];
this.stream = createWriteStream(outPath);
this.offset = 0;
}

async addFile(archivePath, content) {
const header = Buffer.alloc(30);
const nameBytes = Buffer.from(archivePath, 'utf8');
const compressed = await this._deflate(content);
const crc = this._crc32(content);

// Local file header
header.writeUInt32LE(0x04034b50, 0); // signature
header.writeUInt16LE(20, 4); // version needed
header.writeUInt16LE(0, 6); // flags
header.writeUInt16LE(8, 8); // compression: deflate
header.writeUInt16LE(0, 10); // mod time
header.writeUInt16LE(0, 12); // mod date
header.writeUInt32LE(crc, 14); // crc-32
header.writeUInt32LE(compressed.length, 18); // compressed size
header.writeUInt32LE(content.length, 22); // uncompressed size
header.writeUInt16LE(nameBytes.length, 26); // filename length
header.writeUInt16LE(0, 28); // extra field length

const localOffset = this.offset;
this._write(header);
this._write(nameBytes);
this._write(compressed);

this.entries.push({ archivePath, nameBytes, crc, compressedSize: compressed.length, uncompressedSize: content.length, localOffset });
}

finish() {
const cdStart = this.offset;
for (const e of this.entries) {
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0); // signature
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0, 8); // flags
cdh.writeUInt16LE(8, 10); // compression
cdh.writeUInt16LE(0, 12); // time
cdh.writeUInt16LE(0, 14); // date
cdh.writeUInt32LE(e.crc, 16);
cdh.writeUInt32LE(e.compressedSize, 20);
cdh.writeUInt32LE(e.uncompressedSize, 24);
cdh.writeUInt16LE(e.nameBytes.length, 28);
cdh.writeUInt16LE(0, 30); // extra
cdh.writeUInt16LE(0, 32); // comment
cdh.writeUInt16LE(0, 34); // disk
cdh.writeUInt16LE(0, 36); // internal attrs
cdh.writeUInt32LE(0, 38); // external attrs
cdh.writeUInt32LE(e.localOffset, 42);
this._write(cdh);
this._write(e.nameBytes);
}
const cdSize = this.offset - cdStart;

// End of central directory
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(this.entries.length, 8);
eocd.writeUInt16LE(this.entries.length, 10);
eocd.writeUInt32LE(cdSize, 12);
eocd.writeUInt32LE(cdStart, 16);
this._write(eocd);
this.stream.end();
}

_write(buf) {
this.stream.write(buf);
this.offset += buf.length;
}

_deflate(data) {
return new Promise((resolve, reject) => {
const chunks = [];
const deflater = createDeflateRaw();
deflater.on('data', c => chunks.push(c));
deflater.on('end', () => resolve(Buffer.concat(chunks)));
deflater.on('error', reject);
deflater.end(data);
});
}

_crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i];
for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
}

const zip = new ZipWriter(OUT);
for (const f of FILES) {
const content = readFileSync(join(ROOT, f));
await zip.addFile(f, content);
}
zip.finish();

const size = statSync(OUT).size;
console.log(`Packed ${FILES.length} files → ${OUT} (${(size / 1024).toFixed(1)} KB)`);
16 changes: 16 additions & 0 deletions packages/chrome-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@markus/chrome-extension",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "esbuild src/background.ts --bundle --outfile=dist/background.js --format=esm --target=es2022 --platform=browser",
"watch": "esbuild src/background.ts --bundle --outfile=dist/background.js --format=esm --target=es2022 --platform=browser --watch",
"pack": "pnpm run build && node pack.mjs",
"prepare": "pnpm run build"
},
"devDependencies": {
"@types/chrome": "^0.0.300",
"esbuild": "^0.25.0",
"typescript": "^5.6.0"
}
}
84 changes: 84 additions & 0 deletions packages/chrome-extension/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html>
<head>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 280px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
padding: 16px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.header img { width: 24px; height: 24px; }
.header h1 { font-size: 14px; font-weight: 600; color: #fff; }
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 10px;
}
.status.connected { background: rgba(34, 197, 94, 0.15); }
.status.disconnected { background: rgba(107, 114, 128, 0.15); }
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot.green { background: #22c55e; }
.dot.gray { background: #6b7280; }
.status-text { font-size: 13px; font-weight: 500; }
.info {
font-size: 11px;
color: #9ca3af;
line-height: 1.5;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
.info-label { color: #9ca3af; }
.info-value { color: #d1d5db; font-family: monospace; }
.divider {
height: 1px;
background: rgba(255,255,255,0.1);
margin: 10px 0;
}
</style>
</head>
<body>
<div class="header">
<img src="icons/icon48.png" alt="">
<h1>Markus Browser</h1>
</div>
<div id="status" class="status disconnected">
<span id="dot" class="dot gray"></span>
<span id="statusText" class="status-text">Checking...</span>
</div>
<div class="info">
<div class="info-row">
<span class="info-label">Bridge</span>
<span id="bridgeUrl" class="info-value">ws://127.0.0.1:9333</span>
</div>
<div class="info-row">
<span class="info-label">Pages tracked</span>
<span id="pageCount" class="info-value">0</span>
</div>
</div>
<div class="divider"></div>
<div class="info" style="margin-top: 4px;">
Enables Markus agents to automate Chrome without the remote debugging dialog.
</div>

<script src="popup.js"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions packages/chrome-extension/popup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Query the background service worker for connection status
chrome.runtime.sendMessage({ type: 'getStatus' }, (response) => {
if (chrome.runtime.lastError || !response) {
document.getElementById('statusText').textContent = 'Extension loading...';
return;
}

const statusEl = document.getElementById('status');
const dotEl = document.getElementById('dot');
const textEl = document.getElementById('statusText');
const pageCountEl = document.getElementById('pageCount');

if (response.connected) {
statusEl.className = 'status connected';
dotEl.className = 'dot green';
textEl.textContent = 'Connected to Markus';
} else {
statusEl.className = 'status disconnected';
dotEl.className = 'dot gray';
textEl.textContent = 'Not connected';
}

pageCountEl.textContent = String(response.pageCount || 0);
document.getElementById('bridgeUrl').textContent = response.bridgeUrl || 'ws://127.0.0.1:9333';
});
56 changes: 56 additions & 0 deletions packages/chrome-extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Chrome Extension Service Worker — main entry point.
*
* Connects to Markus browser bridge via WebSocket and registers
* all tool handlers that mirror chrome-devtools-mcp's API.
*/

import { BridgeClient } from './protocol.js';
import { PageManager } from './page-manager.js';
import { registerNavigationTools } from './tools/navigation.js';
import { registerInputTools } from './tools/input.js';
import { registerInspectionTools, setupConsoleListener } from './tools/inspection.js';
import { registerNetworkTools, setupNetworkListener } from './tools/network.js';

const pm = new PageManager();
const client = new BridgeClient();

// Register all tool handlers
registerNavigationTools((name, handler) => client.registerHandler(name, handler), pm);
registerInputTools((name, handler) => client.registerHandler(name, handler), pm);
registerInspectionTools((name, handler) => client.registerHandler(name, handler), pm);
registerNetworkTools((name, handler) => client.registerHandler(name, handler), pm);

// Set up CDP event listeners
setupConsoleListener();
setupNetworkListener();

// Clean up page state when tabs are closed
chrome.tabs.onRemoved.addListener((tabId) => {
pm.removeByTabId(tabId);
client.send({ event: 'tab_closed', data: { tabId } });
});

// Handle debugger detach events
chrome.debugger.onDetach.addListener((source) => {
if (source.tabId) {
pm.setDebuggerAttached(source.tabId, false);
}
});

// Handle popup status queries
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'getStatus') {
sendResponse({
connected: client.connected,
pageCount: pm.getAllPages().length,
bridgeUrl: 'ws://127.0.0.1:9333',
});
return true;
}
});

// Connect to bridge
client.connect();

console.log('[Markus] Browser automation extension initialized');

Check warning on line 56 in packages/chrome-extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected console statement. Only these console methods are allowed: warn, error
Loading
Loading