keyvoid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +42 -0
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/bin/keyvoid.js +121 -0
- package/package.json +77 -0
- package/src/app.js +294 -0
- package/src/engine/permissions.js +107 -0
- package/src/engine/suppressor/helpers/linux-helper.py +192 -0
- package/src/engine/suppressor/helpers/macos-helper.swift +108 -0
- package/src/engine/suppressor/helpers/macos-pynput-helper.py +179 -0
- package/src/engine/suppressor/helpers/windows-helper.ps1 +144 -0
- package/src/engine/suppressor/index.js +306 -0
- package/src/scripts/postinstall.js +33 -0
- package/src/ui/components/counter.js +64 -0
- package/src/ui/components/header.js +102 -0
- package/src/ui/components/status-bar.js +71 -0
- package/src/ui/components/unvoid-button.js +78 -0
- package/src/ui/mouse.js +97 -0
- package/src/ui/renderer.js +113 -0
- package/src/ui/skins/arcade.js +530 -0
- package/src/ui/skins/cat.js +223 -0
- package/src/ui/skins/clean.js +155 -0
- package/src/ui/skins/hacker.js +194 -0
- package/src/ui/skins/prank.js +195 -0
- package/src/ui/skins/toddler.js +131 -0
- package/src/ui/skins/zen.js +169 -0
- package/src/ui/unlock-sequence.js +105 -0
- package/src/utils/big-digits.js +130 -0
- package/src/utils/colors.js +114 -0
- package/src/utils/terminal.js +119 -0
package/src/app.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// ─── KeyVoid Application ─────────────────────────────────────────────
|
|
2
|
+
// Main orchestrator: ties together the engine, renderer, mouse,
|
|
3
|
+
// and skins into a cohesive full-screen keyboard locking application.
|
|
4
|
+
|
|
5
|
+
import { Renderer } from './ui/renderer.js';
|
|
6
|
+
import { MouseTracker } from './ui/mouse.js';
|
|
7
|
+
import { KeyboardSuppressor } from './engine/suppressor/index.js';
|
|
8
|
+
import { checkPermissions } from './engine/permissions.js';
|
|
9
|
+
import { renderClean } from './ui/skins/clean.js';
|
|
10
|
+
import { renderToddler } from './ui/skins/toddler.js';
|
|
11
|
+
import { renderCat } from './ui/skins/cat.js';
|
|
12
|
+
import { renderPrank, checkPrankExit, resetPrankState } from './ui/skins/prank.js';
|
|
13
|
+
import { renderHacker } from './ui/skins/hacker.js';
|
|
14
|
+
import { renderArcade } from './ui/skins/arcade.js';
|
|
15
|
+
import { renderZen } from './ui/skins/zen.js';
|
|
16
|
+
import { renderUnlockSequence } from './ui/unlock-sequence.js';
|
|
17
|
+
import { write } from './utils/terminal.js';
|
|
18
|
+
import { RESET } from './utils/colors.js';
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
|
|
21
|
+
export class KeyVoidApp {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.skin = options.skin || 'clean';
|
|
24
|
+
this.renderer = new Renderer();
|
|
25
|
+
this.mouse = new MouseTracker();
|
|
26
|
+
this.suppressor = new KeyboardSuppressor();
|
|
27
|
+
|
|
28
|
+
this.state = {
|
|
29
|
+
keyCount: 0,
|
|
30
|
+
startTime: Date.now(),
|
|
31
|
+
suppressorActive: false,
|
|
32
|
+
failsafeProgress: 0,
|
|
33
|
+
skinName: this.skin,
|
|
34
|
+
unlocking: false,
|
|
35
|
+
unlockTick: 0
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
this.tick = 0;
|
|
39
|
+
this.running = false;
|
|
40
|
+
this._boundCleanup = this._cleanup.bind(this);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async start() {
|
|
44
|
+
// ── Permission Check ──
|
|
45
|
+
const perms = checkPermissions();
|
|
46
|
+
if (!perms.ok) {
|
|
47
|
+
console.log(perms.guide);
|
|
48
|
+
console.log(chalk.red.bold('\n Cannot start KeyVoid without required permissions.\n'));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Show splash ──
|
|
53
|
+
this._showSplash();
|
|
54
|
+
|
|
55
|
+
// ── Setup terminal ──
|
|
56
|
+
this.renderer.setup();
|
|
57
|
+
this.running = true;
|
|
58
|
+
|
|
59
|
+
// ── Register cleanup handlers ──
|
|
60
|
+
process.on('SIGINT', this._boundCleanup);
|
|
61
|
+
process.on('SIGTERM', this._boundCleanup);
|
|
62
|
+
process.on('uncaughtException', (err) => {
|
|
63
|
+
this._cleanup();
|
|
64
|
+
console.error('Fatal error:', err);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
process.on('exit', () => {
|
|
68
|
+
// Last-resort cleanup
|
|
69
|
+
if (this.running) {
|
|
70
|
+
this.suppressor.stop();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── Setup mouse tracking ──
|
|
75
|
+
this.mouse.start();
|
|
76
|
+
this._setupMouseRegions();
|
|
77
|
+
|
|
78
|
+
// ── Start keyboard suppressor ──
|
|
79
|
+
this.suppressor.on('started', () => {
|
|
80
|
+
this.state.suppressorActive = true;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.suppressor.on('key', ({ keycode, isDown, count }) => {
|
|
84
|
+
this.state.keyCount = count;
|
|
85
|
+
|
|
86
|
+
// Track spacebar for the Arcade game over screen (Hold 3 sec to restart)
|
|
87
|
+
if (keycode === 49 || keycode === 32 || keycode === 57) {
|
|
88
|
+
this.state.isSpaceDown = isDown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isDown) {
|
|
92
|
+
this.state.lastKeyCode = keycode;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
this.suppressor.on('failsafe-progress', (progress) => {
|
|
97
|
+
this.state.failsafeProgress = progress;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.suppressor.on('failsafe-cancel', () => {
|
|
101
|
+
this.state.failsafeProgress = 0;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.suppressor.on('failsafe', () => {
|
|
105
|
+
this._cleanup();
|
|
106
|
+
console.log(chalk.yellow('\n ⚡ Failsafe triggered! Keyboard unlocked.\n'));
|
|
107
|
+
process.exit(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.suppressor.on('permission-error', (msg) => {
|
|
111
|
+
this._cleanup();
|
|
112
|
+
console.log(chalk.red(`\n ❌ Permission error: ${msg}`));
|
|
113
|
+
console.log(chalk.yellow(' Please grant Accessibility permission and try again.\n'));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.suppressor.on('error', (err) => {
|
|
118
|
+
// Non-fatal: continue with UI but mark suppressor as inactive
|
|
119
|
+
this.state.suppressorActive = false;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.suppressor.on('close', (code) => {
|
|
123
|
+
this.state.suppressorActive = false;
|
|
124
|
+
// If the suppressor closes unexpectedly, keep the UI running
|
|
125
|
+
// (allows for demo mode where suppressor isn't available)
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Start the suppressor
|
|
129
|
+
const suppressorStarted = await this.suppressor.start();
|
|
130
|
+
if (!suppressorStarted) {
|
|
131
|
+
// Run in demo mode (UI only, no actual key blocking)
|
|
132
|
+
this.state.suppressorActive = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Setup render function ──
|
|
136
|
+
this.renderer.setRenderFunction((cols, rows) => {
|
|
137
|
+
return this._render(cols, rows);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Handle stdin for failsafe when suppressor doesn't report keys ──
|
|
141
|
+
process.stdin.on('data', (data) => {
|
|
142
|
+
const str = data.toString();
|
|
143
|
+
|
|
144
|
+
// Handle Ctrl+C as a safety measure
|
|
145
|
+
if (str === '\x03') {
|
|
146
|
+
this._cleanup();
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// If suppressor isn't active, count stdin keystrokes
|
|
151
|
+
if (!this.state.suppressorActive) {
|
|
152
|
+
this.state.keyCount += 1;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── Start the render loop ──
|
|
157
|
+
this.renderer.startLoop();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_render(cols, rows) {
|
|
161
|
+
this.tick++;
|
|
162
|
+
|
|
163
|
+
if (this.state.unlocking) {
|
|
164
|
+
return renderUnlockSequence(cols, rows, this.tick, this.state.unlockTick);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let result;
|
|
168
|
+
switch (this.skin) {
|
|
169
|
+
case 'cat':
|
|
170
|
+
result = renderCat(cols, rows, this.state, this.tick);
|
|
171
|
+
break;
|
|
172
|
+
case 'toddler':
|
|
173
|
+
result = renderToddler(cols, rows, this.state, this.tick);
|
|
174
|
+
break;
|
|
175
|
+
case 'prank':
|
|
176
|
+
result = renderPrank(cols, rows, this.state, this.tick);
|
|
177
|
+
break;
|
|
178
|
+
case 'hacker':
|
|
179
|
+
result = renderHacker(cols, rows, this.state, this.tick);
|
|
180
|
+
break;
|
|
181
|
+
case 'arcade':
|
|
182
|
+
result = renderArcade(cols, rows, this.state, this.tick);
|
|
183
|
+
break;
|
|
184
|
+
case 'zen':
|
|
185
|
+
result = renderZen(cols, rows, this.state, this.tick);
|
|
186
|
+
break;
|
|
187
|
+
case 'clean':
|
|
188
|
+
default:
|
|
189
|
+
result = renderClean(cols, rows, this.state, this.tick);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Update mouse regions based on render output
|
|
194
|
+
if (result.buttonRegion) {
|
|
195
|
+
this.mouse.addRegion(
|
|
196
|
+
'unvoid',
|
|
197
|
+
result.buttonRegion.x,
|
|
198
|
+
result.buttonRegion.y,
|
|
199
|
+
result.buttonRegion.width,
|
|
200
|
+
result.buttonRegion.height,
|
|
201
|
+
() => this._onUnvoid()
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Prank mode: secret exit zone
|
|
206
|
+
if (result.secretRegion) {
|
|
207
|
+
this.mouse.addRegion(
|
|
208
|
+
'prank-exit',
|
|
209
|
+
result.secretRegion.x,
|
|
210
|
+
result.secretRegion.y,
|
|
211
|
+
result.secretRegion.width,
|
|
212
|
+
result.secretRegion.height,
|
|
213
|
+
(event) => {
|
|
214
|
+
if (checkPrankExit(event.x, event.y, cols)) {
|
|
215
|
+
this._onUnvoid();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result.lines;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_setupMouseRegions() {
|
|
225
|
+
// Listen for any click event (for prank mode detection)
|
|
226
|
+
this.mouse.on('click', (event) => {
|
|
227
|
+
// Prank mode handles its own exit logic through the region callback
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
_onUnvoid() {
|
|
232
|
+
if (this.state.unlocking) return; // Prevent double trigger
|
|
233
|
+
|
|
234
|
+
// Stop suppressor and listener immediately but keep rendering UI loop
|
|
235
|
+
this.suppressor.stop();
|
|
236
|
+
this.mouse.stop();
|
|
237
|
+
this.state.unlocking = true;
|
|
238
|
+
this.state.unlockTick = this.tick;
|
|
239
|
+
|
|
240
|
+
// Wait exactly 60 frames (~1.0s) for vault door animation then display summary
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
this._cleanup();
|
|
243
|
+
|
|
244
|
+
const durationMs = Date.now() - this.state.startTime;
|
|
245
|
+
const mins = Math.floor(durationMs / 60000);
|
|
246
|
+
const secs = Math.floor((durationMs % 60000) / 1000);
|
|
247
|
+
const timeStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
248
|
+
|
|
249
|
+
console.log('');
|
|
250
|
+
console.log(chalk.greenBright.bold(' ✅ Keyboard UNVOIDED! You\'re free.'));
|
|
251
|
+
console.log(chalk.dim(' ┌─────────────────────────────────────┐'));
|
|
252
|
+
console.log(chalk.dim(' │ ') + chalk.white('SESSION SUMMARY') + chalk.dim(' │'));
|
|
253
|
+
console.log(chalk.dim(' ├─────────────────────────────────────┤'));
|
|
254
|
+
console.log(chalk.dim(' │ ') + chalk.cyan('Time Locked :') + chalk.whiteBright(` ${timeStr}`.padEnd(16)) + chalk.dim(' │'));
|
|
255
|
+
console.log(chalk.dim(' │ ') + chalk.magenta('Mode Selected :') + chalk.whiteBright(` ${this.skin}`.padEnd(16)) + chalk.dim(' │'));
|
|
256
|
+
console.log(chalk.dim(' │ ') + chalk.yellow('Total Keys Block:') + chalk.whiteBright(` ${this.state.keyCount.toLocaleString()}`.padEnd(16)) + chalk.dim(' │'));
|
|
257
|
+
console.log(chalk.dim(' └─────────────────────────────────────┘\n'));
|
|
258
|
+
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}, 1000);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_cleanup() {
|
|
264
|
+
if (!this.running) return;
|
|
265
|
+
this.running = false;
|
|
266
|
+
|
|
267
|
+
// Stop everything in order
|
|
268
|
+
this.mouse.stop();
|
|
269
|
+
this.suppressor.stop();
|
|
270
|
+
this.renderer.teardown();
|
|
271
|
+
|
|
272
|
+
if (this.skin === 'prank') {
|
|
273
|
+
resetPrankState();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Remove handlers
|
|
277
|
+
process.removeListener('SIGINT', this._boundCleanup);
|
|
278
|
+
process.removeListener('SIGTERM', this._boundCleanup);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_showSplash() {
|
|
282
|
+
// Brief splash before entering full-screen
|
|
283
|
+
console.log('');
|
|
284
|
+
console.log(chalk.cyan.bold(' ◆ KeyVoid'));
|
|
285
|
+
console.log(chalk.dim(` Starting in ${this.skin} mode...`));
|
|
286
|
+
|
|
287
|
+
if (this.skin === 'prank') {
|
|
288
|
+
console.log(chalk.dim(' Secret exit: triple-click top-right corner'));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.log(chalk.dim(' Failsafe: hold ESC + SPACE for 5 seconds'));
|
|
292
|
+
console.log('');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ─── OS Permissions Checker ──────────────────────────────────────────
|
|
2
|
+
// Detects the current OS and checks whether the necessary permissions
|
|
3
|
+
// are granted for global keyboard interception.
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
export function checkPermissions() {
|
|
9
|
+
const platform = process.platform;
|
|
10
|
+
const result = { ok: false, platform, message: '', guide: '' };
|
|
11
|
+
|
|
12
|
+
switch (platform) {
|
|
13
|
+
case 'darwin':
|
|
14
|
+
result.ok = checkMacOS();
|
|
15
|
+
if (!result.ok) {
|
|
16
|
+
result.message = 'Accessibility permission required';
|
|
17
|
+
result.guide = [
|
|
18
|
+
'',
|
|
19
|
+
chalk.yellow.bold(' ⚠ macOS Accessibility Permission Required'),
|
|
20
|
+
'',
|
|
21
|
+
chalk.white(' KeyVoid needs permission to intercept keyboard events.'),
|
|
22
|
+
chalk.white(' Please follow these steps:'),
|
|
23
|
+
'',
|
|
24
|
+
chalk.cyan(' 1. ') + chalk.white('Open ') + chalk.bold('System Settings'),
|
|
25
|
+
chalk.cyan(' 2. ') + chalk.white('Go to ') + chalk.bold('Privacy & Security → Accessibility'),
|
|
26
|
+
chalk.cyan(' 3. ') + chalk.white('Click ') + chalk.bold('+') + chalk.white(' and add your ') + chalk.bold('Terminal app'),
|
|
27
|
+
chalk.cyan(' 4. ') + chalk.white('Toggle the switch ') + chalk.bold('ON'),
|
|
28
|
+
chalk.cyan(' 5. ') + chalk.white('Re-run ') + chalk.bold('keyvoid'),
|
|
29
|
+
'',
|
|
30
|
+
chalk.dim(' Tip: Add the terminal you\'re using (Terminal.app, iTerm2, etc.)'),
|
|
31
|
+
'',
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
|
|
36
|
+
case 'win32':
|
|
37
|
+
// Windows generally allows user-level hooks without special permissions
|
|
38
|
+
result.ok = true;
|
|
39
|
+
result.message = 'Windows hooks available';
|
|
40
|
+
break;
|
|
41
|
+
|
|
42
|
+
case 'linux':
|
|
43
|
+
result.ok = checkLinux();
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
result.message = 'Missing dependencies or permissions';
|
|
46
|
+
result.guide = [
|
|
47
|
+
'',
|
|
48
|
+
chalk.yellow.bold(' ⚠ Linux Dependencies Required'),
|
|
49
|
+
'',
|
|
50
|
+
chalk.white(' KeyVoid needs xinput to disable keyboard devices.'),
|
|
51
|
+
'',
|
|
52
|
+
chalk.cyan(' Ubuntu/Debian: ') + chalk.white('sudo apt install xinput'),
|
|
53
|
+
chalk.cyan(' Fedora: ') + chalk.white('sudo dnf install xinput'),
|
|
54
|
+
chalk.cyan(' Arch: ') + chalk.white('sudo pacman -S xorg-xinput'),
|
|
55
|
+
'',
|
|
56
|
+
chalk.dim(' Note: Wayland is not fully supported. X11 is required.'),
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
result.message = `Unsupported platform: ${platform}`;
|
|
64
|
+
result.guide = chalk.red(` KeyVoid does not support ${platform} yet.`);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkMacOS() {
|
|
72
|
+
// Attempt a quick CGEventTap test — if it fails, Accessibility is not granted.
|
|
73
|
+
// We'll rely on the actual helper to report failure at runtime.
|
|
74
|
+
// For now, optimistically return true and handle failure in the suppressor.
|
|
75
|
+
try {
|
|
76
|
+
// Check if the terminal process has accessibility via tccutil (unreliable)
|
|
77
|
+
// Instead, we'll just check if we can find swiftc/swift
|
|
78
|
+
execSync('which swift', { stdio: 'pipe' });
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
// Even without swift, cc (clang) should be available
|
|
82
|
+
try {
|
|
83
|
+
execSync('which cc', { stdio: 'pipe' });
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkLinux() {
|
|
92
|
+
try {
|
|
93
|
+
execSync('which xinput', { stdio: 'pipe' });
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getPlatformName() {
|
|
101
|
+
switch (process.platform) {
|
|
102
|
+
case 'darwin': return 'macOS';
|
|
103
|
+
case 'win32': return 'Windows';
|
|
104
|
+
case 'linux': return 'Linux';
|
|
105
|
+
default: return process.platform;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Linux Keyboard Suppressor for KeyVoid
|
|
4
|
+
Uses xinput to disable keyboard input devices.
|
|
5
|
+
Requires: xinput (xorg-xinput package)
|
|
6
|
+
|
|
7
|
+
Output protocol (stdout):
|
|
8
|
+
STARTED — helper is active and blocking keys
|
|
9
|
+
K:<keycode>:<1|0> — key event (via /dev/input if available)
|
|
10
|
+
FAILSAFE — not implemented in this helper (handled by Node.js)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import signal
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import select
|
|
19
|
+
|
|
20
|
+
disabled_ids = []
|
|
21
|
+
input_fds = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_keyboard_ids():
|
|
25
|
+
"""Find all physical keyboard device IDs via xinput."""
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
['xinput', 'list'],
|
|
29
|
+
capture_output=True, text=True, timeout=5
|
|
30
|
+
)
|
|
31
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
ids = []
|
|
35
|
+
for line in result.stdout.split('\n'):
|
|
36
|
+
line_lower = line.lower()
|
|
37
|
+
# Match keyboard devices, skip virtual/core devices
|
|
38
|
+
if 'keyboard' in line_lower and \
|
|
39
|
+
'virtual' not in line_lower and \
|
|
40
|
+
'xtest' not in line_lower and \
|
|
41
|
+
'power' not in line_lower and \
|
|
42
|
+
'video' not in line_lower and \
|
|
43
|
+
'consumer' not in line_lower:
|
|
44
|
+
# Extract id=NUMBER
|
|
45
|
+
if 'id=' in line:
|
|
46
|
+
try:
|
|
47
|
+
id_str = line.split('id=')[1].split()[0].strip()
|
|
48
|
+
ids.append(id_str)
|
|
49
|
+
except (IndexError, ValueError):
|
|
50
|
+
pass
|
|
51
|
+
return ids
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def disable_keyboards(ids):
|
|
55
|
+
"""Disable keyboard devices via xinput."""
|
|
56
|
+
global disabled_ids
|
|
57
|
+
for dev_id in ids:
|
|
58
|
+
try:
|
|
59
|
+
subprocess.run(
|
|
60
|
+
['xinput', 'disable', dev_id],
|
|
61
|
+
capture_output=True, timeout=5
|
|
62
|
+
)
|
|
63
|
+
disabled_ids.append(dev_id)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def enable_keyboards():
|
|
69
|
+
"""Re-enable all previously disabled keyboard devices."""
|
|
70
|
+
global disabled_ids
|
|
71
|
+
for dev_id in disabled_ids:
|
|
72
|
+
try:
|
|
73
|
+
subprocess.run(
|
|
74
|
+
['xinput', 'enable', dev_id],
|
|
75
|
+
capture_output=True, timeout=5
|
|
76
|
+
)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
disabled_ids = []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def try_grab_evdev():
|
|
83
|
+
"""
|
|
84
|
+
Attempt to exclusively grab keyboard /dev/input devices.
|
|
85
|
+
This is more reliable than xinput disable but requires root.
|
|
86
|
+
Returns list of file descriptors.
|
|
87
|
+
"""
|
|
88
|
+
import struct
|
|
89
|
+
import fcntl
|
|
90
|
+
|
|
91
|
+
EVIOCGRAB = 0x40044590
|
|
92
|
+
fds = []
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
for entry in os.listdir('/dev/input/'):
|
|
96
|
+
if not entry.startswith('event'):
|
|
97
|
+
continue
|
|
98
|
+
path = f'/dev/input/{entry}'
|
|
99
|
+
try:
|
|
100
|
+
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
101
|
+
# Check if it's a keyboard (EV_KEY capability)
|
|
102
|
+
# Simplified: try to grab it
|
|
103
|
+
fcntl.ioctl(fd, EVIOCGRAB, 1)
|
|
104
|
+
fds.append(fd)
|
|
105
|
+
except (OSError, IOError):
|
|
106
|
+
try:
|
|
107
|
+
os.close(fd)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return fds
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def release_evdev(fds):
|
|
117
|
+
"""Release grabbed evdev devices."""
|
|
118
|
+
import fcntl
|
|
119
|
+
EVIOCGRAB = 0x40044590
|
|
120
|
+
for fd in fds:
|
|
121
|
+
try:
|
|
122
|
+
fcntl.ioctl(fd, EVIOCGRAB, 0)
|
|
123
|
+
os.close(fd)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cleanup(sig=None, frame=None):
|
|
129
|
+
"""Clean up: re-enable keyboards and release evdev grabs."""
|
|
130
|
+
enable_keyboards()
|
|
131
|
+
release_evdev(input_fds)
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Register signal handlers for clean shutdown
|
|
136
|
+
signal.signal(signal.SIGTERM, cleanup)
|
|
137
|
+
signal.signal(signal.SIGINT, cleanup)
|
|
138
|
+
|
|
139
|
+
# Register atexit handler as extra safety
|
|
140
|
+
import atexit
|
|
141
|
+
atexit.register(lambda: enable_keyboards())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
global input_fds
|
|
146
|
+
|
|
147
|
+
# Try evdev grab first (requires root, more reliable)
|
|
148
|
+
if os.geteuid() == 0:
|
|
149
|
+
input_fds = try_grab_evdev()
|
|
150
|
+
if input_fds:
|
|
151
|
+
print("STARTED", flush=True)
|
|
152
|
+
# Monitor grabbed devices for key events
|
|
153
|
+
try:
|
|
154
|
+
while True:
|
|
155
|
+
time.sleep(0.1)
|
|
156
|
+
# Read events from grabbed devices
|
|
157
|
+
for fd in input_fds:
|
|
158
|
+
try:
|
|
159
|
+
data = os.read(fd, 24)
|
|
160
|
+
if len(data) >= 24:
|
|
161
|
+
import struct
|
|
162
|
+
_, _, ev_type, ev_code, ev_value = struct.unpack(
|
|
163
|
+
'llHHI', data
|
|
164
|
+
)
|
|
165
|
+
if ev_type == 1: # EV_KEY
|
|
166
|
+
is_down = 1 if ev_value == 1 else 0
|
|
167
|
+
print(f"K:{ev_code}:{is_down}", flush=True)
|
|
168
|
+
except (BlockingIOError, OSError):
|
|
169
|
+
pass
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
cleanup()
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Fallback: xinput disable
|
|
175
|
+
keyboard_ids = get_keyboard_ids()
|
|
176
|
+
if not keyboard_ids:
|
|
177
|
+
print("ERROR: No keyboard devices found", file=sys.stderr, flush=True)
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
|
|
180
|
+
disable_keyboards(keyboard_ids)
|
|
181
|
+
print("STARTED", flush=True)
|
|
182
|
+
|
|
183
|
+
# Keep running until signal
|
|
184
|
+
try:
|
|
185
|
+
while True:
|
|
186
|
+
time.sleep(0.5)
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
cleanup()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == '__main__':
|
|
192
|
+
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ─── macOS Keyboard Suppressor ───────────────────────────────────────
|
|
2
|
+
// Uses CGEventTap to intercept and suppress all keyboard events.
|
|
3
|
+
// Requires Accessibility permission in System Settings.
|
|
4
|
+
//
|
|
5
|
+
// Compile: swiftc -O macos-helper.swift -o keyvoid-macos-helper
|
|
6
|
+
// Run: ./keyvoid-macos-helper
|
|
7
|
+
//
|
|
8
|
+
// Output protocol (stdout):
|
|
9
|
+
// STARTED — helper is active and blocking keys
|
|
10
|
+
// K:<keycode>:<1|0> — key event (1=down, 0=up)
|
|
11
|
+
// FAILSAFE — Esc+Space held for 5 seconds
|
|
12
|
+
// ERROR:<message> — something went wrong
|
|
13
|
+
|
|
14
|
+
import Cocoa
|
|
15
|
+
import Foundation
|
|
16
|
+
|
|
17
|
+
// ── State ──
|
|
18
|
+
var escPressed = false
|
|
19
|
+
var spacePressed = false
|
|
20
|
+
var failsafeStart: Date? = nil
|
|
21
|
+
let failsafeSeconds: TimeInterval = 5.0
|
|
22
|
+
|
|
23
|
+
// ── Signal Handling ──
|
|
24
|
+
signal(SIGTERM) { _ in exit(0) }
|
|
25
|
+
signal(SIGINT) { _ in exit(0) }
|
|
26
|
+
|
|
27
|
+
// ── Event Callback ──
|
|
28
|
+
func eventCallback(
|
|
29
|
+
proxy: CGEventTapProxy,
|
|
30
|
+
type: CGEventType,
|
|
31
|
+
event: CGEvent,
|
|
32
|
+
refcon: UnsafeMutableRawPointer?
|
|
33
|
+
) -> Unmanaged<CGEvent>? {
|
|
34
|
+
|
|
35
|
+
// If the tap is disabled by the system, re-enable it
|
|
36
|
+
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
37
|
+
if let tap = refcon {
|
|
38
|
+
let machPort = Unmanaged<CFMachPort>.fromOpaque(tap).takeUnretainedValue()
|
|
39
|
+
CGEvent.tapEnable(tap: machPort, enable: true)
|
|
40
|
+
}
|
|
41
|
+
return Unmanaged.passRetained(event)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let keycode = event.getIntegerValueField(.keyboardEventKeycode)
|
|
45
|
+
let isDown = (type == .keyDown || type == .flagsChanged)
|
|
46
|
+
|
|
47
|
+
// Output key event to Node.js
|
|
48
|
+
print("K:\(keycode):\(isDown ? 1 : 0)")
|
|
49
|
+
fflush(stdout)
|
|
50
|
+
|
|
51
|
+
// ── Failsafe: Esc (53) + Space (49) held for 5 seconds ──
|
|
52
|
+
if type == .keyDown {
|
|
53
|
+
if keycode == 53 { escPressed = true }
|
|
54
|
+
if keycode == 49 { spacePressed = true }
|
|
55
|
+
} else if type == .keyUp {
|
|
56
|
+
if keycode == 53 { escPressed = false }
|
|
57
|
+
if keycode == 49 { spacePressed = false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if escPressed && spacePressed {
|
|
61
|
+
if failsafeStart == nil {
|
|
62
|
+
failsafeStart = Date()
|
|
63
|
+
} else if Date().timeIntervalSince(failsafeStart!) >= failsafeSeconds {
|
|
64
|
+
print("FAILSAFE")
|
|
65
|
+
fflush(stdout)
|
|
66
|
+
exit(0)
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
failsafeStart = nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Return nil to suppress (block) the keyboard event
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Main ──
|
|
77
|
+
// NX_SYSDEFINED (type 14) captures media/function keys (volume, brightness, etc.)
|
|
78
|
+
let eventMask: CGEventMask = (
|
|
79
|
+
(1 << CGEventType.keyDown.rawValue) |
|
|
80
|
+
(1 << CGEventType.keyUp.rawValue) |
|
|
81
|
+
(1 << CGEventType.flagsChanged.rawValue) |
|
|
82
|
+
(1 << 14) // NX_SYSDEFINED — media keys, function keys
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
guard let eventTap = CGEvent.tapCreate(
|
|
86
|
+
tap: .cgSessionEventTap,
|
|
87
|
+
place: .headInsertEventTap,
|
|
88
|
+
options: .defaultTap,
|
|
89
|
+
eventsOfInterest: eventMask,
|
|
90
|
+
callback: eventCallback,
|
|
91
|
+
userInfo: nil
|
|
92
|
+
) else {
|
|
93
|
+
fputs("ERROR: Failed to create event tap. Grant Accessibility permission in System Settings → Privacy & Security → Accessibility\n", stderr)
|
|
94
|
+
exit(1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Store the tap reference for re-enabling on timeout
|
|
98
|
+
let tapRef = Unmanaged.passUnretained(eventTap).toOpaque()
|
|
99
|
+
|
|
100
|
+
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
|
|
101
|
+
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
|
102
|
+
CGEvent.tapEnable(tap: eventTap, enable: true)
|
|
103
|
+
|
|
104
|
+
print("STARTED")
|
|
105
|
+
fflush(stdout)
|
|
106
|
+
|
|
107
|
+
// Run the event loop forever (until killed or failsafe triggers)
|
|
108
|
+
CFRunLoopRun()
|