Skip to content

Commit 825ce57

Browse files
committed
Merge branch 'beta'
2 parents 91e416d + fe64048 commit 825ce57

4 files changed

Lines changed: 140 additions & 41 deletions

File tree

bin/clx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,25 @@
33
DIR="$(dirname "$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")")/.."
44
export DYLD_LIBRARY_PATH="${DIR}/rs/target/release:${DYLD_LIBRARY_PATH}"
55

6-
# If args are passed (agent, dino, --help, etc.), run directly — no watchdog.
7-
if [ $# -gt 0 ]; then
6+
# If args are passed (agent, dino, --help, -f, etc.), run directly — no watchdog.
7+
if [ $# -gt 0 ] && [ "$1" != "--watchdog" ]; then
88
exec "${DIR}/clx" "$@"
99
fi
1010

11+
# Fork watchdog to background unless already running as watchdog.
12+
if [ "$1" != "--watchdog" ]; then
13+
mkdir -p "${DIR}/tmp"
14+
# Rotate watchdog log if it exceeds 5 MB so it can't grow unbounded
15+
# (previously hit 56 MB / 583k lines from CGEventTap-disabled spam).
16+
LOG="${DIR}/tmp/clx-watchdog.log"
17+
if [ -f "$LOG" ] && [ "$(stat -f%z "$LOG" 2>/dev/null || stat -c%s "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]; then
18+
mv -f "$LOG" "${LOG}.1"
19+
fi
20+
nohup "$0" --watchdog </dev/null >>"$LOG" 2>&1 &
21+
echo "[CLX] started (pid $!)" >&2
22+
exit 0
23+
fi
24+
1125
# Daemon mode: watchdog with exponential backoff.
1226
# Starts at 10ms, doubles each crash, resets on clean run (>10s uptime).
1327
MAX_RESTARTS=20

rs/adapters/macos/src/brainstorm_overlay.rs

Lines changed: 98 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -259,48 +259,108 @@ unsafe fn hide_window() {
259259
msg1(w, sel(b"orderOut:\0"), std::ptr::null_mut());
260260
}
261261

262-
// ── Subprocess-based prompt input ────────────────────────────────────────────
263-
264-
/// Show a prompt input dialog via a subprocess (`clx-prompt`).
265-
/// The subprocess has no CGEventTap, so keyboard input works normally.
266-
pub fn show_prompt_panel(title: &str, message: &str, prefill: &str) -> Option<String> {
267-
use std::process::Command;
268-
269-
// Find the clx-prompt binary next to the main binary.
270-
let prompt_bin = {
271-
let exe = std::env::current_exe().unwrap_or_default();
272-
let dir = exe.parent().unwrap_or(std::path::Path::new("."));
273-
dir.join("clx-prompt")
274-
};
262+
// ── Daemon-based prompt input (zero cold-start) ──────────────────────────────
275263

276-
// Fall back to looking in the cargo target directory.
277-
let prompt_bin = if prompt_bin.exists() {
278-
prompt_bin
279-
} else {
280-
// Try relative to CWD or in PATH.
281-
std::path::PathBuf::from("clx-prompt")
282-
};
264+
use std::io::{BufRead, BufReader, Write};
265+
use std::process::{Child, ChildStdin, ChildStdout, Stdio};
266+
use std::sync::OnceLock;
267+
268+
struct PromptDaemon {
269+
_child: Child,
270+
stdin: ChildStdin,
271+
stdout: BufReader<ChildStdout>,
272+
}
273+
274+
impl Drop for PromptDaemon {
275+
fn drop(&mut self) {
276+
// Kill the child explicitly so it doesn't outlive us as an orphan.
277+
// Without this, repeated clx restarts (watchdog) leak clx-prompt
278+
// processes (each ~74 MB) until the user reboots.
279+
let _ = self._child.kill();
280+
let _ = self._child.wait();
281+
}
282+
}
283283

284-
eprintln!("[CLX] launching prompt subprocess: {:?}", prompt_bin);
285-
286-
let output = Command::new(&prompt_bin)
287-
.arg(title)
288-
.arg(message)
289-
.arg(prefill)
290-
.output();
291-
292-
match output {
293-
Ok(out) => {
294-
if out.status.success() {
295-
let text = String::from_utf8_lossy(&out.stdout).into_owned();
296-
if text.is_empty() { None } else { Some(text) }
297-
} else {
298-
eprintln!("[CLX] prompt cancelled (exit code {:?})", out.status.code());
299-
None
284+
static DAEMON: OnceLock<Mutex<PromptDaemon>> = OnceLock::new();
285+
286+
fn prompt_bin_path() -> std::path::PathBuf {
287+
let exe = std::env::current_exe().unwrap_or_default();
288+
let dir = exe.parent().unwrap_or(std::path::Path::new("."));
289+
// Check same dir as binary, then bin/ subdir (where build.sh deploys it).
290+
for candidate in [dir.join("clx-prompt"), dir.join("bin").join("clx-prompt")] {
291+
if candidate.exists() {
292+
// Verify it's the Tauri build, not an old AppKit binary.
293+
if let Ok(bytes) = std::fs::read(&candidate) {
294+
if bytes.windows(5).any(|w| w == b"tauri") {
295+
return candidate;
296+
}
297+
eprintln!("[CLX] skipping non-Tauri clx-prompt at {:?}", candidate);
300298
}
301299
}
302-
Err(e) => {
303-
eprintln!("[CLX] failed to launch clx-prompt: {}", e);
300+
}
301+
std::path::PathBuf::from("clx-prompt")
302+
}
303+
304+
/// Pre-spawn the clx-prompt daemon so the window is warm before first use.
305+
/// Call this at CLX startup. Safe to call multiple times.
306+
pub fn spawn_prompt_daemon() {
307+
DAEMON.get_or_init(|| {
308+
let bin = prompt_bin_path();
309+
eprintln!("[CLX] spawning clx-prompt daemon: {:?}", bin);
310+
let mut child = std::process::Command::new(&bin)
311+
.arg("--daemon")
312+
.stdin(Stdio::piped())
313+
.stdout(Stdio::piped())
314+
.stderr(Stdio::inherit())
315+
.spawn()
316+
.expect("failed to spawn clx-prompt --daemon");
317+
318+
let stdin = child.stdin.take().expect("no stdin");
319+
let stdout = BufReader::new(child.stdout.take().expect("no stdout"));
320+
321+
let mut daemon = PromptDaemon { _child: child, stdin, stdout };
322+
323+
// Wait for {"type":"ready"} line before returning.
324+
let mut line = String::new();
325+
let _ = daemon.stdout.read_line(&mut line);
326+
eprintln!("[CLX] clx-prompt daemon ready: {}", line.trim());
327+
328+
Mutex::new(daemon)
329+
});
330+
}
331+
332+
/// Show the prompt dialog. Blocks until user submits or cancels.
333+
/// Returns the prompt text (with optional "[KEEP]\n" prefix), or None if cancelled.
334+
pub fn show_prompt_panel(title: &str, context: &str, last_prompt: &str) -> Option<String> {
335+
// Ensure daemon is running (lazy fallback if spawn_prompt_daemon wasn't called at startup).
336+
spawn_prompt_daemon();
337+
338+
let daemon_lock = DAEMON.get()?;
339+
let mut d = daemon_lock.lock().ok()?;
340+
341+
// Send show command.
342+
let cmd = serde_json::json!({
343+
"cmd": "show",
344+
"title": title,
345+
"context": context,
346+
"last_prompt": last_prompt,
347+
});
348+
writeln!(d.stdin, "{cmd}").ok()?;
349+
d.stdin.flush().ok()?;
350+
351+
// Block until we get a result line.
352+
let mut line = String::new();
353+
d.stdout.read_line(&mut line).ok()?;
354+
let msg: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
355+
356+
match msg["type"].as_str()? {
357+
"submit" => {
358+
let text = msg["text"].as_str()?.to_string();
359+
let keep = msg["keep"].as_bool().unwrap_or(false);
360+
Some(if keep { format!("[KEEP]\n{text}") } else { text })
361+
}
362+
_ => {
363+
eprintln!("[CLX] prompt cancelled");
304364
None
305365
}
306366
}

rs/adapters/macos/src/hook.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,25 @@ unsafe extern "C" fn raw_callback(
9999
if etype_raw == TAP_DISABLED_BY_TIMEOUT || etype_raw == TAP_DISABLED_BY_USER {
100100
let tap = TAP_REF.load(Ordering::Relaxed);
101101
if !tap.is_null() {
102-
eprintln!("[CLX] CGEventTap was disabled (secure input?) - releasing all keys + re-enabling");
102+
// Rate-limit logging: only log first event in a burst, and at most
103+
// once per 5s. The previous version spammed 583k lines into the
104+
// watchdog log over a few days.
105+
use std::sync::atomic::AtomicU64;
106+
use std::time::{SystemTime, UNIX_EPOCH};
107+
static LAST_LOG_SEC: AtomicU64 = AtomicU64::new(0);
108+
static DISABLE_COUNT: AtomicU64 = AtomicU64::new(0);
109+
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
110+
let last = LAST_LOG_SEC.load(Ordering::Relaxed);
111+
let n = DISABLE_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
112+
if now.saturating_sub(last) >= 5 {
113+
LAST_LOG_SEC.store(now, Ordering::Relaxed);
114+
let cause = if etype_raw == TAP_DISABLED_BY_TIMEOUT {
115+
"timeout (handler too slow → key drops/lag)"
116+
} else {
117+
"user (secure input field)"
118+
};
119+
eprintln!("[CLX] CGEventTap disabled — {} (total disables: {}) — re-enabling", cause, n);
120+
}
103121
// Emergency stop: release all held keys and stop all modules.
104122
// Without this, AccModel keeps running (tabs/mouse) because we
105123
// never received the key-up events while the tap was disabled.

rs/adapters/macos/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ fn main() {
130130
"pgrep -f '/clx\\b|/capslockx\\b' | grep -v {} | xargs kill -9 2>/dev/null", my_pid
131131
)])
132132
.status();
133+
134+
// Reap any orphan clx-prompt daemons from previous (crashed) sessions.
135+
// The Tauri prompt helper is ~74 MB each — without this, repeated
136+
// crashes leak ten or more orphan processes.
137+
let _ = std::process::Command::new("sh")
138+
.args(["-c", "pkill -9 -f 'clx-prompt --daemon' 2>/dev/null"])
139+
.status();
133140
}
134141

135142
// Tee stderr to /tmp/clx-debug.log (truncated on each start) so the log

0 commit comments

Comments
 (0)