From 9d104d32e989ffcab080741c93b1601f695cf346 Mon Sep 17 00:00:00 2001 From: dashty8 Date: Mon, 20 Apr 2026 15:06:43 +0300 Subject: [PATCH] Add config UI, Unicode typing, persistence, and macOS accessibility prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary of changes: 1. Phrase configuration UI - New config.html + preload-config.js: tray click opens a small window to add/remove whip phrases; Start button spawns the overlay. - Phrases persisted to {userData}/phrases.json. - Tray context menu gets a "Configure phrases…" entry. 2. Unicode phrase support on Windows - Adds VkKeyScanW + SendInput (KEYEVENTF_UNICODE) via koffi. - sendMacroWindows now uses the layout-VK path when the char is typable on the active keyboard layout (terminal-friendly, same as old code for ASCII) and falls back to KEYEVENTF_UNICODE for Arabic/emoji/etc. 3. macOS accessibility prompt on Start - systemPreferences.isTrustedAccessibilityClient(true) forces the OS Accessibility prompt the first time the user hits Start Whipping. - If still untrusted, a dialog offers a deep link to System Settings → Privacy & Security → Accessibility. 4. Housekeeping - requestSingleInstanceLock + disable-gpu-shader-disk-cache to silence the Windows "Unable to move the cache: Access is denied" spam that happens when a second instance is launched. package.json: add config.html and preload-config.js to the files array so they ship with the published package. Name, version, and bin entries unchanged. --- config.html | 167 +++++++++++++++++++++++++++++++ main.js | 249 +++++++++++++++++++++++++++++++++++++++------- package.json | 2 + preload-config.js | 7 ++ 4 files changed, 391 insertions(+), 34 deletions(-) create mode 100644 config.html create mode 100644 preload-config.js diff --git a/config.html b/config.html new file mode 100644 index 0000000..2bb8ef1 --- /dev/null +++ b/config.html @@ -0,0 +1,167 @@ + + + + +OpenWhip — Phrases + + + +

OpenWhip phrases

+

One of these is typed (after Ctrl+C) each time you crack the whip.

+ +
+ + +
+ + + + + + + + + diff --git a/main.js b/main.js index 1360458..affb147 100644 --- a/main.js +++ b/main.js @@ -1,27 +1,100 @@ -const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen } = require('electron'); +const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen, clipboard, systemPreferences, dialog, shell } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); +// Only one instance — a second launch would fight for the cache dir and spew +// "Unable to move the cache: Access is denied." errors. +if (!app.requestSingleInstanceLock()) { + app.quit(); + process.exit(0); +} + +// GPU shader disk cache isn't needed for a transparent overlay + canvas; skipping +// it also silences the GPU cache warnings on Windows. +app.commandLine.appendSwitch('disable-gpu-shader-disk-cache'); + // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── -let keybd_event, VkKeyScanA; +let keybd_event, VkKeyScanW, SendInput, INPUT, INPUT_SIZE; if (process.platform === 'win32') { try { const koffi = require('koffi'); const user32 = koffi.load('user32.dll'); keybd_event = user32.func('void __stdcall keybd_event(uint8_t bVk, uint8_t bScan, uint32_t dwFlags, uintptr_t dwExtraInfo)'); - VkKeyScanA = user32.func('int16_t __stdcall VkKeyScanA(int ch)'); + // Unicode-aware version of VkKeyScan. Returns -1 if the current layout has + // no single-key mapping (e.g. Arabic chars on a US keyboard). + VkKeyScanW = user32.func('int16_t __stdcall VkKeyScanW(uint16_t ch)'); + + // SendInput supports Unicode (KEYEVENTF_UNICODE) — VkKeyScanA is ASCII-only + // and silently drops Arabic / non-ANSI characters. + const MOUSEINPUT = koffi.struct('MOUSEINPUT', { + dx: 'int32_t', dy: 'int32_t', + mouseData: 'uint32_t', dwFlags: 'uint32_t', + time: 'uint32_t', dwExtraInfo: 'uintptr_t', + }); + const KEYBDINPUT = koffi.struct('KEYBDINPUT', { + wVk: 'uint16_t', wScan: 'uint16_t', + dwFlags: 'uint32_t', time: 'uint32_t', + dwExtraInfo: 'uintptr_t', + }); + const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', { + uMsg: 'uint32_t', wParamL: 'uint16_t', wParamH: 'uint16_t', + }); + const INPUT_UNION = koffi.union('INPUT_U', { + mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT, + }); + INPUT = koffi.struct('INPUT', { + type: 'uint32_t', + u: INPUT_UNION, + }); + INPUT_SIZE = koffi.sizeof(INPUT); + SendInput = user32.func(`uint32_t __stdcall SendInput(uint32_t nInputs, INPUT *pInputs, int cbSize)`); } catch (e) { console.warn('koffi not available – macro sending disabled', e.message); } } // ── Globals ───────────────────────────────────────────────────────────────── -let tray, overlay; +let tray, overlay, configWindow; let overlayReady = false; let spawnQueued = false; +const DEFAULT_PHRASES = [ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', +]; +let phrases = DEFAULT_PHRASES.slice(); + +function phrasesFile() { + return path.join(app.getPath('userData'), 'phrases.json'); +} + +function loadPhrases() { + try { + const raw = fs.readFileSync(phrasesFile(), 'utf8'); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every(p => typeof p === 'string')) { + phrases = parsed; + } + } catch (_) { + // first run or bad file — keep defaults + } +} + +function savePhrases(list) { + try { + fs.writeFileSync(phrasesFile(), JSON.stringify(list, null, 2), 'utf8'); + } catch (e) { + console.warn('savePhrases failed:', e?.message || e); + } +} + const VK_CONTROL = 0x11; const VK_RETURN = 0x0D; const VK_C = 0x43; @@ -170,6 +243,45 @@ function toggleOverlay() { } } +// ── Config window ─────────────────────────────────────────────────────────── +function createConfigWindow() { + configWindow = new BrowserWindow({ + width: 520, + height: 560, + resizable: false, + minimizable: false, + maximizable: false, + title: 'OpenWhip — Phrases', + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, 'preload-config.js'), + contextIsolation: true, + }, + }); + configWindow.setMenuBarVisibility(false); + configWindow.loadFile('config.html'); + configWindow.on('close', (e) => { + if (!app.isQuitting) { + e.preventDefault(); + configWindow.hide(); + } + }); +} + +function showConfigWindow() { + if (!configWindow) createConfigWindow(); + configWindow.show(); + configWindow.focus(); +} + +function handleTrayClick() { + // If the whip overlay is live, a tray click drops it and goes back to the editor. + if (overlay && overlay.isVisible()) { + overlay.webContents.send('drop-whip'); + } + showConfigWindow(); +} + // ── IPC ───────────────────────────────────────────────────────────────────── ipcMain.on('whip-crack', () => { try { @@ -180,19 +292,51 @@ ipcMain.on('whip-crack', () => { }); ipcMain.on('hide-overlay', () => { if (overlay) overlay.hide(); }); +ipcMain.handle('get-phrases', () => phrases); +ipcMain.on('save-phrases', (_e, list) => { + if (Array.isArray(list) && list.every(p => typeof p === 'string')) { + phrases = list; + savePhrases(phrases); + } +}); +function ensureMacAccessibility() { + if (process.platform !== 'darwin') return true; + // Passing `true` asks macOS to show the standard Accessibility prompt if + // the process isn't trusted yet. Returns the current trust state. + const trusted = systemPreferences.isTrustedAccessibilityClient(true); + if (trusted) return true; + + const choice = dialog.showMessageBoxSync({ + type: 'warning', + title: 'Accessibility required', + message: 'OpenWhip needs Accessibility access to type phrases.', + detail: + 'macOS should have just opened a prompt. If not, open:\n\n' + + ' System Settings → Privacy & Security → Accessibility\n\n' + + 'Enable the entry for Electron (or OpenWhip), then quit and relaunch this app.', + buttons: ['Open Accessibility Settings', 'Continue anyway'], + defaultId: 0, + cancelId: 1, + }); + if (choice === 0) { + shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'); + } + return false; +} + +ipcMain.on('start-whipping', () => { + if (!Array.isArray(phrases) || phrases.length === 0) return; + if (!ensureMacAccessibility()) return; + if (configWindow && configWindow.isVisible()) configWindow.hide(); + if (overlay && overlay.isVisible()) return; + toggleOverlay(); +}); + // ── Macro: immediate Ctrl+C, type "Go FASER", Enter ─────────────────────── function sendMacro() { - // Pick a random phrase from a list of similar phrases and type it out - const phrases = [ - 'FASTER', - 'FASTER', - 'FASTER', - 'GO FASTER', - 'Faster CLANKER', - 'Work FASTER', - 'Speed it up clanker', - ]; - const chosen = phrases[Math.floor(Math.random() * phrases.length)]; + // Pick a random phrase from the user's configured list (falls back to defaults) + const pool = (Array.isArray(phrases) && phrases.length > 0) ? phrases : DEFAULT_PHRASES; + const chosen = pool[Math.floor(Math.random() * pool.length)]; if (process.platform === 'win32') { sendMacroWindows(chosen); @@ -204,27 +348,60 @@ function sendMacro() { } function sendMacroWindows(text) { - if (!keybd_event || !VkKeyScanA) return; - const tapKey = vk => { - keybd_event(vk, 0, 0, 0); - keybd_event(vk, 0, KEYUP, 0); - }; - const tapChar = ch => { - const packed = VkKeyScanA(ch.charCodeAt(0)); - if (packed === -1) return; - const vk = packed & 0xff; - const shiftState = (packed >> 8) & 0xff; - if (shiftState & 1) keybd_event(0x10, 0, 0, 0); // Shift down - tapKey(vk); - if (shiftState & 1) keybd_event(0x10, 0, KEYUP, 0); // Shift up - }; + if (!keybd_event) return; - // Ctrl+C (interrupt) + // Ctrl+C (interrupt) — virtual keys so TUIs receive it as a real signal. keybd_event(VK_CONTROL, 0, 0, 0); keybd_event(VK_C, 0, 0, 0); keybd_event(VK_C, 0, KEYUP, 0); keybd_event(VK_CONTROL, 0, KEYUP, 0); - for (const ch of text) tapChar(ch); + + const KEYEVENTF_KEYUP = 0x0002; + const KEYEVENTF_UNICODE = 0x0004; + const INPUT_KEYBOARD = 1; + + // Unicode path (for chars the current layout can't type directly). + // Windows TUIs often ignore WM_CHAR from KEYEVENTF_UNICODE, but GUI apps + // accept it, and we have no better option for non-layout chars. + const typeUnicode = (codeUnit) => { + if (!SendInput) return; + const mkKi = (flags) => ({ + type: INPUT_KEYBOARD, + u: { ki: { + wVk: 0, wScan: codeUnit, + dwFlags: flags, time: 0, dwExtraInfo: 0, + } }, + }); + SendInput(1, [mkKi(KEYEVENTF_UNICODE)], INPUT_SIZE); + SendInput(1, [mkKi(KEYEVENTF_UNICODE | KEYEVENTF_KEYUP)], INPUT_SIZE); + }; + + // VK path — the old, terminal-friendly route. Shift/Ctrl/Alt are honored + // per VkKeyScanW's shift-state byte. + const typeVk = (packed) => { + const vk = packed & 0xff; + const shift = (packed >> 8) & 0xff; + if (shift & 1) keybd_event(0x10, 0, 0, 0); // Shift down + if (shift & 2) keybd_event(VK_CONTROL, 0, 0, 0); // Ctrl down + if (shift & 4) keybd_event(VK_MENU, 0, 0, 0); // Alt down + keybd_event(vk, 0, 0, 0); + keybd_event(vk, 0, KEYUP, 0); + if (shift & 4) keybd_event(VK_MENU, 0, KEYUP, 0); + if (shift & 2) keybd_event(VK_CONTROL, 0, KEYUP, 0); + if (shift & 1) keybd_event(0x10, 0, KEYUP, 0); + }; + + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + const packed = VkKeyScanW ? VkKeyScanW(code) : -1; + // -1 (0xFFFF as int16) or high byte 0xFF means "no single-key mapping". + if (packed !== -1 && packed !== 0xFFFF && ((packed >> 8) & 0xff) !== 0xff) { + typeVk(packed); + } else { + typeUnicode(code); + } + } + keybd_event(VK_RETURN, 0, 0, 0); keybd_event(VK_RETURN, 0, KEYUP, 0); } @@ -277,14 +454,18 @@ function sendMacroLinux(text) { // ── App lifecycle ─────────────────────────────────────────────────────────── app.whenReady().then(async () => { + loadPhrases(); tray = new Tray(await getTrayIcon()); - tray.setToolTip('OpenWhip - click for whip'); + tray.setToolTip('OpenWhip - click to configure'); tray.setContextMenu( Menu.buildFromTemplate([ - { label: 'Quit', click: () => app.quit() }, + { label: 'Configure phrases…', click: () => showConfigWindow() }, + { type: 'separator' }, + { label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } }, ]) ); - tray.on('click', toggleOverlay); + tray.on('click', handleTrayClick); }); +app.on('before-quit', () => { app.isQuitting = true; }); app.on('window-all-closed', e => e.preventDefault()); // keep alive in tray diff --git a/package.json b/package.json index 36e2ea9..4dfda69 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "files": [ "main.js", "preload.js", + "preload-config.js", "overlay.html", + "config.html", "sounds", "icon", "bin/openwhip.js", diff --git a/preload-config.js b/preload-config.js new file mode 100644 index 0000000..0886484 --- /dev/null +++ b/preload-config.js @@ -0,0 +1,7 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('bridge', { + getPhrases: () => ipcRenderer.invoke('get-phrases'), + savePhrases: (phrases) => ipcRenderer.send('save-phrases', phrases), + startWhipping: () => ipcRenderer.send('start-whipping'), +});