From 15b7034088ad73d5e776e50dd7c66aa9cc5740d9 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Thu, 15 Jan 2026 21:16:55 +0000 Subject: [PATCH] wip extension --- .opencode/plans/dap-browser-extension.md | 1176 ++++++++++++++++++++++ browser-ext/README.md | 176 ++++ browser-ext/background/background.js | 292 ++++++ browser-ext/content/content.css | 307 ++++++ browser-ext/content/content.js | 641 ++++++++++++ browser-ext/icons/generate.js | 135 +++ browser-ext/icons/icon128.png | Bin 0 -> 872 bytes browser-ext/icons/icon16.png | Bin 0 -> 126 bytes browser-ext/icons/icon48.png | Bin 0 -> 258 bytes browser-ext/manifest.json | 32 + browser-ext/popup/popup.css | 228 +++++ browser-ext/popup/popup.html | 52 + browser-ext/popup/popup.js | 95 ++ browser-ext/proxy/package-lock.json | 36 + browser-ext/proxy/package.json | 12 + browser-ext/proxy/proxy.js | 171 ++++ 16 files changed, 3353 insertions(+) create mode 100644 .opencode/plans/dap-browser-extension.md create mode 100644 browser-ext/README.md create mode 100644 browser-ext/background/background.js create mode 100644 browser-ext/content/content.css create mode 100644 browser-ext/content/content.js create mode 100644 browser-ext/icons/generate.js create mode 100644 browser-ext/icons/icon128.png create mode 100644 browser-ext/icons/icon16.png create mode 100644 browser-ext/icons/icon48.png create mode 100644 browser-ext/manifest.json create mode 100644 browser-ext/popup/popup.css create mode 100644 browser-ext/popup/popup.html create mode 100644 browser-ext/popup/popup.js create mode 100644 browser-ext/proxy/package-lock.json create mode 100644 browser-ext/proxy/package.json create mode 100644 browser-ext/proxy/proxy.js diff --git a/.opencode/plans/dap-browser-extension.md b/.opencode/plans/dap-browser-extension.md new file mode 100644 index 000000000..15a2df90e --- /dev/null +++ b/.opencode/plans/dap-browser-extension.md @@ -0,0 +1,1176 @@ +# DAP Browser Extension for GitHub Actions Debugging + +**Status:** Planned +**Date:** January 2026 +**Related:** [dap-debugging.md](./dap-debugging.md), [dap-step-backwards.md](./dap-step-backwards.md) + +## Overview + +A Chrome extension that injects a debugger UI into GitHub Actions job pages, connecting to a runner's DAP server via a WebSocket-to-TCP proxy. This enables interactive debugging (variable inspection, REPL, step control) directly in the browser. + +**Goal:** Demonstrate the power of implementing a standard protocol like DAP - the same debugging capabilities available in nvim-dap can now work in a browser with minimal effort. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ GitHub Actions Job Page │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ Content Script (injected) │ │ +│ │ - Observes DOM for step elements │ │ +│ │ - Injects debugger pane above the "next pending step" │ │ +│ │ - Renders scopes tree, REPL console, control buttons │ │ +│ │ - Communicates with background script via chrome.runtime messaging │ │ +│ └───────────────────────────────────────┬──────────────────────────────────┘ │ +│ │ chrome.runtime.sendMessage │ +└──────────────────────────────────────────┼──────────────────────────────────────┘ + │ +┌──────────────────────────────────────────┼──────────────────────────────────────┐ +│ Background Script (Service Worker) │ +│ ┌───────────────────────────────────────┴──────────────────────────────────┐ │ +│ │ DAP Client │ │ +│ │ - Connects to WebSocket proxy (ws://localhost:4712) │ │ +│ │ - Implements DAP request/response handling │ │ +│ │ - Relays events to content script │ │ +│ │ - Manages connection state │ │ +│ └───────────────────────────────────────┬──────────────────────────────────┘ │ +│ │ WebSocket │ +└──────────────────────────────────────────┼──────────────────────────────────────┘ + │ +┌──────────────────────────────────────────┼──────────────────────────────────────┐ +│ WebSocket-to-TCP Proxy (Node.js) │ +│ ┌───────────────────────────────────────┴──────────────────────────────────┐ │ +│ │ - Listens on ws://localhost:4712 │ │ +│ │ - Connects to tcp://localhost:4711 (DAP server) │ │ +│ │ - Handles DAP message framing (Content-Length headers) │ │ +│ │ - Bidirectional message relay │ │ +│ └───────────────────────────────────────┬──────────────────────────────────┘ │ +│ │ TCP │ +└──────────────────────────────────────────┼──────────────────────────────────────┘ + │ +┌──────────────────────────────────────────┼──────────────────────────────────────┐ +│ Runner DAP Server (existing) │ +│ ┌───────────────────────────────────────┴──────────────────────────────────┐ │ +│ │ tcp://localhost:4711 │ │ +│ │ - DapServer.cs (existing implementation) │ │ +│ │ - DapDebugSession.cs (handles all debug operations) │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +browser-ext/ +├── manifest.json # Chrome extension manifest v3 +├── background/ +│ └── background.js # Service worker - DAP client, WebSocket connection +├── content/ +│ ├── content.js # DOM manipulation, pane injection, UI logic +│ └── content.css # Styling for debugger pane +├── popup/ +│ ├── popup.html # Extension popup (connection status, settings) +│ ├── popup.js +│ └── popup.css +├── lib/ +│ └── dap-protocol.js # DAP message types and helpers +├── proxy/ +│ ├── proxy.js # WebSocket-to-TCP bridge +│ └── package.json # Proxy dependencies (ws) +└── icons/ + ├── icon16.png + ├── icon48.png + └── icon128.png +``` + +## GitHub Actions Job Page DOM Structure + +### Step Container Element: `` + +Each step is a custom element with rich data attributes: + +```html + + data-number="4" + data-conclusion="failure" + data-external-id="759535e9-..." + data-expand="true" + data-started-at="2026-01-15T..." + data-completed-at="2026-01-15T..." + data-log-url="..." + data-job-completed="" +> +``` + +### Key Selectors + +| Target | Selector | +|--------|----------| +| All steps | `check-step` or `check-steps > check-step` | +| Step by number | `check-step[data-number="4"]` | +| Failed steps | `check-step[data-conclusion="failure"]` | +| In-progress steps | `check-step[data-conclusion="in-progress"]` | +| Pending steps | `check-step:not([data-conclusion])` | +| Step name | `check-step[data-name]` attribute | +| Steps container | `check-steps` | +| Step details (expandable) | `details.CheckStep` inside `check-step` | + +### Dark Mode Detection + +GitHub stores theme info in: +```html + +``` + +Can also check: `document.documentElement.dataset.colorMode` or CSS custom properties. + +--- + +## Implementation Phases + +### Phase 1: WebSocket-to-TCP Proxy (~1-2 hours) + +**File: `browser-ext/proxy/proxy.js`** + +A minimal Node.js script that bridges WebSocket to the DAP TCP server. + +**Responsibilities:** +1. Listen for WebSocket connections on port 4712 +2. For each WebSocket client, open TCP connection to localhost:4711 +3. Handle DAP message framing: + - WS→TCP: Wrap JSON with `Content-Length: N\r\n\r\n` + - TCP→WS: Parse headers, extract JSON, send to WebSocket +4. Log messages for debugging +5. Clean disconnect handling + +**Key implementation:** + +```javascript +const WebSocket = require('ws'); +const net = require('net'); + +const WS_PORT = 4712; +const DAP_HOST = '127.0.0.1'; +const DAP_PORT = 4711; + +const wss = new WebSocket.Server({ port: WS_PORT }); + +wss.on('connection', (ws) => { + console.log('[Proxy] WebSocket client connected'); + + const tcp = net.createConnection({ host: DAP_HOST, port: DAP_PORT }); + let buffer = ''; + + // WebSocket → TCP (add Content-Length framing) + ws.on('message', (data) => { + const json = data.toString(); + const framed = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`; + tcp.write(framed); + }); + + // TCP → WebSocket (parse Content-Length framing) + tcp.on('data', (chunk) => { + buffer += chunk.toString(); + // Parse DAP messages from buffer... + // For each complete message, ws.send(json) + }); + + // Handle disconnects + ws.on('close', () => tcp.end()); + tcp.on('close', () => ws.close()); +}); +``` + +**File: `browser-ext/proxy/package.json`** + +```json +{ + "name": "dap-websocket-proxy", + "version": "1.0.0", + "main": "proxy.js", + "dependencies": { + "ws": "^8.16.0" + } +} +``` + +--- + +### Phase 2: Chrome Extension Scaffold (~1 hour) + +**File: `browser-ext/manifest.json`** + +```json +{ + "manifest_version": 3, + "name": "Actions DAP Debugger", + "version": "0.1.0", + "description": "Debug GitHub Actions workflows with DAP", + "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:** Create simple debug-themed icons (bug icon or similar). + +--- + +### Phase 3: Background Script - DAP Client (~2-3 hours) + +**File: `browser-ext/background/background.js`** + +**Responsibilities:** + +1. **Connection management:** + - Connect to WebSocket proxy on user action (popup button) + - Handle disconnect/reconnect + - Track connection state (disconnected, connecting, connected, paused, running) + +2. **DAP protocol handling:** + - Sequence number tracking + - Request/response correlation (pending requests Map) + - Event dispatch to content script + +3. **DAP commands to implement:** + +| Command | Purpose | +|---------|---------| +| `initialize` | Exchange capabilities | +| `attach` | Attach to running debug session | +| `configurationDone` | Signal ready to receive events | +| `threads` | Get thread list (single thread for job) | +| `stackTrace` | Get current step + history as frames | +| `scopes` | Get scope categories for a frame | +| `variables` | Get variables for a scope/object | +| `evaluate` | Expression eval + REPL commands | +| `continue` | Run to end or next breakpoint | +| `next` | Step to next step | +| `stepBack` | Step back to previous checkpoint | +| `reverseContinue` | Go back to first checkpoint | +| `disconnect` | End debug session | + +4. **Message relay structure:** + +```javascript +// Content script → Background +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'dap-request') { + sendDapRequest(message.command, message.args) + .then(response => sendResponse({ success: true, body: response })) + .catch(error => sendResponse({ success: false, error: error.message })); + return true; // Async response + } + if (message.type === 'connect') { + connectToProxy(message.host, message.port); + } +}); + +// Background → Content script (events) +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 }); + }); + }); +} +``` + +--- + +### Phase 4: Content Script - UI Injection (~3-4 hours) + +**File: `browser-ext/content/content.js`** + +#### 4.1 Step Detection & Mapping + +```javascript +// Build step map: DAP frame index → DOM element +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; +} + +// Match DAP frame to DOM step +// DAP stackTrace returns frames with name = step display name +function findStepByName(stepName) { + return document.querySelector(`check-step[data-name="${CSS.escape(stepName)}"]`); +} +``` + +#### 4.2 Debugger Pane Structure + +```html +
+ +
+ ... + Debugger + Paused before: Run cat doesnotexist + PAUSED +
+ + +
+ +
+
Variables
+
+ +
+
+ + +
+
Console
+
+ +
+
+ +
+
+
+ + +
+ + + + + + Step 4 of 8 · Checkpoints: 3 + +
+
+``` + +#### 4.3 Pane Injection + +```javascript +function injectDebuggerPane(beforeStep) { + // Remove existing pane if any + const existing = document.querySelector('.dap-debugger-pane'); + if (existing) existing.remove(); + + // Create pane + const pane = document.createElement('div'); + pane.className = 'dap-debugger-pane px-2 mb-2 border rounded-2'; + pane.innerHTML = PANE_HTML; // Template from above + + // Insert before the target step + beforeStep.parentNode.insertBefore(pane, beforeStep); + + // Setup event handlers + setupPaneEventHandlers(pane); + + return pane; +} + +function moveDebuggerPane(newStepIndex, stepName) { + const pane = document.querySelector('.dap-debugger-pane'); + const steps = document.querySelectorAll('check-step'); + const targetStep = steps[newStepIndex]; + + if (pane && targetStep) { + targetStep.parentNode.insertBefore(pane, targetStep); + pane.querySelector('.dap-header .color-fg-muted').textContent = + `Paused before: ${stepName}`; + pane.dataset.dapStep = targetStep.dataset.number; + } +} +``` + +#### 4.4 Scopes Tree Rendering + +```javascript +async function loadScopes(frameId) { + const response = await sendDapRequest('scopes', { frameId }); + const scopesContainer = document.querySelector('.dap-scope-tree'); + scopesContainer.innerHTML = ''; + + for (const scope of response.scopes) { + const node = createTreeNode(scope.name, scope.variablesReference, true); + scopesContainer.appendChild(node); + } +} + +function createTreeNode(name, variablesReference, isExpandable) { + const node = document.createElement('div'); + node.className = 'dap-tree-node'; + node.dataset.variablesRef = variablesReference; + node.innerHTML = ` + ${isExpandable ? '▶' : ' '} + ${escapeHtml(name)} + `; + + if (isExpandable) { + node.addEventListener('click', () => toggleTreeNode(node)); + } + + return node; +} + +async function toggleTreeNode(node) { + const children = node.querySelector('.dap-tree-children'); + if (children) { + children.hidden = !children.hidden; + node.querySelector('.dap-expand-icon').textContent = children.hidden ? '▶' : '▼'; + return; + } + + // Fetch children + const variablesRef = parseInt(node.dataset.variablesRef); + 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) { + if (variable.variablesReference > 0) { + // Expandable + const childNode = createTreeNode(variable.name, variable.variablesReference, true); + childNode.querySelector('.text-bold').insertAdjacentHTML('afterend', + `: ${escapeHtml(variable.value)}`); + childContainer.appendChild(childNode); + } else { + // Leaf + const leaf = document.createElement('div'); + leaf.className = 'dap-tree-leaf'; + leaf.innerHTML = ` + ${escapeHtml(variable.name)}: + ${escapeHtml(variable.value)} + `; + childContainer.appendChild(leaf); + } + } + + node.appendChild(childContainer); + node.querySelector('.dap-expand-icon').textContent = '▼'; +} +``` + +#### 4.5 REPL Console + +```javascript +function setupReplInput(pane) { + const input = pane.querySelector('.dap-repl-input input'); + const output = pane.querySelector('.dap-repl-output'); + const history = []; + let historyIndex = -1; + + input.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + const command = input.value.trim(); + if (!command) return; + + history.push(command); + historyIndex = history.length; + input.value = ''; + + // Show command + appendOutput(output, `> ${command}`, 'input'); + + // Send to DAP + try { + const response = await sendDapRequest('evaluate', { + expression: command, + context: command.startsWith('!') ? 'repl' : 'watch' + }); + appendOutput(output, response.result, 'result'); + } catch (error) { + appendOutput(output, error.message, 'error'); + } + } else if (e.key === 'ArrowUp') { + if (historyIndex > 0) { + historyIndex--; + input.value = history[historyIndex]; + } + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + if (historyIndex < history.length - 1) { + historyIndex++; + input.value = history[historyIndex]; + } else { + historyIndex = history.length; + input.value = ''; + } + e.preventDefault(); + } + }); +} + +function appendOutput(container, text, type) { + 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'); + line.textContent = text; + container.appendChild(line); + container.scrollTop = container.scrollHeight; +} +``` + +#### 4.6 DAP Event Handling + +```javascript +chrome.runtime.onMessage.addListener((message) => { + if (message.type !== 'dap-event') return; + + const event = message.event; + + switch (event.event) { + case 'stopped': + handleStoppedEvent(event.body); + break; + case 'output': + handleOutputEvent(event.body); + break; + case 'terminated': + handleTerminatedEvent(); + break; + } +}); + +async function handleStoppedEvent(body) { + // Update status + updateStatus('PAUSED', body.reason); + enableControls(true); + + // Get current location + const stackTrace = await sendDapRequest('stackTrace', { threadId: 1 }); + if (stackTrace.stackFrames.length > 0) { + const currentFrame = stackTrace.stackFrames[0]; + moveDebuggerPane(currentFrame.id, currentFrame.name); + await loadScopes(currentFrame.id); + } +} + +function handleOutputEvent(body) { + const output = document.querySelector('.dap-repl-output'); + if (output) { + const category = body.category === 'stderr' ? 'error' : 'stdout'; + appendOutput(output, body.output.trimEnd(), category); + } +} + +function handleTerminatedEvent() { + updateStatus('TERMINATED'); + enableControls(false); +} +``` + +#### 4.7 Control Buttons + +```javascript +function setupControlButtons(pane) { + 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(`DAP ${action} failed:`, error); + appendOutput(document.querySelector('.dap-repl-output'), + `Error: ${error.message}`, 'error'); + enableControls(true); + updateStatus('ERROR'); + } + }); + }); +} + +function enableControls(enabled) { + document.querySelectorAll('.dap-controls button').forEach(btn => { + btn.disabled = !enabled; + }); +} + +function updateStatus(status, reason) { + const label = document.querySelector('.dap-header .Label'); + if (label) { + label.textContent = status; + label.className = 'Label ml-auto ' + { + 'PAUSED': 'Label--attention', + 'RUNNING': 'Label--success', + 'TERMINATED': 'Label--secondary', + 'ERROR': 'Label--danger' + }[status]; + } +} +``` + +--- + +### Phase 5: Styling (~1 hour) + +**File: `browser-ext/content/content.css`** + +```css +/* Match GitHub's Primer design system */ +.dap-debugger-pane { + background-color: var(--bgColor-default, #0d1117); + border-color: var(--borderColor-default, #30363d) !important; + margin-left: 8px; + margin-right: 8px; +} + +.dap-header { + background-color: var(--bgColor-muted, #161b22); +} + +.dap-scopes { + border-color: var(--borderColor-default, #30363d) !important; +} + +.dap-scope-tree { + font-size: 12px; +} + +.dap-tree-node { + cursor: pointer; + padding: 2px 0; +} + +.dap-tree-node:hover { + background-color: var(--bgColor-muted, #161b22); +} + +.dap-tree-leaf { + padding: 2px 0; + padding-left: 16px; +} + +.dap-expand-icon { + display: inline-block; + width: 16px; + text-align: center; + color: var(--fgColor-muted, #8b949e); +} + +.dap-repl-output { + background-color: var(--bgColor-inset, #010409); + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; +} + +.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); +} + +.dap-repl-input input { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; + font-size: 12px; + background-color: var(--bgColor-inset, #010409); + border-color: var(--borderColor-default, #30363d); + color: var(--fgColor-default, #e6edf3); +} + +.dap-controls { + background-color: var(--bgColor-muted, #161b22); +} + +.dap-controls button { + min-width: 32px; +} + +.dap-controls button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Status labels */ +.Label--attention { + background-color: #9e6a03; + color: #ffffff; +} + +.Label--success { + background-color: #238636; + color: #ffffff; +} + +.Label--danger { + background-color: #da3633; + color: #ffffff; +} + +.Label--secondary { + background-color: #30363d; + color: #8b949e; +} +``` + +--- + +### Phase 6: Popup UI (~1 hour) + +**File: `browser-ext/popup/popup.html`** + +```html + + + + + + + + + + +``` + +**File: `browser-ext/popup/popup.js`** + +```javascript +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 hostInput = document.getElementById('proxy-host'); + const portInput = document.getElementById('proxy-port'); + + // Load saved config + chrome.storage.local.get(['proxyHost', 'proxyPort'], (data) => { + if (data.proxyHost) hostInput.value = data.proxyHost; + if (data.proxyPort) portInput.value = data.proxyPort; + }); + + // Get current status from background + chrome.runtime.sendMessage({ type: 'get-status' }, (response) => { + updateStatusUI(response.status); + }); + + connectBtn.addEventListener('click', () => { + const host = hostInput.value; + const port = parseInt(portInput.value); + + // Save config + chrome.storage.local.set({ proxyHost: host, proxyPort: port }); + + // Connect + chrome.runtime.sendMessage({ type: 'connect', host, port }, (response) => { + updateStatusUI(response.status); + }); + }); + + disconnectBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'disconnect' }, (response) => { + updateStatusUI(response.status); + }); + }); + + function updateStatusUI(status) { + statusText.textContent = status; + statusIndicator.className = 'status-indicator status-' + status.toLowerCase(); + connectBtn.disabled = (status !== 'disconnected'); + disconnectBtn.disabled = (status === 'disconnected'); + } +}); +``` + +**File: `browser-ext/popup/popup.css`** + +```css +body { + width: 300px; + padding: 16px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 14px; + background-color: #0d1117; + color: #e6edf3; +} + +h3 { + margin: 0 0 16px 0; + font-size: 16px; +} + +.status-section { + display: flex; + align-items: center; + margin-bottom: 16px; + padding: 8px; + background-color: #161b22; + border-radius: 6px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; +} + +.status-disconnected { background-color: #6e7681; } +.status-connecting { background-color: #9e6a03; } +.status-connected { background-color: #238636; } +.status-paused { background-color: #9e6a03; } +.status-error { background-color: #da3633; } + +.config-section { + margin-bottom: 16px; +} + +.config-section label { + display: block; + margin-bottom: 8px; + font-size: 12px; + color: #8b949e; +} + +.config-section input { + width: 100%; + padding: 8px; + margin-top: 4px; + background-color: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #e6edf3; + font-size: 14px; +} + +.actions-section { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +button { + flex: 1; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: #238636; + color: white; +} + +.btn-secondary { + background-color: #30363d; + color: #e6edf3; +} + +.help-section { + font-size: 12px; + color: #8b949e; +} + +.help-section p { + margin: 4px 0; +} + +.help-section code { + background-color: #161b22; + padding: 2px 4px; + border-radius: 3px; + font-family: ui-monospace, monospace; +} +``` + +--- + +### Phase 7: DAP Protocol Helpers + +**File: `browser-ext/lib/dap-protocol.js`** + +```javascript +// DAP message types and constants +const DapCommands = { + INITIALIZE: 'initialize', + ATTACH: 'attach', + CONFIGURATION_DONE: 'configurationDone', + THREADS: 'threads', + STACK_TRACE: 'stackTrace', + SCOPES: 'scopes', + VARIABLES: 'variables', + EVALUATE: 'evaluate', + CONTINUE: 'continue', + NEXT: 'next', + STEP_BACK: 'stepBack', + REVERSE_CONTINUE: 'reverseContinue', + DISCONNECT: 'disconnect' +}; + +const DapEvents = { + STOPPED: 'stopped', + OUTPUT: 'output', + TERMINATED: 'terminated', + INITIALIZED: 'initialized' +}; + +// Helper to create DAP request +function createDapRequest(seq, command, args = {}) { + return { + seq, + type: 'request', + command, + arguments: args + }; +} + +// Helper to parse DAP response/event +function parseDapMessage(json) { + const msg = JSON.parse(json); + return { + isResponse: msg.type === 'response', + isEvent: msg.type === 'event', + seq: msg.seq, + requestSeq: msg.request_seq, + command: msg.command, + event: msg.event, + success: msg.success, + body: msg.body, + message: msg.message + }; +} + +// Export for use in other scripts +if (typeof module !== 'undefined') { + module.exports = { DapCommands, DapEvents, createDapRequest, parseDapMessage }; +} +``` + +--- + +## DAP Protocol Flow + +``` +Extension Proxy Runner + │ │ │ + │──── WebSocket connect ──►│ │ + │ │──── TCP connect ────────►│ + │◄─── "connected" ─────────│ │ + │ │ │ + │──── initialize ─────────►│──── initialize ─────────►│ + │◄─── InitializeResponse ──│◄─── InitializeResponse ──│ + │ │ │ + │──── attach ─────────────►│──── attach ─────────────►│ + │◄─── AttachResponse ──────│◄─── AttachResponse ──────│ + │ │ │ + │──── configurationDone ──►│──── configurationDone ──►│ + │◄─── ConfigDoneResponse ──│◄─── ConfigDoneResponse ──│ + │ │ │ + │◄─── stopped event ───────│◄─── stopped event ───────│ (paused at step) + │ │ │ + │──── threads ────────────►│──── threads ────────────►│ + │◄─── ThreadsResponse ─────│◄─── ThreadsResponse ─────│ + │ │ │ + │──── stackTrace ─────────►│──── stackTrace ─────────►│ + │◄─── StackTraceResponse ──│◄─── StackTraceResponse ──│ + │ │ │ + │──── scopes ─────────────►│──── scopes ─────────────►│ + │◄─── ScopesResponse ──────│◄─── ScopesResponse ──────│ + │ │ │ + │──── variables ──────────►│──── variables ──────────►│ (user expands scope) + │◄─── VariablesResponse ───│◄─── VariablesResponse ───│ + │ │ │ + │──── evaluate ───────────►│──── evaluate ───────────►│ (REPL command) + │◄─── output events ───────│◄─── output events ───────│ (streaming) + │◄─── EvaluateResponse ────│◄─── EvaluateResponse ────│ + │ │ │ + │──── next ───────────────►│──── next ───────────────►│ (step to next) + │◄─── NextResponse ────────│◄─── NextResponse ────────│ + │◄─── stopped event ───────│◄─── stopped event ───────│ (paused at next step) +``` + +--- + +## Files Summary + +| File | Lines Est. | Purpose | +|------|------------|---------| +| `proxy/proxy.js` | ~100 | WebSocket↔TCP bridge with DAP framing | +| `proxy/package.json` | ~10 | Proxy dependencies | +| `manifest.json` | ~35 | Extension configuration | +| `background/background.js` | ~300 | DAP client, WebSocket, message relay | +| `content/content.js` | ~450 | DOM manipulation, pane injection, UI | +| `content/content.css` | ~150 | Debugger pane styling | +| `lib/dap-protocol.js` | ~50 | DAP message helpers | +| `popup/popup.html` | ~40 | Popup structure | +| `popup/popup.js` | ~80 | Popup logic | +| `popup/popup.css` | ~80 | Popup styling | + +**Total: ~1,300 lines** + +--- + +## Testing Plan + +### 1. Proxy Testing +- [ ] Start proxy, verify WebSocket accepts connection +- [ ] Test with simple WebSocket client (wscat) +- [ ] Connect nvim-dap through proxy to verify passthrough works + +### 2. Extension Load Testing +- [ ] Load unpacked extension in Chrome +- [ ] Navigate to GitHub Actions job page +- [ ] Verify content script activates (check console) +- [ ] Click popup, verify UI renders + +### 3. Integration Testing +- [ ] Start proxy +- [ ] Run workflow with "Enable debug logging" +- [ ] Connect extension via popup +- [ ] Verify debugger pane appears +- [ ] Test scope expansion +- [ ] Test REPL commands: `${{ github.ref }}`, `!env | grep GITHUB` +- [ ] Test step controls: next, continue +- [ ] Test step-back functionality +- [ ] Verify pane moves between steps + +### 4. Edge Cases +- [ ] Disconnect/reconnect handling +- [ ] Proxy not running error +- [ ] DAP server timeout +- [ ] Large variable values +- [ ] Special characters in step names + +--- + +## Demo Flow + +1. **Setup:** + ```bash + cd browser-ext/proxy && npm install && node proxy.js + ``` + +2. **Load Extension:** + - Chrome → Extensions → Load unpacked → select `browser-ext/` + +3. **Start Debug Session:** + - Go to GitHub repo → Actions → Re-run job with "Enable debug logging" + - Wait for runner to show "DAP debugger waiting for connection..." + +4. **Connect:** + - Open job page in Chrome + - Click extension popup → "Connect" + - Debugger pane appears above first step + +5. **Demo Features:** + - Expand scopes to show `github`, `env`, `steps` contexts + - Run REPL: `${{ github.event_name }}` → shows "push" + - Run REPL: `!ls -la` → shows files + - Click "Step" → pane moves to next step + - Click "Step Back" → demonstrate time-travel + - Modify env via REPL, step forward, show fix works + +--- + +## Estimated Effort + +| Phase | Effort | +|-------|--------| +| Phase 1: WebSocket proxy | 1-2 hours | +| Phase 2: Extension scaffold | 1 hour | +| Phase 3: Background/DAP client | 2-3 hours | +| Phase 4: Content script/UI | 3-4 hours | +| Phase 5: Styling | 1 hour | +| Phase 6: Popup UI | 1 hour | +| Phase 7: DAP helpers | 0.5 hours | +| Testing & Polish | 1-2 hours | +| **Total** | **~11-14 hours** | + +--- + +## Future Enhancements (Out of Scope) + +- Firefox extension support +- Breakpoint setting UI (click on step to set breakpoint) +- Watch expressions panel +- Call stack visualization +- Integration with GitHub Codespaces (direct connection without proxy) +- Persistent connection across page navigations +- Multiple job debugging diff --git a/browser-ext/README.md b/browser-ext/README.md new file mode 100644 index 000000000..0bf0ed065 --- /dev/null +++ b/browser-ext/README.md @@ -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. diff --git a/browser-ext/background/background.js b/browser-ext/background/background.js new file mode 100644 index 000000000..102159ce4 --- /dev/null +++ b/browser-ext/background/background.js @@ -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'); diff --git a/browser-ext/content/content.css b/browser-ext/content/content.css new file mode 100644 index 000000000..da70556ee --- /dev/null +++ b/browser-ext/content/content.css @@ -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; +} diff --git a/browser-ext/content/content.js b/browser-ext/content/content.js new file mode 100644 index 000000000..1aa06d6df --- /dev/null +++ b/browser-ext/content/content.js @@ -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 ` +
+ + + + + + + Debugger + Connecting... + CONNECTING +
+ +
+ +
+
Variables
+
+
Connect to view variables
+
+
+ + +
+
Console
+
+
Welcome to Actions DAP Debugger
+
Enter expressions like: \${{ github.ref }}
+
Or shell commands: !ls -la
+
+
+ +
+
+
+ + +
+ + + + + + Not connected + +
+ `; +} + +/** + * 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 = '
Loading...
'; + + 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 = `
Error: ${escapeHtml(error.message)}
`; + } +} + +/** + * 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(); +} diff --git a/browser-ext/icons/generate.js b/browser-ext/icons/generate.js new file mode 100644 index 000000000..872155c01 --- /dev/null +++ b/browser-ext/icons/generate.js @@ -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!'); diff --git a/browser-ext/icons/icon128.png b/browser-ext/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..01c6c3d9d62f16321f7719fefb9850a1283df843 GIT binary patch literal 872 zcmV-u1DE`XP)xJ=V^r-QGnHbi!%J-=c>(>9p%F z=s`|A?fesZj1x~g|3D9M@@e;xU*IA?_xcF*hjTar{n;E0Kz}p`BhVkr!3gxnX5CNd zx0Q}Se`=NV9b<_60r9(paC`tW(SM-<=#Oam$48()Q9*v!2qHfbj76Xyp8#M0`r#Gj z9~FUqbOL|@=m%Gpe?$cOu?YYMpdXq5a0L319prb7AOR$Rz2ql=$r0$sb&`KWZ*Xh? zmC#!p7eGbyCc^>fhjo^JLvJ$_fqqm1NC2(n-_RQ+0F2(~QULlvyU4$xw|Y7P{g?!h z0Jf2TLvNMS-VH!MBmpFX z1R&)nfVJoi+Xk=`ddmcW(OV_}jNUQJ1k697hU*)o7#BG4N? z6hPGoc0h0Vko*L&4!vOlz~~JV07h^4Z~zq}*a5xa!}2Qz&^-dZT>`-9?GgY+Z})Tn ziC`XjvrF<5z&!M32>_!vyBt6w7)Nh4BtHR+qqiCkAQ4my~WZ162Z^t4VKHV9Dt5M|NUhFB!a``R}bLT z5$L~?020CB^1DQ^Dg^zf67oAmFe?Q8hjQ{e1uznVesk#nI!16W2>qs3@^^?}mt*o1 zfj{m313kovr=5R7k8$E@=U>o+oOIgtx9DL`IPLf@dZ3+8yL}2h)=sBgUPceM!)b@Z z=m9&Qb~g<@WXIFamY_%NaN5yw^uU!*yLlOU=!&PE5cKF3PCF=pmJsnp&{8742wGCa y7ePyl_#$YD5nl)`Hqwg`;o%bz9vIe8HP literal 0 HcmV?d00001 diff --git a/browser-ext/icons/icon16.png b/browser-ext/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..69a59b9aa4fffadfafed01d1a1033602831aca47 GIT binary patch literal 126 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`uAVNAAr*6y6Bd{TmdKI;Vst00|Q${Qv*} literal 0 HcmV?d00001 diff --git a/browser-ext/icons/icon48.png b/browser-ext/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..967eae086e369fd048ee12a4c41664cbaaf57a13 GIT binary patch literal 258 zcmV+d0sa1oP)TFpNmYLIB3gp2mVugtH;*0vz#DL;;cjXS@XAj%OeWfX1VU z0!#x)#zzrp0K7s4D8MRzp(OyxHKN4-7JwpX>yx?#YP|av=wIK7ki|O?@^}RB20@?M z1z;eUr#pTo#Blr!2sl28pvAi(l5iJ79D{?#@7L;wH)07*qo IM6N<$g7@ZTUjP6A literal 0 HcmV?d00001 diff --git a/browser-ext/manifest.json b/browser-ext/manifest.json new file mode 100644 index 000000000..f43eb121b --- /dev/null +++ b/browser-ext/manifest.json @@ -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" + } +} diff --git a/browser-ext/popup/popup.css b/browser-ext/popup/popup.css new file mode 100644 index 000000000..d4b987215 --- /dev/null +++ b/browser-ext/popup/popup.css @@ -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; +} diff --git a/browser-ext/popup/popup.html b/browser-ext/popup/popup.html new file mode 100644 index 000000000..1aeeb04f3 --- /dev/null +++ b/browser-ext/popup/popup.html @@ -0,0 +1,52 @@ + + + + + + + + + + + diff --git a/browser-ext/popup/popup.js b/browser-ext/popup/popup.js new file mode 100644 index 000000000..9db74a737 --- /dev/null +++ b/browser-ext/popup/popup.js @@ -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; + } +}); diff --git a/browser-ext/proxy/package-lock.json b/browser-ext/proxy/package-lock.json new file mode 100644 index 000000000..4eb567bd3 --- /dev/null +++ b/browser-ext/proxy/package-lock.json @@ -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 + } + } + } + } +} diff --git a/browser-ext/proxy/package.json b/browser-ext/proxy/package.json new file mode 100644 index 000000000..d20b78b28 --- /dev/null +++ b/browser-ext/proxy/package.json @@ -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" + } +} diff --git a/browser-ext/proxy/proxy.js b/browser-ext/proxy/proxy.js new file mode 100644 index 000000000..f767ad165 --- /dev/null +++ b/browser-ext/proxy/proxy.js @@ -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); + }); +});