Skip to content

Commit 27f4d1a

Browse files
feat: add bare-modifier-monitor for macOS appshot hotkey detection
Fixes openai#24009 — Appshots hotkey registers but never fires on macOS 26.5. Adds `codex-rs/bare-modifier-monitor/` crate — a macOS-only binary that monitors for double-press of a bare modifier key (Shift or Command) via CoreGraphics event taps. Core fix (macOS 26.5 compatibility): - Uses kCGSessionEventTap instead of kCGHIDEventTap for broader macOS 15+ compatibility - Handles kCGEventTapDisabledByTimeout to auto re-enable the tap - Strict modifier isolation — only fires when target modifier is sole modifier pressed Code quality: - Safe atomics (OnceLock/AtomicBool/AtomicPtr) instead of static mut - Proper CFRelease for CoreFoundation objects - LAST_PRESS_MS cleared on modifier switch to prevent false double-press detection - BUILD.bazel for Bazel integration
1 parent b14f11d commit 27f4d1a

5 files changed

Lines changed: 310 additions & 0 deletions

File tree

codex-rs/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"agent-graph-store",
66
"agent-identity",
77
"backend-client",
8+
"bare-modifier-monitor",
89
"bwrap",
910
"ansi-escape",
1011
"async-utils",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
load("//:defs.bzl", "codex_rust_crate")
2+
3+
codex_rust_crate(
4+
name = "bare-modifier-monitor",
5+
crate_name = "codex_bare_modifier_monitor",
6+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "codex-bare-modifier-monitor"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
7+
[[bin]]
8+
name = "bare-modifier-monitor"
9+
path = "src/main.rs"
10+
11+
[lints]
12+
workspace = true
13+
14+
[target.'cfg(target_os = "macos")'.dependencies]
15+
core-foundation = "0.9"
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
//! Monitors for double-press of a bare modifier key (Shift or Command) on macOS.
2+
//!
3+
//! Prints `ready` to stdout once the event tap is installed, then prints `fired`
4+
//! each time the configured double-press is detected.
5+
//!
6+
//! Usage:
7+
//! bare-modifier-monitor --key <DoubleShift|DoubleCommand> [--immediate]
8+
9+
fn main() {
10+
#[cfg(target_os = "macos")]
11+
macos::run();
12+
13+
#[cfg(not(target_os = "macos"))]
14+
{
15+
eprintln!("bare-modifier-monitor is only supported on macOS");
16+
std::process::exit(1);
17+
}
18+
}
19+
20+
#[cfg(target_os = "macos")]
21+
mod macos {
22+
use core_foundation::base::TCFType;
23+
use core_foundation::runloop::CFRunLoop;
24+
use core_foundation::runloop::kCFRunLoopCommonModes;
25+
use std::ffi::c_void;
26+
use std::io::Write;
27+
use std::io::stdout;
28+
use std::sync::OnceLock;
29+
use std::sync::atomic::AtomicBool;
30+
use std::sync::atomic::AtomicPtr;
31+
use std::sync::atomic::AtomicU64;
32+
use std::sync::atomic::Ordering;
33+
use std::time::SystemTime;
34+
use std::time::UNIX_EPOCH;
35+
36+
// ---------------------------------------------------------------
37+
// Core Graphics FFI declarations
38+
// ---------------------------------------------------------------
39+
40+
type CGEventRef = *mut c_void;
41+
type CGEventTapProxy = *mut c_void;
42+
type CGEventType = u32;
43+
type CGEventMask = u64;
44+
type CGEventFlags = u64;
45+
type CFMachPortRef = *mut c_void;
46+
47+
// Event tap placement – use session-level tap for broader compatibility
48+
// with macOS 15+ (Sequoia) and later. The older `kCGHIDEventTap` (0)
49+
// can silently fail to deliver events on recent macOS versions.
50+
const K_CG_SESSION_EVENT_TAP: u32 = 1;
51+
52+
// Passive listener – we never modify events.
53+
const K_CG_HEAD_INSERT_EVENT_TAP: u32 = 0;
54+
const K_CG_EVENT_TAP_OPTION_LISTEN_ONLY: u32 = 1;
55+
56+
// Event types
57+
const K_CG_EVENT_FLAGS_CHANGED: CGEventType = 12;
58+
const K_CG_EVENT_TAP_DISABLED_BY_TIMEOUT: CGEventType = 0xFFFFFFFE;
59+
60+
// Modifier flag masks
61+
const K_CG_EVENT_FLAG_MASK_SHIFT: CGEventFlags = 0x00020000;
62+
const K_CG_EVENT_FLAG_MASK_COMMAND: CGEventFlags = 0x00100000;
63+
// Mask covering device-independent modifier bits only.
64+
const MODIFIER_FLAGS_MASK: CGEventFlags = 0x00FF0000;
65+
66+
const DOUBLE_TAP_MAX_INTERVAL_MS: u64 = 400;
67+
68+
// Global state for the callback. The callback is invoked on the same
69+
// thread that runs the CFRunLoop so no cross-thread synchronisation is
70+
// required beyond atomic stores/loads.
71+
static LAST_PRESS_MS: AtomicU64 = AtomicU64::new(0);
72+
static WAITING_FOR_RELEASE: AtomicBool = AtomicBool::new(false);
73+
74+
/// Which modifier key pair to watch (set once before run-loop starts).
75+
static TARGET_FLAG: OnceLock<CGEventFlags> = OnceLock::new();
76+
/// If `true`, fire on the first detected double-press and exit.
77+
static IMMEDIATE: AtomicBool = AtomicBool::new(false);
78+
/// The CFMachPortRef for the event tap, stored so the callback can
79+
/// re-enable it if macOS disables it by timeout.
80+
static TAP_PORT: AtomicPtr<c_void> = AtomicPtr::new(std::ptr::null_mut());
81+
82+
#[link(name = "CoreGraphics", kind = "framework")]
83+
unsafe extern "C" {
84+
fn CGEventTapCreate(
85+
tap: u32,
86+
place: u32,
87+
options: u32,
88+
events_of_interest: CGEventMask,
89+
callback: unsafe extern "C" fn(
90+
CGEventTapProxy,
91+
CGEventType,
92+
CGEventRef,
93+
*mut c_void,
94+
) -> CGEventRef,
95+
user_info: *mut c_void,
96+
) -> CFMachPortRef;
97+
98+
fn CGEventGetFlags(event: CGEventRef) -> CGEventFlags;
99+
fn CGEventTapEnable(tap: CFMachPortRef, enable: bool);
100+
}
101+
102+
// CFMachPort / CFRunLoop helpers from CoreFoundation (C API).
103+
#[link(name = "CoreFoundation", kind = "framework")]
104+
unsafe extern "C" {
105+
fn CFMachPortCreateRunLoopSource(
106+
allocator: *const c_void,
107+
port: CFMachPortRef,
108+
order: i64,
109+
) -> *mut c_void;
110+
fn CFRelease(cf: *const c_void);
111+
fn CFRunLoopAddSource(
112+
rl: core_foundation::runloop::CFRunLoopRef,
113+
source: *mut c_void,
114+
mode: core_foundation::string::CFStringRef,
115+
);
116+
}
117+
118+
/// The event-tap callback. Invoked for every `kCGEventFlagsChanged`
119+
/// event and for tap-disabled notifications.
120+
unsafe extern "C" fn tap_callback(
121+
_proxy: CGEventTapProxy,
122+
event_type: CGEventType,
123+
event: CGEventRef,
124+
_user_info: *mut c_void,
125+
) -> CGEventRef {
126+
// macOS may disable the tap after a timeout. Re-enable it.
127+
if event_type == K_CG_EVENT_TAP_DISABLED_BY_TIMEOUT {
128+
let port = TAP_PORT.load(Ordering::Relaxed);
129+
if !port.is_null() {
130+
unsafe {
131+
CGEventTapEnable(port, /*enable*/ true)
132+
};
133+
}
134+
return event;
135+
}
136+
137+
if event_type != K_CG_EVENT_FLAGS_CHANGED {
138+
return event;
139+
}
140+
141+
let flags = unsafe { CGEventGetFlags(event) } & MODIFIER_FLAGS_MASK;
142+
let target = *TARGET_FLAG.get().expect("TARGET_FLAG not set");
143+
144+
let target_pressed = (flags & target) == target;
145+
// Make sure *only* our target modifier is held (no other modifiers).
146+
let only_target = target_pressed && (flags & !target) == 0;
147+
148+
if only_target {
149+
if WAITING_FOR_RELEASE.load(Ordering::Relaxed) {
150+
// Still held from the first press – ignore.
151+
return event;
152+
}
153+
154+
let now = SystemTime::now()
155+
.duration_since(UNIX_EPOCH)
156+
.unwrap_or_default()
157+
.as_millis() as u64;
158+
let prev = LAST_PRESS_MS.load(Ordering::Relaxed);
159+
let delta = now.saturating_sub(prev);
160+
161+
if prev != 0 && delta <= DOUBLE_TAP_MAX_INTERVAL_MS {
162+
// Double-press detected.
163+
let _ = writeln!(stdout(), "fired");
164+
let _ = stdout().flush();
165+
LAST_PRESS_MS.store(0, Ordering::Relaxed);
166+
167+
if IMMEDIATE.load(Ordering::Relaxed) {
168+
std::process::exit(0);
169+
}
170+
} else {
171+
LAST_PRESS_MS.store(now, Ordering::Relaxed);
172+
}
173+
WAITING_FOR_RELEASE.store(true, Ordering::Relaxed);
174+
} else {
175+
// Modifier released (or a different modifier is now held).
176+
// Clear the first-press timestamp when a *different* modifier is
177+
// pressed so that target → other → target within 400ms is not
178+
// mis-detected as a double-press of target.
179+
if flags != 0 {
180+
LAST_PRESS_MS.store(0, Ordering::Relaxed);
181+
}
182+
WAITING_FOR_RELEASE.store(false, Ordering::Relaxed);
183+
}
184+
185+
event
186+
}
187+
188+
pub(super) fn run() {
189+
let args: Vec<String> = std::env::args().collect();
190+
191+
let mut key: Option<&str> = None;
192+
let mut immediate = false;
193+
let mut i = 1;
194+
while i < args.len() {
195+
match args[i].as_str() {
196+
"--key" => {
197+
i += 1;
198+
if i < args.len() {
199+
key = Some(&args[i]);
200+
}
201+
}
202+
"--immediate" => {
203+
immediate = true;
204+
}
205+
other => {
206+
eprintln!("unknown argument: {other}");
207+
std::process::exit(1);
208+
}
209+
}
210+
i += 1;
211+
}
212+
213+
let target_flag = match key {
214+
Some("DoubleShift") => K_CG_EVENT_FLAG_MASK_SHIFT,
215+
Some("DoubleCommand") => K_CG_EVENT_FLAG_MASK_COMMAND,
216+
Some(other) => {
217+
eprintln!("unsupported key: {other}");
218+
std::process::exit(1);
219+
}
220+
None => {
221+
eprintln!(
222+
"usage: bare-modifier-monitor --key <DoubleShift|DoubleCommand> [--immediate]"
223+
);
224+
std::process::exit(1);
225+
}
226+
};
227+
228+
TARGET_FLAG
229+
.set(target_flag)
230+
.expect("TARGET_FLAG already set");
231+
IMMEDIATE.store(immediate, Ordering::Relaxed);
232+
233+
let event_mask: CGEventMask = 1 << K_CG_EVENT_FLAGS_CHANGED;
234+
235+
let tap = unsafe {
236+
CGEventTapCreate(
237+
K_CG_SESSION_EVENT_TAP,
238+
K_CG_HEAD_INSERT_EVENT_TAP,
239+
K_CG_EVENT_TAP_OPTION_LISTEN_ONLY,
240+
event_mask,
241+
tap_callback,
242+
std::ptr::null_mut(),
243+
)
244+
};
245+
246+
if tap.is_null() {
247+
eprintln!(
248+
"failed to create event tap – check Accessibility / Input Monitoring permissions"
249+
);
250+
std::process::exit(1);
251+
}
252+
253+
TAP_PORT.store(tap, Ordering::Relaxed);
254+
255+
let source = unsafe {
256+
CFMachPortCreateRunLoopSource(std::ptr::null(), tap, /*order*/ 0)
257+
};
258+
if source.is_null() {
259+
// Clean up the tap before exiting.
260+
unsafe { CFRelease(tap) };
261+
eprintln!("failed to create run-loop source from event tap");
262+
std::process::exit(1);
263+
}
264+
265+
unsafe {
266+
let rl = CFRunLoop::get_current();
267+
CFRunLoopAddSource(rl.as_concrete_TypeRef(), source, kCFRunLoopCommonModes);
268+
// Release our ownership of the source (the run-loop retains it).
269+
CFRelease(source);
270+
}
271+
272+
// Signal readiness.
273+
println!("ready");
274+
let _ = stdout().flush();
275+
276+
// Run the event loop forever (or until `--immediate` triggers exit).
277+
// Note: `tap` is intentionally not released here – it must remain
278+
// alive for the duration of the process. On exit the OS reclaims it.
279+
CFRunLoop::run_current();
280+
}
281+
}

0 commit comments

Comments
 (0)