mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
215 lines
8.4 KiB
JavaScript
215 lines
8.4 KiB
JavaScript
const { app, globalShortcut, ipcMain, systemPreferences } = require('electron');
|
|
|
|
// Voice dictation hotkey, user-rebindable (Settings > Interface > Dictation shortcut), two tiers:
|
|
//
|
|
// NATIVE (uiohook-napi event tap): sees real key-down AND key-up globally, in or out of focus,
|
|
// immune to macOS's letter-keyup-under-Cmd suppression, so the keyboard gets TRUE hold-to-talk
|
|
// exactly like the mic buttons. Listen-only (never swallows keys from other apps).
|
|
//
|
|
// FALLBACK (globalShortcut while unfocused + before-input relay while focused): press-to-toggle,
|
|
// key-ups undetectable there.
|
|
//
|
|
// THE TRAP THIS FILE IS SHAPED AROUND: on macOS a listen-only keyboard tap needs the Input
|
|
// Monitoring grant, which is SEPARATE from Accessibility, and a tap without it starts cleanly and
|
|
// then delivers nothing (caught live on the packaged build). So "tap started" proves nothing; the
|
|
// fallback stays armed until the tap delivers its first real key event. To keep the two paths from
|
|
// double-firing on one press, fallback sends are deferred 90ms and skipped when the tap just
|
|
// handled a key; a deaf tap never updates that timestamp, so the fallback always fires.
|
|
//
|
|
// F5 is deliberately NOT a default: macOS's media-key layer routes it to Siri before any app sees
|
|
// it. It stays bindable for users who have remapped that key at the OS level.
|
|
|
|
const DEFAULT_COMBO = process.platform === 'darwin' ? 'Meta+Shift+d' : 'Ctrl+Shift+d';
|
|
const TAP_FRESH_MS = 200;
|
|
const FALLBACK_DEFER_MS = 90;
|
|
|
|
// "Meta+Shift+d" (renderer parts format, same as new_agent_shortcut) -> matcher pieces.
|
|
function parseCombo(str) {
|
|
const parts = String(str || DEFAULT_COMBO).split('+').filter(Boolean);
|
|
const key = parts[parts.length - 1] || 'd';
|
|
const mods = {
|
|
meta: parts.includes('Meta'),
|
|
ctrl: parts.includes('Ctrl') || parts.includes('Control'),
|
|
alt: parts.includes('Alt'),
|
|
shift: parts.includes('Shift'),
|
|
};
|
|
const accel = [
|
|
mods.meta ? 'Meta' : null,
|
|
mods.ctrl ? 'Control' : null,
|
|
mods.alt ? 'Alt' : null,
|
|
mods.shift ? 'Shift' : null,
|
|
key.length === 1 ? key.toUpperCase() : key,
|
|
].filter(Boolean).join('+');
|
|
return { key, mods, accel };
|
|
}
|
|
|
|
function uiohookKeycodeFor(key, UiohookKey) {
|
|
if (key.length === 1 && /[a-z]/i.test(key)) return UiohookKey[key.toUpperCase()];
|
|
if (key.length === 1 && /[0-9]/.test(key)) return UiohookKey[key];
|
|
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) return UiohookKey[key];
|
|
if (key === ' ' || key === 'Space') return UiohookKey.Space;
|
|
return undefined; // unmappable for the tap; the fallback tiers still cover it
|
|
}
|
|
|
|
function installVoiceHotkey(getMainWindow) {
|
|
const send = (channel) => {
|
|
const win = getMainWindow();
|
|
if (win && !win.isDestroyed()) win.webContents.send(channel);
|
|
};
|
|
|
|
let combo = parseCombo(DEFAULT_COMBO);
|
|
let tapProven = false;
|
|
let lastTapKeyMs = 0;
|
|
let registeredAccel = null;
|
|
|
|
const unregisterFallbackShortcut = () => {
|
|
if (!registeredAccel) return;
|
|
try { globalShortcut.unregister(registeredAccel); } catch (_) {}
|
|
registeredAccel = null;
|
|
};
|
|
|
|
// Fallback toggle, deferred so a live tap's hold-down wins the same press.
|
|
const sendFallbackToggle = () => {
|
|
setTimeout(() => {
|
|
if (Date.now() - lastTapKeyMs < TAP_FRESH_MS) return;
|
|
send('voice:toggle');
|
|
}, FALLBACK_DEFER_MS);
|
|
};
|
|
|
|
// Fallback shortcut stays registered while unfocused until the tap proves alive.
|
|
const registerVoiceShortcut = () => {
|
|
if (tapProven) return;
|
|
if (registeredAccel === combo.accel) return;
|
|
unregisterFallbackShortcut();
|
|
try {
|
|
if (globalShortcut.register(combo.accel, sendFallbackToggle)) registeredAccel = combo.accel;
|
|
} catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ }
|
|
};
|
|
|
|
let tapKeycode;
|
|
let UiohookKeyRef = null;
|
|
|
|
const tryStartNativeTap = () => {
|
|
try {
|
|
if (process.platform === 'darwin' && !systemPreferences.isTrustedAccessibilityClient(false)) {
|
|
console.log('[voice] no Accessibility grant, keyboard stays press-to-toggle');
|
|
return false;
|
|
}
|
|
const { uIOhook, UiohookKey } = require('uiohook-napi');
|
|
UiohookKeyRef = UiohookKey;
|
|
tapKeycode = uiohookKeycodeFor(combo.key, UiohookKey);
|
|
const MOD_KEYS = new Set([
|
|
UiohookKey.Ctrl, UiohookKey.CtrlRight, UiohookKey.Shift, UiohookKey.ShiftRight,
|
|
UiohookKey.Meta, UiohookKey.MetaRight, UiohookKey.Alt, UiohookKey.AltRight,
|
|
]);
|
|
let held = false;
|
|
|
|
const markAlive = () => {
|
|
lastTapKeyMs = Date.now();
|
|
if (!tapProven) {
|
|
tapProven = true;
|
|
unregisterFallbackShortcut();
|
|
console.log('[voice] native key tap PROVEN (events flowing), hold-to-talk enabled');
|
|
}
|
|
};
|
|
|
|
const modsMatch = (e) =>
|
|
(!combo.mods.meta || e.metaKey) &&
|
|
(!combo.mods.ctrl || e.ctrlKey) &&
|
|
(!combo.mods.alt || e.altKey) &&
|
|
(!combo.mods.shift || e.shiftKey);
|
|
|
|
uIOhook.on('keydown', (e) => {
|
|
markAlive();
|
|
if (held || tapKeycode === undefined) return;
|
|
if (e.keycode === tapKeycode && modsMatch(e)) {
|
|
held = true;
|
|
send('voice:hold-down');
|
|
}
|
|
});
|
|
uIOhook.on('keyup', (e) => {
|
|
markAlive();
|
|
if (!held) return;
|
|
if (e.keycode === tapKeycode || MOD_KEYS.has(e.keycode)) {
|
|
held = false;
|
|
send('voice:hold-up');
|
|
}
|
|
});
|
|
|
|
uIOhook.start();
|
|
app.on('will-quit', () => { try { uIOhook.stop(); } catch (_) {} });
|
|
console.log('[voice] native key tap armed (awaiting first event to prove Input Monitoring)');
|
|
return true;
|
|
} catch (e) {
|
|
console.log('[voice] native key tap unavailable (continuing with toggle):', e && e.message);
|
|
return false;
|
|
}
|
|
};
|
|
tryStartNativeTap();
|
|
registerVoiceShortcut();
|
|
app.on('browser-window-focus', unregisterFallbackShortcut);
|
|
app.on('browser-window-blur', registerVoiceShortcut);
|
|
|
|
const inputMatchesCombo = (input) => {
|
|
const k = combo.key;
|
|
const keyHit = k.length === 1
|
|
? (input.code === `Key${k.toUpperCase()}` || (input.key || '').toLowerCase() === k.toLowerCase())
|
|
: (input.code === k || input.key === k);
|
|
return keyHit &&
|
|
(!combo.mods.meta || input.meta) &&
|
|
(!combo.mods.ctrl || input.control) &&
|
|
(!combo.mods.alt || input.alt) &&
|
|
(!combo.mods.shift || input.shift);
|
|
};
|
|
|
|
const installVoiceHoldRelay = (contents) => {
|
|
contents.on('before-input-event', (event, input) => {
|
|
if (input.type !== 'keyDown' || input.isAutoRepeat) return;
|
|
if (inputMatchesCombo(input)) {
|
|
if (!tapProven) sendFallbackToggle();
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
};
|
|
// Installed via web-contents-created: the main window is born later in the whenReady sequence, and
|
|
// webview guests swallow keys when a page has focus, so every window/guest gets the relay.
|
|
app.on('web-contents-created', (event, contents) => {
|
|
const t = contents.getType();
|
|
if (t === 'window' || t === 'webview') installVoiceHoldRelay(contents);
|
|
});
|
|
|
|
// Renderer pushes the user's saved combo on boot and whenever Settings changes it.
|
|
ipcMain.on('voice:set-hotkey', (_e, comboStr) => {
|
|
const next = parseCombo(comboStr);
|
|
if (next.accel === combo.accel) return;
|
|
combo = next;
|
|
if (UiohookKeyRef) tapKeycode = uiohookKeycodeFor(combo.key, UiohookKeyRef);
|
|
unregisterFallbackShortcut();
|
|
registerVoiceShortcut();
|
|
console.log('[voice] hotkey set to', combo.accel);
|
|
});
|
|
|
|
ipcMain.handle('voice:hold-capable', () => tapProven);
|
|
// Settings' "Hold to talk" fires the Accessibility prompt; Input Monitoring has no Electron API,
|
|
// but a running tap makes macOS list the app in that pane for the user to flip.
|
|
ipcMain.handle('voice:request-hold-permission', () => {
|
|
if (process.platform === 'darwin' && !tapProven) {
|
|
try { systemPreferences.isTrustedAccessibilityClient(true); } catch (_) {}
|
|
}
|
|
return tapProven;
|
|
});
|
|
// Fires the real TCC mic prompt BEFORE the first capture: with the entitlement present but no
|
|
// prior grant, getUserMedia would still fail once and burn the user's first dictation attempt.
|
|
ipcMain.handle('voice:request-mic-access', async () => {
|
|
if (process.platform !== 'darwin') return true;
|
|
try {
|
|
if (systemPreferences.getMediaAccessStatus('microphone') === 'granted') return true;
|
|
return await systemPreferences.askForMediaAccess('microphone');
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = { installVoiceHotkey };
|