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/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()