From 618d6443fe58afcdf72894e275f33f5119f65845 Mon Sep 17 00:00:00 2001 From: Ettore Pane Date: Sat, 11 Apr 2026 18:13:28 +0200 Subject: [PATCH] Add Linux and GNOME support for overlay launcher --- README.md | 30 +++- bin/badclaude.js | 9 +- launcher.html | 128 ++++++++++++++++ main.js | 366 ++++++++++++++++++++++++++++++++++++++++------ overlay.html | 25 +++- package-lock.json | 9 ++ package.json | 7 +- preload.js | 3 + scripts/start.js | 38 +++++ 9 files changed, 564 insertions(+), 51 deletions(-) create mode 100644 launcher.html create mode 100644 scripts/start.js diff --git a/README.md b/README.md index e7756af..1244383 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,34 @@ npm install -g badclaude badclaude ``` +## Platform support + +- Windows: full support. +- macOS: full support after granting Accessibility permissions to the terminal or packaged app. +- Linux X11/Xorg: full whip mode when `xdotool` is installed. +- Linux Wayland: the app prefers X11/XWayland automatically when available so the overlay can render more reliably; global typing into native Wayland apps may still be unavailable by design. + +### Linux notes + +For full Linux automation on X11/Xorg, install `xdotool` first. + +Examples: + +```bash +# Debian/Ubuntu +sudo apt install xdotool + +# Fedora +sudo dnf install xdotool + +# Arch +sudo pacman -S xdotool +``` + +Wayland sessions can still run the app, but some desktops block the exact cross-app keyboard automation this toy relies on. On GNOME-like desktops, tray support may also depend on AppIndicator/StatusNotifier extensions. + +On GNOME, badclaude also shows a small floating launcher window because the tray icon is often hidden entirely. + ## Controls - Click tray icon: spawn whip. @@ -24,4 +52,4 @@ badclaude - [x] Cease and desist letter from Anthropic - [ ] Crypto miner - [ ] Logs of how many times you whipped claude so when the robots come we can order people nicely for them -- [ ] Updated whip physics \ No newline at end of file +- [ ] Updated whip physics diff --git a/bin/badclaude.js b/bin/badclaude.js index 29f8c7e..0f4341e 100644 --- a/bin/badclaude.js +++ b/bin/badclaude.js @@ -11,9 +11,16 @@ try { } const appPath = path.resolve(__dirname, '..'); +const extraArgs = process.argv.slice(2); +const env = { ...process.env }; -const child = spawn(electronBinary, [appPath], { +if (process.platform === 'linux' && env.WAYLAND_DISPLAY && env.DISPLAY && !env.ELECTRON_OZONE_PLATFORM_HINT) { + env.ELECTRON_OZONE_PLATFORM_HINT = 'x11'; +} + +const child = spawn(electronBinary, [appPath, ...extraArgs], { detached: true, + env, stdio: 'ignore', windowsHide: true, }); diff --git a/launcher.html b/launcher.html new file mode 100644 index 0000000..8a009ec --- /dev/null +++ b/launcher.html @@ -0,0 +1,128 @@ + + + + + + + + +
+ + +
+ + + diff --git a/main.js b/main.js index 526d9ec..d3848b7 100644 --- a/main.js +++ b/main.js @@ -3,6 +3,33 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); +const { promisify } = require('util'); + +const execFileAsync = promisify(execFile); +const linuxSessionType = process.platform === 'linux' + ? ((process.env.XDG_SESSION_TYPE || '').toLowerCase() || (process.env.WAYLAND_DISPLAY ? 'wayland' : (process.env.DISPLAY ? 'x11' : 'unknown'))) + : 'unsupported'; +const initialLinuxDesktop = process.platform === 'linux' + ? `${process.env.XDG_CURRENT_DESKTOP || ''}:${process.env.DESKTOP_SESSION || ''}`.toLowerCase() + : ''; +const preferLinuxX11 = process.platform === 'linux' + && linuxSessionType === 'wayland' + && Boolean(process.env.DISPLAY) + && !initialLinuxDesktop.includes('gnome'); +const linuxOzonePlatformHint = process.platform === 'linux' + ? (process.env.ELECTRON_OZONE_PLATFORM_HINT || '').trim() + : ''; +const hasExplicitLinuxOzonePlatform = process.platform === 'linux' + && (Boolean(linuxOzonePlatformHint) || app.commandLine.hasSwitch('ozone-platform')); +const forceLinuxX11 = preferLinuxX11 && !hasExplicitLinuxOzonePlatform; + +if (process.platform === 'linux') { + app.disableHardwareAcceleration(); + if (forceLinuxX11) { + app.commandLine.appendSwitch('ozone-platform', 'x11'); + } + app.commandLine.appendSwitch('enable-transparent-visuals'); +} // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── let keybd_event, VkKeyScanA; @@ -18,9 +45,19 @@ if (process.platform === 'win32') { } // ── Globals ───────────────────────────────────────────────────────────────── -let tray, overlay; +let tray, overlay, launcher; let overlayReady = false; let spawnQueued = false; +const linuxDesktop = initialLinuxDesktop; +const linuxState = { + sessionType: detectLinuxSessionType(), + prefersX11: forceLinuxX11, + automationBackend: 'none', + usesOverlayFallback: process.env.BADCLAUDE_OPAQUE_OVERLAY === '1', + needsLauncher: linuxDesktop.includes('gnome'), + previousWindowId: null, + warned: new Set(), +}; const VK_CONTROL = 0x11; const VK_RETURN = 0x0D; @@ -29,32 +66,188 @@ const VK_MENU = 0x12; // Alt const VK_TAB = 0x09; const KEYUP = 0x0002; +function detectLinuxSessionType() { + if (process.platform !== 'linux') return 'unsupported'; + if (forceLinuxX11) return 'x11'; + const session = (process.env.XDG_SESSION_TYPE || '').toLowerCase(); + if (session === 'wayland' || session === 'x11') return session; + if (process.env.WAYLAND_DISPLAY) return 'wayland'; + if (process.env.DISPLAY) return 'x11'; + return 'unknown'; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function formatCommandError(err) { + const stderr = err?.stderr?.toString().trim(); + if (stderr) return stderr; + return err?.message || String(err); +} + +function warnOnce(key, message) { + if (linuxState.warned.has(key)) return; + linuxState.warned.add(key); + console.warn(message); +} + +async function execFileChecked(file, args, options = {}) { + return execFileAsync(file, args, { + timeout: 2500, + windowsHide: true, + ...options, + }); +} + +async function detectLinuxAutomationBackend() { + if (process.platform !== 'linux' || !process.env.DISPLAY) { + return 'none'; + } + try { + const { stdout } = await execFileChecked('xdotool', ['getactivewindow']); + if (stdout.trim()) return 'xdotool'; + } catch { + return 'none'; + } + return 'none'; +} + +async function detectLinuxNeedsLauncher() { + if (process.platform !== 'linux' || !linuxDesktop.includes('gnome')) { + return false; + } + + try { + const { stdout } = await execFileChecked('gsettings', ['get', 'org.gnome.shell', 'enabled-extensions']); + const enabled = stdout.toLowerCase(); + const hasTrayExtension = enabled.includes('appindicator') + || enabled.includes('kstatusnotifier') + || enabled.includes('trayicons'); + return !hasTrayExtension; + } catch { + return true; + } +} + +async function refreshLinuxAutomationBackend() { + if (process.platform !== 'linux') return 'none'; + linuxState.sessionType = detectLinuxSessionType(); + linuxState.automationBackend = await detectLinuxAutomationBackend(); + return linuxState.automationBackend; +} + +async function capturePreviousAppContext() { + if (process.platform !== 'linux') return; + linuxState.previousWindowId = null; + if (linuxState.automationBackend === 'none') { + await refreshLinuxAutomationBackend(); + } + if (linuxState.automationBackend !== 'xdotool') return; + try { + const { stdout } = await execFileChecked('xdotool', ['getactivewindow']); + const windowId = stdout.trim(); + if (windowId) linuxState.previousWindowId = windowId; + } catch (err) { + warnOnce('linux-window-capture', `badclaude: could not capture the previous Linux window: ${formatCommandError(err)}`); + } +} + +async function activateLinuxWindow(windowId) { + if (!windowId) return; + await execFileChecked('xdotool', ['windowactivate', '--sync', windowId]); +} + +async function sendMacroLinux(text) { + if (linuxState.automationBackend === 'none') { + await refreshLinuxAutomationBackend(); + } + + if (linuxState.automationBackend !== 'xdotool') { + const message = linuxState.sessionType === 'wayland' + ? 'badclaude: Linux automation is unavailable on this Wayland session. The overlay still works, but full whip mode needs X11/xdotool.' + : 'badclaude: Linux automation is unavailable because xdotool could not be used in this session.'; + warnOnce('linux-automation-unavailable', message); + return; + } + + if (!linuxState.previousWindowId) { + warnOnce('linux-no-target-window', 'badclaude: no previous Linux window was captured, so the whip macro was skipped.'); + return; + } + + try { + await activateLinuxWindow(linuxState.previousWindowId); + await sleep(40); + await execFileChecked('xdotool', ['key', '--clearmodifiers', '--window', linuxState.previousWindowId, 'ctrl+c']); + await sleep(30); + await execFileChecked('xdotool', ['type', '--delay', '1', '--clearmodifiers', '--window', linuxState.previousWindowId, text]); + await sleep(30); + await execFileChecked('xdotool', ['key', '--clearmodifiers', '--window', linuxState.previousWindowId, 'Return']); + } catch (err) { + warnOnce('linux-macro-failed', `badclaude: Linux whip macro failed: ${formatCommandError(err)}`); + } +} + +async function initializeLinuxSupport() { + if (process.platform !== 'linux') return; + await refreshLinuxAutomationBackend(); + linuxState.needsLauncher = await detectLinuxNeedsLauncher(); + console.log(`badclaude: linux session=${linuxSessionType}, windowing=${linuxState.sessionType}, automation=${linuxState.automationBackend}`); + if (linuxState.prefersX11) { + console.log('badclaude: forcing Electron onto X11/XWayland so the overlay can render on Wayland desktops that expose DISPLAY.'); + } + if (linuxSessionType === 'wayland' && !linuxState.prefersX11) { + console.log('badclaude: keeping native Wayland on this desktop so the overlay can stay compositor-native.'); + } + if (linuxState.needsLauncher) { + console.log('badclaude: GNOME detected, enabling the floating launcher because tray icons are often hidden.'); + console.log('badclaude: for a more integrated tray experience on GNOME, install/enable the "AppIndicator and KStatusNotifierItem Support" extension.'); + } +} + /** One Alt+Tab / Cmd+Tab so focus returns to the previously active app after tray click. */ -function refocusPreviousApp() { - const delayMs = 80; - const run = () => { - if (process.platform === 'win32') { - if (!keybd_event) return; - keybd_event(VK_MENU, 0, 0, 0); - keybd_event(VK_TAB, 0, 0, 0); - keybd_event(VK_TAB, 0, KEYUP, 0); - keybd_event(VK_MENU, 0, KEYUP, 0); - } else if (process.platform === 'darwin') { - const script = [ - 'tell application "System Events"', - ' key down command', - ' key code 48', // Tab - ' key up command', - 'end tell', - ].join('\n'); - execFile('osascript', ['-e', script], err => { - if (err) { - console.warn('refocus previous app (Cmd+Tab) failed:', err.message); - } - }); +async function refocusPreviousApp() { + await sleep(80); + if (process.platform === 'win32') { + if (!keybd_event) return; + keybd_event(VK_MENU, 0, 0, 0); + keybd_event(VK_TAB, 0, 0, 0); + keybd_event(VK_TAB, 0, KEYUP, 0); + keybd_event(VK_MENU, 0, KEYUP, 0); + return; + } + if (process.platform === 'darwin') { + const script = [ + 'tell application "System Events"', + ' key down command', + ' key code 48', // Tab + ' key up command', + 'end tell', + ].join('\n'); + try { + await execFileChecked('osascript', ['-e', script]); + } catch (err) { + console.warn('refocus previous app (Cmd+Tab) failed:', formatCommandError(err)); } - }; - setTimeout(run, delayMs); + return; + } + if (process.platform === 'linux') { + if (linuxState.automationBackend === 'none') { + await refreshLinuxAutomationBackend(); + } + if (linuxState.automationBackend !== 'xdotool' || !linuxState.previousWindowId) { + if (linuxState.sessionType === 'wayland') { + warnOnce('linux-refocus-unavailable', 'badclaude: Linux focus restore is limited on Wayland, so the overlay may stay in front until you click away.'); + } + return; + } + try { + await activateLinuxWindow(linuxState.previousWindowId); + } catch (err) { + warnOnce('linux-refocus-failed', `badclaude: could not refocus the previous Linux window: ${formatCommandError(err)}`); + } + } } function createTrayIconFallback() { @@ -116,14 +309,17 @@ async function getTrayIcon() { // ── Overlay window ────────────────────────────────────────────────────────── function createOverlay() { - const { bounds } = screen.getPrimaryDisplay(); + const { bounds } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const useLinuxOverlayFallback = process.platform === 'linux' && linuxState.usesOverlayFallback; overlay = new BrowserWindow({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, - transparent: true, + show: false, + transparent: !useLinuxOverlayFallback, + backgroundColor: useLinuxOverlayFallback ? '#12000000' : '#00ffffff', frame: false, alwaysOnTop: true, - focusable: false, + focusable: useLinuxOverlayFallback, skipTaskbar: true, resizable: false, hasShadow: false, @@ -132,14 +328,29 @@ function createOverlay() { }, }); overlay.setAlwaysOnTop(true, 'screen-saver'); + overlay.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + overlay.setFullScreenable(false); + if (useLinuxOverlayFallback) { + overlay.setOpacity(0.985); + } overlayReady = false; overlay.loadFile('overlay.html'); + overlay.once('ready-to-show', () => { + if (overlay && !overlay.isVisible()) { + overlay.show(); + } + }); overlay.webContents.on('did-finish-load', () => { overlayReady = true; + if (process.platform === 'linux') { + overlay.webContents.send('set-linux-overlay-mode', { + fallback: useLinuxOverlayFallback, + }); + } if (spawnQueued && overlay && overlay.isVisible()) { spawnQueued = false; overlay.webContents.send('spawn-whip'); - refocusPreviousApp(); + void refocusPreviousApp(); } }); overlay.on('closed', () => { @@ -149,16 +360,68 @@ function createOverlay() { }); } -function toggleOverlay() { +function createLauncherWindow() { + if (process.platform !== 'linux' || !linuxState.needsLauncher || launcher) return; + const workArea = screen.getPrimaryDisplay().workArea; + launcher = new BrowserWindow({ + x: workArea.x + workArea.width - 122, + y: workArea.y + 18, + width: 104, + height: 132, + show: false, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + title: 'badclaude launcher', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + }, + }); + launcher.setAlwaysOnTop(true, 'floating'); + launcher.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + launcher.removeMenu(); + launcher.loadFile('launcher.html'); + launcher.once('ready-to-show', () => { + launcher?.show(); + }); + launcher.on('closed', () => { + launcher = null; + }); +} + +function showLauncherWindow() { + createLauncherWindow(); + if (!launcher) return; + launcher.show(); + launcher.moveTop(); + launcher.focus(); +} + +function hideLauncherWindow() { + launcher?.hide(); +} + +async function toggleOverlay() { if (overlay && overlay.isVisible()) { overlay.webContents.send('drop-whip'); return; } + await capturePreviousAppContext(); if (!overlay) createOverlay(); overlay.show(); + overlay.moveTop(); + if (process.platform === 'linux' && linuxState.usesOverlayFallback) { + overlay.focus(); + } if (overlayReady) { overlay.webContents.send('spawn-whip'); - refocusPreviousApp(); + void refocusPreviousApp(); } else { spawnQueued = true; } @@ -166,16 +429,20 @@ function toggleOverlay() { // ── IPC ───────────────────────────────────────────────────────────────────── ipcMain.on('whip-crack', () => { - try { - sendMacro(); - } catch (err) { + void sendMacro().catch(err => { console.warn('sendMacro failed:', err?.message || err); - } + }); }); ipcMain.on('hide-overlay', () => { if (overlay) overlay.hide(); }); +ipcMain.on('toggle-overlay', () => { + void toggleOverlay(); +}); +ipcMain.on('quit-app', () => { + app.quit(); +}); // ── Macro: immediate Ctrl+C, type "Go FASER", Enter ─────────────────────── -function sendMacro() { +async function sendMacro() { // Pick a random phrase from a list of similar phrases and type it out const phrases = [ 'FASTER', @@ -191,7 +458,9 @@ function sendMacro() { if (process.platform === 'win32') { sendMacroWindows(chosen); } else if (process.platform === 'darwin') { - sendMacroMac(chosen); + await sendMacroMac(chosen); + } else if (process.platform === 'linux') { + await sendMacroLinux(chosen); } } @@ -221,7 +490,7 @@ function sendMacroWindows(text) { keybd_event(VK_RETURN, 0, KEYUP, 0); } -function sendMacroMac(text) { +async function sendMacroMac(text) { const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const script = [ 'tell application "System Events"', @@ -232,23 +501,32 @@ function sendMacroMac(text) { 'end tell' ].join('\n'); - execFile('osascript', ['-e', script], err => { - if (err) { - console.warn('mac macro failed (enable Accessibility for terminal/app):', err.message); - } - }); + try { + await execFileChecked('osascript', ['-e', script]); + } catch (err) { + console.warn('mac macro failed (enable Accessibility for terminal/app):', formatCommandError(err)); + } } // ── App lifecycle ─────────────────────────────────────────────────────────── app.whenReady().then(async () => { + await initializeLinuxSupport(); tray = new Tray(await getTrayIcon()); tray.setToolTip('Bad Claude – click for whip'); tray.setContextMenu( Menu.buildFromTemplate([ + { label: 'Whip', click: () => void toggleOverlay() }, + { type: 'separator' }, + { label: 'Show Launcher', click: () => showLauncherWindow() }, + { label: 'Hide Launcher', click: () => hideLauncherWindow() }, + { type: 'separator' }, { label: 'Quit', click: () => app.quit() }, ]) ); - tray.on('click', toggleOverlay); + tray.on('click', () => { + void toggleOverlay(); + }); + createLauncherWindow(); }); app.on('window-all-closed', e => e.preventDefault()); // keep alive in tray diff --git a/overlay.html b/overlay.html index 2354b86..c6c66aa 100644 --- a/overlay.html +++ b/overlay.html @@ -2,8 +2,10 @@ @@ -85,16 +87,24 @@ let handleAngle = P.baseTargetAngle; let handleAngVel = 0; +function setCursorHidden(hidden) { + document.body.classList.toggle('whip-active', hidden); +} + const WHIP_CRACK_SOUNDS = ['sounds/A.mp3', 'sounds/B.mp3', 'sounds/C.mp3', 'sounds/D.mp3', 'sounds/E.mp3']; document.addEventListener('mousemove', e => { mouseX = e.clientX; mouseY = e.clientY; }); document.addEventListener('mousedown', () => { - if (whip && !dropping) dropping = true; + if (whip && !dropping) { + dropping = true; + setCursorHidden(false); + } }); // ── Whip creation ─────────────────────────────────────────────────────────── function spawnWhip(mx, my) { dropping = false; + setCursorHidden(true); lastCrackTime = 0; whipSpawnTime = Date.now(); const pts = []; @@ -370,6 +380,7 @@ if (dropping && whip.every(p => p.y > H + 60)) { whip = null; dropping = false; + setCursorHidden(false); window.bridge.hideOverlay(); } prevMouseX = mouseX; @@ -439,6 +450,7 @@ window.bridge.onSpawnWhip(() => { whip = spawnWhip(mouseX || W / 2, mouseY || H / 2); dropping = false; + setCursorHidden(true); prevMouseX = mouseX; prevMouseY = mouseY; handleAngle = P.baseTargetAngle; @@ -446,7 +458,14 @@ }); window.bridge.onDropWhip(() => { - if (whip && !dropping) dropping = true; + if (whip && !dropping) { + dropping = true; + setCursorHidden(false); + } +}); + +window.bridge.onLinuxOverlayMode?.(({ fallback }) => { + document.body.classList.toggle('linux-overlay-fallback', Boolean(fallback)); }); diff --git a/package-lock.json b/package-lock.json index 5816cb2..edb6529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,21 @@ "": { "name": "badclaude", "version": "1.0.2", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], "dependencies": { "electron": "^33.0.0", "koffi": "^2.9.0" }, "bin": { "badclaude": "bin/badclaude.js" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@electron/get": { diff --git a/package.json b/package.json index 463b497..0fd9ea8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "os": [ "darwin", + "linux", "win32" ], "engines": { @@ -32,14 +33,16 @@ "main.js", "preload.js", "overlay.html", + "launcher.html", + "scripts/start.js", "sounds", "icon", "bin/badclaude.js", "README.md" ], "scripts": { - "start": "electron .", - "dev": "electron .", + "start": "node scripts/start.js", + "dev": "node scripts/start.js", "pack": "npm pack" }, "dependencies": { diff --git a/preload.js b/preload.js index ae83618..a83942d 100644 --- a/preload.js +++ b/preload.js @@ -3,6 +3,9 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('bridge', { whipCrack: () => ipcRenderer.send('whip-crack'), hideOverlay: () => ipcRenderer.send('hide-overlay'), + toggleOverlay: () => ipcRenderer.send('toggle-overlay'), + quitApp: () => ipcRenderer.send('quit-app'), onSpawnWhip: (fn) => ipcRenderer.on('spawn-whip', () => fn()), onDropWhip: (fn) => ipcRenderer.on('drop-whip', () => fn()), + onLinuxOverlayMode: (fn) => ipcRenderer.on('set-linux-overlay-mode', (_event, payload) => fn(payload)), }); diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 0000000..75d5f9f --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +const path = require('path'); +const { spawn } = require('child_process'); + +let electronBinary; +try { + electronBinary = require('electron'); +} catch (err) { + console.error('Could not load Electron. Run `npm install` first.'); + process.exit(1); +} + +const appPath = path.resolve(__dirname, '..'); +const extraArgs = process.argv.slice(2); +const env = { ...process.env }; + +if (process.platform === 'linux' && env.WAYLAND_DISPLAY && env.DISPLAY && !env.ELECTRON_OZONE_PLATFORM_HINT) { + env.ELECTRON_OZONE_PLATFORM_HINT = 'x11'; +} + +const child = spawn(electronBinary, [appPath, ...extraArgs], { + env, + stdio: 'inherit', + windowsHide: true, +}); + +child.on('error', err => { + console.error('Failed to start badclaude:', err.message); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +});