wip extension

This commit is contained in:
Francesco Renzi
2026-01-15 21:16:55 +00:00
committed by GitHub
parent bbe97ff1c8
commit 15b7034088
16 changed files with 3353 additions and 0 deletions

File diff suppressed because it is too large Load Diff

176
browser-ext/README.md Normal file
View File

@@ -0,0 +1,176 @@
# Actions DAP Debugger - Browser Extension
A Chrome extension that enables interactive debugging of GitHub Actions workflows directly in the browser. Connects to the runner's DAP server via a WebSocket proxy.
## Features
- **Variable Inspection**: Browse workflow context variables (`github`, `env`, `steps`, etc.)
- **REPL Console**: Evaluate expressions and run shell commands
- **Step Control**: Step forward, step back, continue, and reverse continue
- **GitHub Integration**: Debugger pane injects directly into the job page
## Quick Start
### 1. Start the WebSocket Proxy
The proxy bridges WebSocket connections from the browser to the DAP TCP server.
```bash
cd browser-ext/proxy
npm install
node proxy.js
```
The proxy listens on `ws://localhost:4712` and connects to the DAP server at `tcp://localhost:4711`.
### 2. Load the Extension in Chrome
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `browser-ext` directory
### 3. Start a Debug Session
1. Go to your GitHub repository
2. Navigate to Actions and select a workflow run
3. Click "Re-run jobs" → check "Enable debug logging"
4. Wait for the runner to display "DAP debugger waiting for connection..."
### 4. Connect the Extension
1. Navigate to the job page (`github.com/.../actions/runs/.../job/...`)
2. Click the extension icon in Chrome toolbar
3. Click "Connect"
4. The debugger pane will appear above the first workflow step
## Usage
### Variable Browser (Left Panel)
Click on scope names to expand and view variables:
- **Globals**: `github`, `env`, `runner` contexts
- **Job Outputs**: Outputs from previous jobs
- **Step Outputs**: Outputs from previous steps
### Console (Right Panel)
Enter expressions or commands:
```bash
# Evaluate expressions
${{ github.ref }}
${{ github.event_name }}
${{ env.MY_VAR }}
# Run shell commands (prefix with !)
!ls -la
!cat package.json
!env | grep GITHUB
# Modify variables
!export MY_VAR=new_value
```
### Control Buttons
| Button | Action | Description |
|--------|--------|-------------|
| ⏮ | Reverse Continue | Go back to first checkpoint |
| ◀ | Step Back | Go to previous checkpoint |
| ▶ | Continue | Run until next breakpoint/end |
| ⏭ | Step (Next) | Step to next workflow step |
## Architecture
```
Browser Extension ──WebSocket──► Proxy ──TCP──► Runner DAP Server
(port 4712) (port 4711)
```
The WebSocket proxy handles DAP message framing (Content-Length headers) and provides a browser-compatible connection.
## Configuration
### Proxy Settings
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `WS_PORT` | 4712 | WebSocket server port |
| `DAP_HOST` | 127.0.0.1 | DAP server host |
| `DAP_PORT` | 4711 | DAP server port |
Or use CLI arguments:
```bash
node proxy.js --ws-port 4712 --dap-host 127.0.0.1 --dap-port 4711
```
### Extension Settings
Click the extension popup to configure:
- **Proxy Host**: Default `localhost`
- **Proxy Port**: Default `4712`
## File Structure
```
browser-ext/
├── manifest.json # Extension configuration
├── background/
│ └── background.js # Service worker - DAP client
├── content/
│ ├── content.js # UI injection and interaction
│ └── content.css # Debugger pane styling
├── popup/
│ ├── popup.html # Extension popup UI
│ ├── popup.js # Popup logic
│ └── popup.css # Popup styling
├── lib/
│ └── dap-protocol.js # DAP message helpers
├── proxy/
│ ├── proxy.js # WebSocket-to-TCP bridge
│ └── package.json # Proxy dependencies
└── icons/
├── icon16.png
├── icon48.png
└── icon128.png
```
## Troubleshooting
### "Failed to connect to DAP server"
1. Ensure the proxy is running: `node proxy.js`
2. Ensure the runner is waiting for a debugger connection
3. Check that debug logging is enabled for the job
### Debugger pane doesn't appear
1. Verify you're on a job page (`/actions/runs/*/job/*`)
2. Open DevTools and check for console errors
3. Reload the page after loading the extension
### Variables don't load
1. Wait for the "stopped" event (status shows PAUSED)
2. Click on a scope to expand it
3. Check the console for error messages
## Development
### Modifying the Extension
After making changes:
1. Go to `chrome://extensions/`
2. Click the refresh icon on the extension card
3. Reload the GitHub job page
### Debugging
- **Background script**: Inspect via `chrome://extensions/` → "Inspect views: service worker"
- **Content script**: Use DevTools on the GitHub page
- **Proxy**: Watch terminal output for message logs
## Security Note
The proxy and extension are designed for local development. The proxy only accepts connections from localhost. Do not expose the proxy to the network without additional security measures.

View File

