Electron Live Patcher
Customize any Electron desktop app live using the Chrome DevTools Protocol (CDP). No file modification, no restarts, no sudo. Changes appear instantly in the running app.
IMPORTANT: Default workflow for ANY app
Never guess CSS class names. Every Electron app has a different structure. Always follow this order:
- Connect to the app via CDP
- Dump the live DOM to discover real selectors
- Only then write and inject CSS/JS
Use this snippet to inspect any unknown app first:
// Step 1 — find what elements exist related to the user's request
expression: `
Array.from(document.querySelectorAll('*'))
.filter(el => el.className && typeof el.className === 'string')
.filter(el => el.className.match(/sidebar|panel|nav|left|right|drawer|rail/i))
.map(el => el.tagName + ' classes: ' + el.className.substring(0, 100))
.slice(0, 20)
.join('\\n')
`
// Step 2 — inspect the layout container
expression: `document.body.innerHTML.substring(0, 4000)`
Then use what you find to write precise CSS targeting real class names from the live app.
How It Works
Every Electron app is built on Chromium. When launched with --remote-debugging-port=9222, it exposes a WebSocket API that lets you run JavaScript inside its renderer process — the same way browser DevTools work, but programmatically from outside the app.
Step-by-Step Process
1. Check if the app is running with CDP enabled
curl -s http://localhost:9222/json 2>/dev/null
If empty or connection refused, the app needs to be relaunched with the debug port.
2. Relaunch with CDP enabled (if needed)
pkill -x "AppName"
sleep 1
open -a "AppName" --args --remote-debugging-port=9222
sleep 3
3. Find the main page target
curl -s http://localhost:9222/json
Look for the target where "type": "page" and "title" matches the app name. Note its webSocketDebuggerUrl.
4. Inject CSS or JS via Node.js WebSocket
Create and run a temporary inject script:
const WebSocket = require('ws');
const http = require('http');
const CSS = `/* your CSS here */`;
function getTargets() {
return new Promise((resolve, reject) => {
http.get('http://localhost:9222/json', (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
}).on('error', reject);
});
}
async function inject() {
const targets = await getTargets();
const page = targets.find(t => t.type === 'page');
if (!page) return console.error('No page target found');
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 1;
ws.on('open', () => {
ws.send(JSON.stringify({
id: id++,
method: 'Runtime.evaluate',
params: {
expression: `
(function() {
const existing = document.getElementById('__zap__');
if (existing) existing.remove();
const style = document.createElement('style');
style.id = '__zap__';
style.textContent = ${JSON.stringify(CSS)};
document.head.appendChild(style);
return 'injected';
})()
`
}
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.result?.result?.value === 'injected') {
console.log('Zap applied!');
ws.close();
}
});
}
inject();
Run: node /tmp/zap.js (install ws first if needed: npm install -g ws)
Discovering the DOM Structure
Before writing CSS, inspect the app's live DOM:
// Dump top-level structure
expression: `document.body.innerHTML.substring(0, 3000)`
// Find sidebar/panel elements
expression: `
Array.from(document.querySelectorAll('[class*="sidebar"],[class*="panel"],[class*="nav"]'))
.map(el => el.tagName + ' ' + el.className.substring(0, 80))
.slice(0, 20).join('\\n')
`
Common Customizations
Move left sidebar to the right
.max-h-full.min-h-0.w-full.flex-1:has(> aside) {
flex-direction: row-reverse;
}
Hide an element
[data-testid="element-name"] { display: none !important; }
Change fonts globally
* { font-family: "Your Font", sans-serif !important; }
Custom theme colors
:root {
--background-color: #1a1a2e;
--text-color: #eee;
}
Inject a floating widget
const div = document.createElement('div');
div.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:9999;background:#000;color:#fff;padding:10px;border-radius:8px;';
div.textContent = 'Hello from Zap!';
document.body.appendChild(div);
Persisting Patches
CDP injection is in-memory only — it disappears on app restart or update. If the user asks to keep a change saved, follow these instructions based on their scenario:
Scenario 1: User wants changes to survive restarts
Save the patch as a named script file and create a shell alias that auto-relaunches the app with the debug port open and re-injects the patch every time.
Step 1 — Save the patch as a script (e.g. ~/patches/appname.js):
const WebSocket = require('ws');
const http = require('http');
const CSS = `/* the user's CSS here */`;
function getTargets() {
return new Promise((resolve, reject) => {
http.get('http://localhost:9222/json', (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
}).on('error', reject);
});
}
async function inject() {
const targets = await getTargets();
const page = targets.find(t => t.type === 'page');
if (!page) return console.error('App not found on debug port');
const ws = new WebSocket(page.webSocketDebuggerUrl);
ws.on('open', () => {
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: `
(function() {
const el = document.getElementById('__zap__');
if (el) el.remove();
const s = document.createElement('style');
s.id = '__zap__';
s.textContent = ${JSON.stringify(CSS)};
document.head.appendChild(s);
return 'injected';
})()
`
}
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.result?.result?.value === 'injected') { console.log('Patch applied!'); ws.close(); }
});
}
inject();
Step 2 — Create a shell alias so every time the user opens the app it auto-patches:
# Add to ~/.zshrc (replace AppName and patch path)
alias appname='pkill -x "AppName" 2>/dev/null; sleep 0.5; open -a "AppName" --args --remote-debugging-port=9222; sleep 3; node ~/patches/appname.js'
Then run source ~/.zshrc to activate. From now on the user just types appname in their terminal and it opens already patched.
Scenario 2: User wants changes to survive app updates
App updates don't affect the patch script — the CSS/JS lives in the patch file on disk, not inside the app. As long as:
- The patch script is saved (Scenario 1 above)
- The alias is set up
...updates are transparent. The app updates, the user reopens via alias, patches re-inject automatically.
The only time an update breaks a patch is if the app changes its DOM structure or class names — in that case, re-inspect the DOM and update the CSS selectors in the patch file.
App-Specific Notes
| App | Process Name | Notes |
|---|---|---|
| Slack | Slack | Legacy CSS class names, easy to target |
| Codex | Codex | Tailwind utility classes, use :has() selectors |
| VS Code | Electron | Has built-in DevTools; debug port may conflict on 9222 — try 9223 |
| Discord | Discord | May need --ignore-gpu-blacklist flag too |
| Notion | Notion | React-based, inspect DOM first |
| Cursor | Cursor | VS Code fork, same notes as VS Code |
Guidelines
- Always inspect the DOM before writing CSS — class names vary per app
- Use
__zap__as the style tag ID so re-injections replace cleanly - Prefer CSS over JS for visual changes — safer and easier to revert
- Test with a tiny change first (e.g.,
body { outline: 2px solid red }) to confirm injection works - The debug port (9222) is local-only — no security exposure
Examples
- "Move Codex sidebar to the right" → flex-direction row-reverse on the layout container
- "Hide Slack's sidebar" →
aside { display: none } - "Make VS Code font 16px" →
* { font-size: 16px !important } - "Add dark overlay to Notion" → inject a fixed div with rgba background
- "Move Discord member list to the left" → reorder flex children with
orderproperty