| name | zap |
| description | Customize any running Electron desktop app (Slack, Codex, VS Code, Discord, Notion, etc.) live without modifying app files. Use when the user wants to change how an Electron app looks or behaves — move panels, change fonts, hide elements, inject features, restyle UI — all applied instantly via Chrome DevTools Protocol (CDP) while the app is running. |
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:
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')
`
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:
expression: `document.body.innerHTML.substring(0, 3000)`
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:
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
order property