@@ -0,0 +1,292 @@
/**
* Background Script - DAP Client
*
* Service worker that manages WebSocket connection to the proxy
* and handles DAP protocol communication.
*/
// Connection state
let ws = null;
let connectionStatus = 'disconnected'; // disconnected, connecting, connected, paused, running
let sequenceNumber = 1;
const pendingRequests = new Map(); // seq -> { resolve, reject, command }
// Default configuration
const DEFAULT_URL = 'ws://localhost:4712';
/**
* Connect to the WebSocket proxy
*/
function connect(url) {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('[Background] Already connected');
return;
}
connectionStatus = 'connecting';
broadcastStatus();
// Use provided URL or default
const wsUrl = url || DEFAULT_URL;
console.log(`[Background] Connecting to ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.onopen = async () => {
console.log('[Background] WebSocket connected');
connectionStatus = 'connected';
broadcastStatus();
// Initialize DAP session
try {
await initializeDapSession();
} catch (error) {
console.error('[Background] Failed to initialize DAP session:', error);
connectionStatus = 'error';
broadcastStatus();
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleDapMessage(message);
} catch (error) {
console.error('[Background] Failed to parse message:', error);
}
};
ws.onclose = (event) => {
console.log(`[Background] WebSocket closed: ${event.code} ${event.reason}`);
ws = null;
connectionStatus = 'disconnected';
broadcastStatus();
// Reject any pending requests
for (const [seq, pending] of pendingRequests) {
pending.reject(new Error('Connection closed'));
}
pendingRequests.clear();
};
ws.onerror = (event) => {
console.error('[Background] WebSocket error:', event);
connectionStatus = 'error';
broadcastStatus();
};
}
/**
* Disconnect from the WebSocket proxy
*/
function disconnect() {
if (ws) {
// Send disconnect request to DAP server first
sendDapRequest('disconnect', {}).catch(() => {});
ws.close(1000, 'User disconnected');
ws = null;
}
connectionStatus = 'disconnected';
broadcastStatus();
}
/**
* Initialize DAP session (initialize + attach + configurationDone)
*/
async function initializeDapSession() {
// 1. Initialize
const initResponse = await sendDapRequest('initialize', {
clientID: 'browser-extension',
clientName: 'Actions DAP Debugger',
adapterID: 'github-actions-runner',
pathFormat: 'path',
linesStartAt1: true,
columnsStartAt1: true,
supportsVariableType: true,
supportsVariablePaging: true,
supportsRunInTerminalRequest: false,
supportsProgressReporting: false,
supportsInvalidatedEvent: true,
});
console.log('[Background] Initialize response:', initResponse);
// 2. Attach to running session
const attachResponse = await sendDapRequest('attach', {});
console.log('[Background] Attach response:', attachResponse);
// 3. Configuration done
const configResponse = await sendDapRequest('configurationDone', {});
console.log('[Background] ConfigurationDone response:', configResponse);
}
/**
* Send a DAP request and return a promise for the response
*/
function sendDapRequest(command, args = {}) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
reject(new Error('Not connected'));
return;
}
const seq = sequenceNumber++;
const request = {
seq,
type: 'request',
command,
arguments: args,
};
console.log(`[Background] Sending DAP request: ${command} (seq: ${seq})`);
pendingRequests.set(seq, { resolve, reject, command });
// Set timeout for request
setTimeout(() => {
if (pendingRequests.has(seq)) {
pendingRequests.delete(seq);
reject(new Error(`Request timed out: ${command}`));
}
}, 30000);
ws.send(JSON.stringify(request));
});
}
/**
* Handle incoming DAP message (response or event)
*/
function handleDapMessage(message) {
if (message.type === 'response') {
handleDapResponse(message);
} else if (message.type === 'event') {
handleDapEvent(message);
} else if (message.type === 'proxy-error') {
console.error('[Background] Proxy error:', message.message);
connectionStatus = 'error';
broadcastStatus();
}
}
/**
* Handle DAP response
*/
function handleDapResponse(response) {
const pending = pendingRequests.get(response.request_seq);
if (!pending) {
console.warn(`[Background] No pending request for seq ${response.request_seq}`);
return;
}
pendingRequests.delete(response.request_seq);
if (response.success) {
console.log(`[Background] DAP response success: ${response.command}`);
pending.resolve(response.body || {});
} else {
console.error(`[Background] DAP response error: ${response.command} - ${response.message}`);
pending.reject(new Error(response.message || 'Unknown error'));
}
}
/**
* Handle DAP event
*/
function handleDapEvent(event) {
console.log(`[Background] DAP event: ${event.event}`, event.body);
switch (event.event) {
case 'initialized':
// DAP server is ready
break;
case 'stopped':
connectionStatus = 'paused';
broadcastStatus();
break;
case 'continued':
connectionStatus = 'running';
broadcastStatus();
break;
case 'terminated':
connectionStatus = 'disconnected';
broadcastStatus();
break;
case 'output':
// Output event - forward to content scripts
break;
}
// Broadcast event to all content scripts
broadcastEvent(event);
}
/**
* Broadcast connection status to popup and content scripts
*/
function broadcastStatus() {
// Broadcast to all extension contexts
chrome.runtime.sendMessage({ type: 'status-changed', status: connectionStatus }).catch(() => {});
// Broadcast to content scripts
chrome.tabs.query({ url: 'https://github.com/*/*/actions/runs/*/job/*' }, (tabs) => {
tabs.forEach((tab) => {
chrome.tabs.sendMessage(tab.id, { type: 'status-changed', status: connectionStatus }).catch(() => {});
});
});
}
/**
* Broadcast DAP event to content scripts
*/
function broadcastEvent(event) {
chrome.tabs.query({ url: 'https://github.com/*/*/actions/runs/*/job/*' }, (tabs) => {
tabs.forEach((tab) => {
chrome.tabs.sendMessage(tab.id, { type: 'dap-event', event }).catch(() => {});
});
});
}
/**
* Message handler for requests from popup and content scripts
*/
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('[Background] Received message:', message.type);
switch (message.type) {
case 'get-status':
sendResponse({ status: connectionStatus });
return false;
case 'connect':
connect(message.url || DEFAULT_URL);
sendResponse({ status: connectionStatus });
return false;
case 'disconnect':
disconnect();
sendResponse({ status: connectionStatus });
return false;
case 'dap-request':
// Handle DAP request from content script
sendDapRequest(message.command, message.args || {})
.then((body) => {
sendResponse({ success: true, body });
})
.catch((error) => {
sendResponse({ success: false, error: error.message });
});
return true; // Will respond asynchronously
default:
console.warn('[Background] Unknown message type:', message.type);
return false;
}
});
// Log startup
console.log('[Background] Actions DAP Debugger background script loaded');

View File

@@ -0,0 +1,307 @@
/**
* Content Script Styles
*
* Matches GitHub's Primer design system for seamless integration.
* Uses CSS custom properties for light/dark mode support.
*/
/* Debugger Pane Container */
.dap-debugger-pane {
background-color: var(--bgColor-default, #0d1117);
border-color: var(--borderColor-default, #30363d) !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
font-size: 14px;
}
/* Header */
.dap-header {
background-color: var(--bgColor-muted, #161b22);
}
.dap-header .octicon {
color: var(--fgColor-muted, #8b949e);
}
.dap-step-info {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Status Labels */
.dap-status-label {
flex-shrink: 0;
}
.Label--attention {
background-color: #9e6a03 !important;
color: #ffffff !important;
border: none !important;
}
.Label--success {
background-color: #238636 !important;
color: #ffffff !important;
border: none !important;
}
.Label--danger {
background-color: #da3633 !important;
color: #ffffff !important;
border: none !important;
}
.Label--secondary {
background-color: #30363d !important;
color: #8b949e !important;
border: none !important;
}
/* Content Area */
.dap-content {
min-height: 200px;
max-height: 400px;
}
/* Scopes Panel */
.dap-scopes {
border-color: var(--borderColor-default, #30363d) !important;
min-width: 150px;
}
.dap-scope-header {
background-color: var(--bgColor-muted, #161b22);
font-size: 12px;
}
.dap-scope-tree {
font-size: 12px;
line-height: 1.6;
}
/* Tree Nodes */
.dap-tree-node {
padding: 1px 0;
}
.dap-tree-content {
display: flex;
align-items: flex-start;
padding: 2px 4px;
border-radius: 3px;
}
.dap-tree-content:hover {
background-color: var(--bgColor-muted, #161b22);
}
.dap-tree-children {
margin-left: 16px;
border-left: 1px solid var(--borderColor-muted, #21262d);
padding-left: 8px;
}
.dap-expand-icon {
display: inline-block;
width: 16px;
text-align: center;
color: var(--fgColor-muted, #8b949e);
font-size: 10px;
flex-shrink: 0;
user-select: none;
}
.dap-tree-node .text-bold {
color: var(--fgColor-default, #e6edf3);
font-weight: 600;
word-break: break-word;
}
.dap-tree-node .color-fg-muted {
color: var(--fgColor-muted, #8b949e);
word-break: break-word;
}
/* REPL Console */
.dap-repl {
display: flex;
flex-direction: column;
}
.dap-repl-header {
background-color: var(--bgColor-muted, #161b22);
font-size: 12px;
flex-shrink: 0;
}
.dap-repl-output {
background-color: var(--bgColor-inset, #010409);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.5;
padding: 8px;
flex: 1;
overflow-y: auto;
min-height: 100px;
}
.dap-output-input {
color: var(--fgColor-muted, #8b949e);
}
.dap-output-result {
color: var(--fgColor-default, #e6edf3);
}
.dap-output-stdout {
color: var(--fgColor-default, #e6edf3);
}
.dap-output-error {
color: var(--fgColor-danger, #f85149);
}
/* REPL Input */
.dap-repl-input {
flex-shrink: 0;
}
.dap-repl-input input {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
background-color: var(--bgColor-inset, #010409) !important;
border-color: var(--borderColor-default, #30363d) !important;
color: var(--fgColor-default, #e6edf3) !important;
width: 100%;
}
.dap-repl-input input:focus {
border-color: var(--focus-outlineColor, #1f6feb) !important;
outline: none;
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
}
.dap-repl-input input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dap-repl-input input::placeholder {
color: var(--fgColor-muted, #8b949e);
}
/* Control Buttons */
.dap-controls {
background-color: var(--bgColor-muted, #161b22);
}
.dap-controls button {
min-width: 32px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
}
.dap-controls button svg {
width: 14px;
height: 14px;
}
.dap-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dap-controls button:not(:disabled):hover {
background-color: var(--bgColor-accent-muted, #388bfd26);
}
.dap-step-counter {
flex-shrink: 0;
}
/* Utility Classes (in case GitHub's aren't loaded) */
.d-flex { display: flex; }
.flex-column { flex-direction: column; }
.flex-items-center { align-items: center; }
.flex-auto { flex: 1 1 auto; }
.p-2 { padding: 8px; }
.px-2 { padding-left: 8px; padding-right: 8px; }
.mx-2 { margin-left: 8px; margin-right: 8px; }
.mb-2 { margin-bottom: 8px; }
.ml-2 { margin-left: 8px; }
.ml-3 { margin-left: 16px; }
.mr-2 { margin-right: 8px; }
.ml-auto { margin-left: auto; }
.border { border: 1px solid var(--borderColor-default, #30363d); }
.border-bottom { border-bottom: 1px solid var(--borderColor-default, #30363d); }
.border-top { border-top: 1px solid var(--borderColor-default, #30363d); }
.border-right { border-right: 1px solid var(--borderColor-default, #30363d); }
.rounded-2 { border-radius: 6px; }
.overflow-auto { overflow: auto; }
.text-bold { font-weight: 600; }
.text-mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
.text-small { font-size: 12px; }
.color-fg-muted { color: var(--fgColor-muted, #8b949e); }
.color-fg-danger { color: var(--fgColor-danger, #f85149); }
.color-fg-default { color: var(--fgColor-default, #e6edf3); }
/* Light mode overrides */
@media (prefers-color-scheme: light) {
.dap-debugger-pane {
background-color: var(--bgColor-default, #ffffff);
border-color: var(--borderColor-default, #d0d7de) !important;
}
.dap-header,
.dap-scope-header,
.dap-repl-header,
.dap-controls {
background-color: var(--bgColor-muted, #f6f8fa);
}
.dap-repl-output,
.dap-repl-input input {
background-color: var(--bgColor-inset, #f6f8fa) !important;
}
.dap-tree-node .text-bold {
color: var(--fgColor-default, #1f2328);
}
.color-fg-muted {
color: var(--fgColor-muted, #656d76);
}
}
/* Respect GitHub's color mode data attribute */
[data-color-mode="light"] .dap-debugger-pane,
html[data-color-mode="light"] .dap-debugger-pane {
background-color: #ffffff;
border-color: #d0d7de !important;
}
[data-color-mode="light"] .dap-header,
[data-color-mode="light"] .dap-scope-header,
[data-color-mode="light"] .dap-repl-header,
[data-color-mode="light"] .dap-controls,
html[data-color-mode="light"] .dap-header,
html[data-color-mode="light"] .dap-scope-header,
html[data-color-mode="light"] .dap-repl-header,
html[data-color-mode="light"] .dap-controls {
background-color: #f6f8fa;
}
[data-color-mode="light"] .dap-repl-output,
[data-color-mode="light"] .dap-repl-input input,
html[data-color-mode="light"] .dap-repl-output,
html[data-color-mode="light"] .dap-repl-input input {
background-color: #f6f8fa !important;
}

View File

@@ -0,0 +1,641 @@
/**
* Content Script - Debugger UI
*
* Injects the debugger pane into GitHub Actions job pages and handles
* all UI interactions.
*/
// State
let debuggerPane = null;
let currentFrameId = 0;
let isConnected = false;
let replHistory = [];
let replHistoryIndex = -1;
// HTML escape helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Send DAP request to background script
*/
function sendDapRequest(command, args = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type: 'dap-request', command, args }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.success) {
resolve(response.body);
} else {
reject(new Error(response?.error || 'Unknown error'));
}
});
});
}
/**
* Build map of steps from DOM
*/
function buildStepMap() {
const steps = document.querySelectorAll('check-step');
const map = new Map();
steps.forEach((el, idx) => {
map.set(idx, {
element: el,
number: parseInt(el.dataset.number),
name: el.dataset.name,
conclusion: el.dataset.conclusion,
externalId: el.dataset.externalId,
});
});
return map;
}
/**
* Find step element by name
*/
function findStepByName(stepName) {
return document.querySelector(`check-step[data-name="${CSS.escape(stepName)}"]`);
}
/**
* Find step element by number
*/
function findStepByNumber(stepNumber) {
return document.querySelector(`check-step[data-number="${stepNumber}"]`);
}
/**
* Get all step elements
*/
function getAllSteps() {
return document.querySelectorAll('check-step');
}
/**
* Create the debugger pane HTML
*/
function createDebuggerPaneHTML() {
return `
<div class="dap-header d-flex flex-items-center p-2 border-bottom">
<svg class="octicon mr-2" viewBox="0 0 16 16" width="16" height="16">
<path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1-1.06 1.06l-.22-.22-.22.22a.75.75 0 0 1-1.06-1.06l1-1Z"/>
<path fill="currentColor" d="M11.28.22a.75.75 0 0 0-1.06 0l-1 1a.75.75 0 0 0 1.06 1.06l.22-.22.22.22a.75.75 0 0 0 1.06-1.06l-1-1Z"/>
<path fill="currentColor" d="M8 4a4 4 0 0 0-4 4v1h1v2.5a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5V9h1V8a4 4 0 0 0-4-4Z"/>
<path fill="currentColor" d="M5 9H3.5a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5H5V9ZM11 9h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H11V9Z"/>
</svg>
<span class="text-bold">Debugger</span>
<span class="dap-step-info color-fg-muted ml-2">Connecting...</span>
<span class="Label dap-status-label ml-auto">CONNECTING</span>
</div>
<div class="dap-content d-flex" style="height: 300px;">
<!-- Scopes Panel -->
<div class="dap-scopes border-right overflow-auto" style="width: 33%;">
<div class="dap-scope-header p-2 text-bold border-bottom">Variables</div>
<div class="dap-scope-tree p-2">
<div class="color-fg-muted">Connect to view variables</div>
</div>
</div>
<!-- REPL Console -->
<div class="dap-repl d-flex flex-column" style="width: 67%;">
<div class="dap-repl-header p-2 text-bold border-bottom">Console</div>
<div class="dap-repl-output overflow-auto flex-auto p-2 text-mono text-small">
<div class="color-fg-muted">Welcome to Actions DAP Debugger</div>
<div class="color-fg-muted">Enter expressions like: \${{ github.ref }}</div>
<div class="color-fg-muted">Or shell commands: !ls -la</div>
</div>
<div class="dap-repl-input border-top p-2">
<input type="text" class="form-control input-sm text-mono"
placeholder="Enter expression or !command" disabled>
</div>
</div>
</div>
<!-- Control buttons -->
<div class="dap-controls d-flex flex-items-center p-2 border-top">
<button class="btn btn-sm mr-2" data-action="reverseContinue" title="Reverse Continue (go to first checkpoint)" disabled>
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V8.5l5 4V8.5l5 4V2.5l-5 4V2.5l-5 4V2z"/></svg>
</button>
<button class="btn btn-sm mr-2" data-action="stepBack" title="Step Back" disabled>
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V2H2zm3 6 7 5V3L5 8z"/></svg>
</button>
<button class="btn btn-sm btn-primary mr-2" data-action="continue" title="Continue" disabled>
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M4 2l10 6-10 6z"/></svg>
</button>
<button class="btn btn-sm mr-2" data-action="next" title="Step to Next" disabled>
<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 3l7 5-7 5V3zm7 5l5 0V2h2v12h-2V8.5l-5 0z"/></svg>
</button>
<span class="dap-step-counter color-fg-muted ml-auto text-small">
Not connected
</span>
</div>
`;
}
/**
* Inject debugger pane into the page
*/
function injectDebuggerPane() {
// Remove existing pane if any
const existing = document.querySelector('.dap-debugger-pane');
if (existing) existing.remove();
// Find where to inject
const stepsContainer = document.querySelector('check-steps');
if (!stepsContainer) {
console.warn('[Content] No check-steps container found');
return null;
}
// Create pane
const pane = document.createElement('div');
pane.className = 'dap-debugger-pane mx-2 mb-2 border rounded-2';
pane.innerHTML = createDebuggerPaneHTML();
// Insert at the top of steps container
stepsContainer.insertBefore(pane, stepsContainer.firstChild);
// Setup event handlers
setupPaneEventHandlers(pane);
debuggerPane = pane;
return pane;
}
/**
* Move debugger pane to before a specific step
*/
function moveDebuggerPane(stepElement, stepName) {
if (!debuggerPane || !stepElement) return;
// Move the pane
stepElement.parentNode.insertBefore(debuggerPane, stepElement);
// Update step info
const stepInfo = debuggerPane.querySelector('.dap-step-info');
if (stepInfo) {
stepInfo.textContent = `Paused before: ${stepName}`;
}
}
/**
* Setup event handlers for debugger pane
*/
function setupPaneEventHandlers(pane) {
// Control buttons
pane.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
enableControls(false);
updateStatus('RUNNING');
try {
await sendDapRequest(action, { threadId: 1 });
} catch (error) {
console.error(`[Content] DAP ${action} failed:`, error);
appendOutput(`Error: ${error.message}`, 'error');
enableControls(true);
updateStatus('ERROR');
}
});
});
// REPL input
const input = pane.querySelector('.dap-repl-input input');
if (input) {
input.addEventListener('keydown', handleReplKeydown);
}
}
/**
* Handle REPL input keydown
*/
async function handleReplKeydown(e) {
const input = e.target;
if (e.key === 'Enter') {
const command = input.value.trim();
if (!command) return;
replHistory.push(command);
replHistoryIndex = replHistory.length;
input.value = '';
// Show command
appendOutput(`> ${command}`, 'input');
// Send to DAP
try {
const response = await sendDapRequest('evaluate', {
expression: command,
frameId: currentFrameId,
context: command.startsWith('!') ? 'repl' : 'watch',
});
if (response.result) {
appendOutput(response.result, 'result');
}
} catch (error) {
appendOutput(error.message, 'error');
}
} else if (e.key === 'ArrowUp') {
if (replHistoryIndex > 0) {
replHistoryIndex--;
input.value = replHistory[replHistoryIndex];
}
e.preventDefault();
} else if (e.key === 'ArrowDown') {
if (replHistoryIndex < replHistory.length - 1) {
replHistoryIndex++;
input.value = replHistory[replHistoryIndex];
} else {
replHistoryIndex = replHistory.length;
input.value = '';
}
e.preventDefault();
}
}
/**
* Append output to REPL console
*/
function appendOutput(text, type) {
const output = document.querySelector('.dap-repl-output');
if (!output) return;
const line = document.createElement('div');
line.className = `dap-output-${type}`;
if (type === 'error') line.classList.add('color-fg-danger');
if (type === 'input') line.classList.add('color-fg-muted');
// Handle multi-line output
const lines = text.split('\n');
lines.forEach((l, i) => {
if (i > 0) {
output.appendChild(document.createElement('br'));
}
const span = document.createElement('span');
span.textContent = l;
if (i === 0) {
span.className = line.className;
}
output.appendChild(span);
});
if (lines.length === 1) {
line.textContent = text;
output.appendChild(line);
}
output.scrollTop = output.scrollHeight;
}
/**
* Enable/disable control buttons
*/
function enableControls(enabled) {
if (!debuggerPane) return;
debuggerPane.querySelectorAll('.dap-controls button').forEach((btn) => {
btn.disabled = !enabled;
});
const input = debuggerPane.querySelector('.dap-repl-input input');
if (input) {
input.disabled = !enabled;
}
}
/**
* Update status display
*/
function updateStatus(status, extra) {
if (!debuggerPane) return;
const label = debuggerPane.querySelector('.dap-status-label');
if (label) {
label.textContent = status;
label.className = 'Label dap-status-label ml-auto ';
switch (status) {
case 'PAUSED':
label.classList.add('Label--attention');
break;
case 'RUNNING':
label.classList.add('Label--success');
break;
case 'TERMINATED':
case 'DISCONNECTED':
label.classList.add('Label--secondary');
break;
case 'ERROR':
label.classList.add('Label--danger');
break;
default:
label.classList.add('Label--secondary');
}
}
// Update step counter if extra info provided
if (extra) {
const counter = debuggerPane.querySelector('.dap-step-counter');
if (counter) {
counter.textContent = extra;
}
}
}
/**
* Load scopes for current frame
*/
async function loadScopes(frameId) {
const scopesContainer = document.querySelector('.dap-scope-tree');
if (!scopesContainer) return;
scopesContainer.innerHTML = '<div class="color-fg-muted">Loading...</div>';
try {
const response = await sendDapRequest('scopes', { frameId });
scopesContainer.innerHTML = '';
for (const scope of response.scopes) {
const node = createTreeNode(scope.name, scope.variablesReference, true);
scopesContainer.appendChild(node);
}
} catch (error) {
scopesContainer.innerHTML = `<div class="color-fg-danger">Error: ${escapeHtml(error.message)}</div>`;
}
}
/**
* Create a tree node for scope/variable display
*/
function createTreeNode(name, variablesReference, isExpandable, value) {
const node = document.createElement('div');
node.className = 'dap-tree-node';
node.dataset.variablesRef = variablesReference;
const content = document.createElement('div');
content.className = 'dap-tree-content';
// Expand icon
const expandIcon = document.createElement('span');
expandIcon.className = 'dap-expand-icon';
expandIcon.textContent = isExpandable ? '\u25B6' : ' '; // ▶ or space
content.appendChild(expandIcon);
// Name
const nameSpan = document.createElement('span');
nameSpan.className = 'text-bold';
nameSpan.textContent = name;
content.appendChild(nameSpan);
// Value (if provided)
if (value !== undefined) {
const valueSpan = document.createElement('span');
valueSpan.className = 'color-fg-muted';
valueSpan.textContent = `: ${value}`;
content.appendChild(valueSpan);
}
node.appendChild(content);
if (isExpandable && variablesReference > 0) {
content.style.cursor = 'pointer';
content.addEventListener('click', () => toggleTreeNode(node));
}
return node;
}
/**
* Toggle tree node expansion
*/
async function toggleTreeNode(node) {
const children = node.querySelector('.dap-tree-children');
const expandIcon = node.querySelector('.dap-expand-icon');
if (children) {
// Toggle visibility
children.hidden = !children.hidden;
expandIcon.textContent = children.hidden ? '\u25B6' : '\u25BC'; // ▶ or ▼
return;
}
// Fetch children
const variablesRef = parseInt(node.dataset.variablesRef);
if (!variablesRef) return;
expandIcon.textContent = '...';
try {
const response = await sendDapRequest('variables', { variablesReference: variablesRef });
const childContainer = document.createElement('div');
childContainer.className = 'dap-tree-children ml-3';
for (const variable of response.variables) {
const hasChildren = variable.variablesReference > 0;
const childNode = createTreeNode(
variable.name,
variable.variablesReference,
hasChildren,
variable.value
);
childContainer.appendChild(childNode);
}
node.appendChild(childContainer);
expandIcon.textContent = '\u25BC'; // ▼
} catch (error) {
console.error('[Content] Failed to load variables:', error);
expandIcon.textContent = '\u25B6'; // ▶
}
}
/**
* Handle stopped event from DAP
*/
async function handleStoppedEvent(body) {
console.log('[Content] Stopped event:', body);
isConnected = true;
updateStatus('PAUSED', body.reason || 'paused');
enableControls(true);
// Get current location
try {
const stackTrace = await sendDapRequest('stackTrace', { threadId: 1 });
if (stackTrace.stackFrames && stackTrace.stackFrames.length > 0) {
const currentFrame = stackTrace.stackFrames[0];
currentFrameId = currentFrame.id;
// Find the step element and move pane
const stepName = currentFrame.name;
const stepElement = findStepByName(stepName);
if (stepElement) {
moveDebuggerPane(stepElement, stepName);
} else {
// Try to find by frame id as step index
const steps = getAllSteps();
if (steps[currentFrame.id]) {
moveDebuggerPane(steps[currentFrame.id], stepName);
}
}
// Update step counter
const counter = debuggerPane?.querySelector('.dap-step-counter');
if (counter) {
counter.textContent = `Step ${currentFrame.id + 1} of ${stackTrace.stackFrames.length}`;
}
// Load scopes
await loadScopes(currentFrame.id);
}
} catch (error) {
console.error('[Content] Failed to get stack trace:', error);
appendOutput(`Error: ${error.message}`, 'error');
}
}
/**
* Handle output event from DAP
*/
function handleOutputEvent(body) {
if (body.output) {
const category = body.category === 'stderr' ? 'error' : 'stdout';
appendOutput(body.output.trimEnd(), category);
}
}
/**
* Handle terminated event from DAP
*/
function handleTerminatedEvent() {
isConnected = false;
updateStatus('TERMINATED');
enableControls(false);
const stepInfo = debuggerPane?.querySelector('.dap-step-info');
if (stepInfo) {
stepInfo.textContent = 'Session ended';
}
}
/**
* Handle status change from background
*/
function handleStatusChange(status) {
console.log('[Content] Status changed:', status);
switch (status) {
case 'connected':
isConnected = true;
updateStatus('CONNECTED');
const stepInfo = debuggerPane?.querySelector('.dap-step-info');
if (stepInfo) {
stepInfo.textContent = 'Waiting for debug event...';
}
break;
case 'paused':
isConnected = true;
updateStatus('PAUSED');
enableControls(true);
break;
case 'running':
isConnected = true;
updateStatus('RUNNING');
enableControls(false);
break;
case 'disconnected':
isConnected = false;
updateStatus('DISCONNECTED');
enableControls(false);
break;
case 'error':
isConnected = false;
updateStatus('ERROR');
enableControls(false);
break;
}
}
/**
* Listen for messages from background script
*/
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('[Content] Received message:', message.type);
switch (message.type) {
case 'dap-event':
const event = message.event;
switch (event.event) {
case 'stopped':
handleStoppedEvent(event.body);
break;
case 'output':
handleOutputEvent(event.body);
break;
case 'terminated':
handleTerminatedEvent();
break;
}
break;
case 'status-changed':
handleStatusChange(message.status);
break;
}
});
/**
* Initialize content script
*/
function init() {
console.log('[Content] Actions DAP Debugger content script loaded');
// Check if we're on a job page
const steps = getAllSteps();
if (steps.length === 0) {
console.log('[Content] No steps found, waiting for DOM...');
// Wait for steps to appear
const observer = new MutationObserver((mutations) => {
const steps = getAllSteps();
if (steps.length > 0) {
observer.disconnect();
console.log('[Content] Steps found, injecting debugger pane');
injectDebuggerPane();
}
});
observer.observe(document.body, { childList: true, subtree: true });
return;
}
// Inject debugger pane
injectDebuggerPane();
// Check current connection status
chrome.runtime.sendMessage({ type: 'get-status' }, (response) => {
if (response && response.status) {
handleStatusChange(response.status);
}
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Create simple green circle PNG icons
* No dependencies required - uses pure JavaScript to create valid PNG files
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
function createPNG(size) {
// PNG uses RGBA format, one pixel = 4 bytes
const pixelData = [];
const centerX = size / 2;
const centerY = size / 2;
const radius = size / 2 - 1;
const innerRadius = radius * 0.4;
for (let y = 0; y < size; y++) {
pixelData.push(0); // Filter byte for each row
for (let x = 0; x < size; x++) {
const dx = x - centerX;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= radius) {
// Green circle (#238636)
pixelData.push(35, 134, 54, 255);
} else {
// Transparent
pixelData.push(0, 0, 0, 0);
}
}
}
// Add a white "bug" shape in the center
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - centerX;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
// Bug body (oval)
const bodyDx = dx;
const bodyDy = (dy - size * 0.05) / 1.3;
const bodyDist = Math.sqrt(bodyDx * bodyDx + bodyDy * bodyDy);
// Bug head (circle above body)
const headDx = dx;
const headDy = dy + size * 0.15;
const headDist = Math.sqrt(headDx * headDx + headDy * headDy);
if (bodyDist < innerRadius || headDist < innerRadius * 0.6) {
const idx = 1 + y * (1 + size * 4) + x * 4;
pixelData[idx] = 255;
pixelData[idx + 1] = 255;
pixelData[idx + 2] = 255;
pixelData[idx + 3] = 255;
}
}
}
const rawData = Buffer.from(pixelData);
const compressed = zlib.deflateSync(rawData);
// Build PNG file
const chunks = [];
// PNG signature
chunks.push(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
// IHDR chunk
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0); // width
ihdr.writeUInt32BE(size, 4); // height
ihdr.writeUInt8(8, 8); // bit depth
ihdr.writeUInt8(6, 9); // color type (RGBA)
ihdr.writeUInt8(0, 10); // compression
ihdr.writeUInt8(0, 11); // filter
ihdr.writeUInt8(0, 12); // interlace
chunks.push(createChunk('IHDR', ihdr));
// IDAT chunk
chunks.push(createChunk('IDAT', compressed));
// IEND chunk
chunks.push(createChunk('IEND', Buffer.alloc(0)));
return Buffer.concat(chunks);
}
function createChunk(type, data) {
const typeBuffer = Buffer.from(type);
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crcData = Buffer.concat([typeBuffer, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(crcData), 0);
return Buffer.concat([length, typeBuffer, data, crc]);
}
// CRC32 implementation
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc = crc32Table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
// CRC32 lookup table
const crc32Table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crc32Table[i] = c;
}
// Generate icons
const iconsDir = path.join(__dirname);
const sizes = [16, 48, 128];
sizes.forEach((size) => {
const png = createPNG(size);
const filename = `icon${size}.png`;
fs.writeFileSync(path.join(iconsDir, filename), png);
console.log(`Created ${filename} (${size}x${size})`);
});
console.log('Done!');

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

32
browser-ext/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "Actions DAP Debugger",
"version": "0.1.0",
"description": "Debug GitHub Actions workflows with DAP - interactive debugging directly in the browser",
"permissions": ["activeTab", "storage"],
"host_permissions": ["https://github.com/*"],
"background": {
"service_worker": "background/background.js"
},
"content_scripts": [
{
"matches": ["https://github.com/*/*/actions/runs/*/job/*"],
"js": ["lib/dap-protocol.js", "content/content.js"],
"css": ["content/content.css"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

228
browser-ext/popup/popup.css Normal file
View File

@@ -0,0 +1,228 @@
/**
* Popup Styles
*
* GitHub-inspired dark theme for the extension popup.
*/
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 320px;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
font-size: 14px;
background-color: #0d1117;
color: #e6edf3;
}
h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
}
h3 .icon {
flex-shrink: 0;
}
/* Status Section */
.status-section {
display: flex;
align-items: center;
margin-bottom: 16px;
padding: 12px;
background-color: #161b22;
border-radius: 6px;
border: 1px solid #30363d;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 10px;
flex-shrink: 0;
}
.status-disconnected {
background-color: #6e7681;
}
.status-connecting {
background-color: #9e6a03;
animation: pulse 1.5s ease-in-out infinite;
}
.status-connected {
background-color: #238636;
}
.status-paused {
background-color: #9e6a03;
}
.status-running {
background-color: #238636;
animation: pulse 1.5s ease-in-out infinite;
}
.status-error {
background-color: #da3633;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
#status-text {
font-weight: 500;
}
/* Config Section */
.config-section {
margin-bottom: 16px;
}
.config-section label {
display: block;
margin-bottom: 12px;
font-size: 12px;
font-weight: 500;
color: #8b949e;
}
.config-section input {
display: block;
width: 100%;
padding: 8px 12px;
margin-top: 6px;
background-color: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 14px;
}
.config-section input:focus {
border-color: #1f6feb;
outline: none;
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3);
}
.config-section input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.config-hint {
font-size: 11px;
color: #6e7681;
margin-top: 4px;
}
/* Actions Section */
.actions-section {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
button {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: #238636;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2ea043;
}
.btn-secondary {
background-color: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
}
.btn-secondary:hover:not(:disabled) {
background-color: #30363d;
}
/* Help Section */
.help-section {
font-size: 12px;
color: #8b949e;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.help-section p {
margin: 6px 0;
line-height: 1.5;
}
.help-section p:first-child {
margin-top: 0;
}
.help-section strong {
color: #e6edf3;
}
.help-section code {
display: block;
background-color: #0d1117;
padding: 8px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 11px;
overflow-x: auto;
margin: 8px 0;
white-space: nowrap;
}
/* Footer */
.footer {
text-align: center;
padding-top: 8px;
border-top: 1px solid #21262d;
}
.footer a {
color: #58a6ff;
text-decoration: none;
font-size: 12px;
}
.footer a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="popup-container">
<h3>
<svg class="icon" viewBox="0 0 16 16" width="16" height="16">
<path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1-1.06 1.06l-.22-.22-.22.22a.75.75 0 0 1-1.06-1.06l1-1Z"/>
<path fill="currentColor" d="M11.28.22a.75.75 0 0 0-1.06 0l-1 1a.75.75 0 0 0 1.06 1.06l.22-.22.22.22a.75.75 0 0 0 1.06-1.06l-1-1Z"/>
<path fill="currentColor" d="M8 4a4 4 0 0 0-4 4v1h1v2.5a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5V9h1V8a4 4 0 0 0-4-4Z"/>
<path fill="currentColor" d="M5 9H3.5a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5H5V9ZM11 9h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H11V9Z"/>
</svg>
Actions DAP Debugger
</h3>
<div class="status-section">
<div class="status-indicator" id="status-indicator"></div>
<span id="status-text">Disconnected</span>
</div>
<div class="config-section">
<label>
Proxy URL
<input type="text" id="proxy-url" value="ws://localhost:4712"
placeholder="ws://localhost:4712 or wss://...">
</label>
<p class="config-hint">For codespaces, use the forwarded URL (wss://...)</p>
</div>
<div class="actions-section">
<button id="connect-btn" class="btn-primary">Connect</button>
<button id="disconnect-btn" class="btn-secondary" disabled>Disconnect</button>
</div>
<div class="help-section">
<p><strong>Quick Start:</strong></p>
<p>1. Start the proxy:</p>
<code>cd browser-ext/proxy && npm install && node proxy.js</code>
<p>2. Re-run your GitHub Actions job with "Enable debug logging"</p>
<p>3. Click Connect when the job is waiting for debugger</p>
</div>
<div class="footer">
<a href="https://github.com/actions/runner" target="_blank">Documentation</a>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,95 @@
/**
* Popup Script
*
* Handles extension popup UI and connection management.
*/
document.addEventListener('DOMContentLoaded', () => {
const statusIndicator = document.getElementById('status-indicator');
const statusText = document.getElementById('status-text');
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const urlInput = document.getElementById('proxy-url');
// Load saved config
chrome.storage.local.get(['proxyUrl'], (data) => {
if (data.proxyUrl) urlInput.value = data.proxyUrl;
});
// Get current status from background
chrome.runtime.sendMessage({ type: 'get-status' }, (response) => {
if (response && response.status) {
updateStatusUI(response.status);
}
});
// Listen for status changes
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'status-changed') {
updateStatusUI(message.status);
}
});
// Connect button
connectBtn.addEventListener('click', () => {
const url = urlInput.value.trim() || 'ws://localhost:4712';
// Save config
chrome.storage.local.set({ proxyUrl: url });
// Update UI immediately
updateStatusUI('connecting');
// Connect
chrome.runtime.sendMessage({ type: 'connect', url }, (response) => {
if (response && response.status) {
updateStatusUI(response.status);
}
});
});
// Disconnect button
disconnectBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'disconnect' }, (response) => {
if (response && response.status) {
updateStatusUI(response.status);
}
});
});
/**
* Update the UI to reflect current status
*/
function updateStatusUI(status) {
// Update text
const statusNames = {
disconnected: 'Disconnected',
connecting: 'Connecting...',
connected: 'Connected',
paused: 'Paused',
running: 'Running',
error: 'Error',
};
statusText.textContent = statusNames[status] || status;
// Update indicator color
statusIndicator.className = 'status-indicator status-' + status;
// Update button states
const isConnected = ['connected', 'paused', 'running'].includes(status);
const isConnecting = status === 'connecting';
connectBtn.disabled = isConnected || isConnecting;
disconnectBtn.disabled = !isConnected;
// Update connect button text
if (isConnecting) {
connectBtn.textContent = 'Connecting...';
} else {
connectBtn.textContent = 'Connect';
}
// Disable inputs when connected
urlInput.disabled = isConnected || isConnecting;
}
});

36
browser-ext/proxy/package-lock.json generated Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "dap-websocket-proxy",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dap-websocket-proxy",
"version": "1.0.0",
"dependencies": {
"ws": "^8.16.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "dap-websocket-proxy",
"version": "1.0.0",
"description": "WebSocket-to-TCP bridge for DAP debugging",
"main": "proxy.js",
"scripts": {
"start": "node proxy.js"
},
"dependencies": {
"ws": "^8.16.0"
}
}

171
browser-ext/proxy/proxy.js Normal file
View File

@@ -0,0 +1,171 @@
/**
* DAP WebSocket-to-TCP Proxy
*
* Bridges WebSocket connections from browser extensions to the DAP TCP server.
* Handles DAP message framing (Content-Length headers).
*
* Usage: node proxy.js [--ws-port 4712] [--dap-host 127.0.0.1] [--dap-port 4711]
*/
const WebSocket = require('ws');
const net = require('net');
// Configuration (can be overridden via CLI args)
const config = {
wsPort: parseInt(process.env.WS_PORT) || 4712,
dapHost: process.env.DAP_HOST || '127.0.0.1',
dapPort: parseInt(process.env.DAP_PORT) || 4711,
};
// Parse CLI arguments
for (let i = 2; i < process.argv.length; i++) {
switch (process.argv[i]) {
case '--ws-port':
config.wsPort = parseInt(process.argv[++i]);
break;
case '--dap-host':
config.dapHost = process.argv[++i];
break;
case '--dap-port':
config.dapPort = parseInt(process.argv[++i]);
break;
}
}
console.log(`[Proxy] Starting WebSocket-to-TCP proxy`);
console.log(`[Proxy] WebSocket: ws://localhost:${config.wsPort}`);
console.log(`[Proxy] DAP Server: tcp://${config.dapHost}:${config.dapPort}`);
const wss = new WebSocket.Server({ port: config.wsPort });
console.log(`[Proxy] WebSocket server listening on port ${config.wsPort}`);
wss.on('connection', (ws, req) => {
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
console.log(`[Proxy] WebSocket client connected: ${clientId}`);
// Connect to DAP TCP server
const tcp = net.createConnection({
host: config.dapHost,
port: config.dapPort,
});
let tcpBuffer = '';
let tcpConnected = false;
tcp.on('connect', () => {
tcpConnected = true;
console.log(`[Proxy] Connected to DAP server at ${config.dapHost}:${config.dapPort}`);
});
tcp.on('error', (err) => {
console.error(`[Proxy] TCP error: ${err.message}`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'proxy-error',
message: `Failed to connect to DAP server: ${err.message}`,
})
);
ws.close(1011, 'DAP server connection failed');
}
});
tcp.on('close', () => {
console.log(`[Proxy] TCP connection closed`);
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'DAP server disconnected');
}
});
// WebSocket → TCP: Add Content-Length framing
ws.on('message', (data) => {
if (!tcpConnected) {
console.warn(`[Proxy] TCP not connected, dropping message`);
return;
}
const json = data.toString();
try {
// Validate it's valid JSON
const parsed = JSON.parse(json);
console.log(`[Proxy] WS→TCP: ${parsed.command || parsed.event || 'message'}`);
// Add DAP framing
const framed = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`;
tcp.write(framed);
} catch (err) {
console.error(`[Proxy] Invalid JSON from WebSocket: ${err.message}`);
}
});
// TCP → WebSocket: Parse Content-Length framing
tcp.on('data', (chunk) => {
tcpBuffer += chunk.toString();
// Process complete DAP messages from buffer
while (true) {
// Look for Content-Length header
const headerEnd = tcpBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) break;
const header = tcpBuffer.substring(0, headerEnd);
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
console.error(`[Proxy] Invalid DAP header: ${header}`);
tcpBuffer = tcpBuffer.substring(headerEnd + 4);
continue;
}
const contentLength = parseInt(match[1]);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
// Check if we have the complete message
if (tcpBuffer.length < messageEnd) break;
// Extract the JSON message
const json = tcpBuffer.substring(messageStart, messageEnd);
tcpBuffer = tcpBuffer.substring(messageEnd);
// Send to WebSocket
try {
const parsed = JSON.parse(json);
console.log(
`[Proxy] TCP→WS: ${parsed.type} ${parsed.command || parsed.event || ''} ${parsed.request_seq ? `(req_seq: ${parsed.request_seq})` : ''}`
);
if (ws.readyState === WebSocket.OPEN) {
ws.send(json);
}
} catch (err) {
console.error(`[Proxy] Invalid JSON from TCP: ${err.message}`);
}
}
});
// Handle WebSocket close
ws.on('close', (code, reason) => {
console.log(`[Proxy] WebSocket closed: ${code} ${reason}`);
tcp.end();
});
ws.on('error', (err) => {
console.error(`[Proxy] WebSocket error: ${err.message}`);
tcp.end();
});
});
wss.on('error', (err) => {
console.error(`[Proxy] WebSocket server error: ${err.message}`);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log(`\n[Proxy] Shutting down...`);
wss.clients.forEach((ws) => ws.close(1001, 'Server shutting down'));
wss.close(() => {
console.log(`[Proxy] Goodbye!`);
process.exit(0);
});
});