mirror of
https://github.com/actions/runner.git
synced 2026-01-16 16:58:29 +08:00
wip extension
This commit is contained in:
176
browser-ext/README.md
Normal file
176
browser-ext/README.md
Normal 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.
|
||||
292
browser-ext/background/background.js
Normal file
292
browser-ext/background/background.js
Normal 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');
|
||||
307
browser-ext/content/content.css
Normal file
307
browser-ext/content/content.css
Normal 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;
|
||||
}
|
||||
641
browser-ext/content/content.js
Normal file
641
browser-ext/content/content.js
Normal 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();
|
||||
}
|
||||
135
browser-ext/icons/generate.js
Normal file
135
browser-ext/icons/generate.js
Normal 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!');
|
||||
BIN
browser-ext/icons/icon128.png
Normal file
BIN
browser-ext/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 872 B |
BIN
browser-ext/icons/icon16.png
Normal file
BIN
browser-ext/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 B |
BIN
browser-ext/icons/icon48.png
Normal file
BIN
browser-ext/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 B |
32
browser-ext/manifest.json
Normal file
32
browser-ext/manifest.json
Normal 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
228
browser-ext/popup/popup.css
Normal 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;
|
||||
}
|
||||
52
browser-ext/popup/popup.html
Normal file
52
browser-ext/popup/popup.html
Normal 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>
|
||||
95
browser-ext/popup/popup.js
Normal file
95
browser-ext/popup/popup.js
Normal 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
36
browser-ext/proxy/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
browser-ext/proxy/package.json
Normal file
12
browser-ext/proxy/package.json
Normal 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
171
browser-ext/proxy/proxy.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user