use clap::{Parser, Subcommand};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use synheart_sensor_agent::{
collector::{check_permission, Collector, CollectorConfig, SensorEvent},
config::{Config, SourceConfig},
transparency::create_shared_log_with_persistence,
PRIVACY_DECLARATION, VERSION,
};
#[derive(Parser)]
#[command(name = "synheart-sensor")]
#[command(author = "Synheart")]
#[command(version = VERSION)]
#[command(about = "Privacy-first behavioral sensor for research", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Start {
#[arg(long, default_value = "all")]
sources: String,
},
#[cfg(feature = "server")]
Serve {
#[arg(long, default_value = "8081")]
port: u16,
#[arg(long)]
token: Option<String>,
},
Pause,
Resume,
Status,
Privacy,
Config,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Start { sources } => {
cmd_start(&sources);
}
#[cfg(feature = "server")]
Commands::Serve { port, token } => {
cmd_serve(port, token);
}
Commands::Pause => {
cmd_pause();
}
Commands::Resume => {
cmd_resume();
}
Commands::Status => {
cmd_status();
}
Commands::Privacy => {
cmd_privacy();
}
Commands::Config => {
cmd_config();
}
}
}
fn load_config() -> Config {
match Config::load() {
Ok(config) => config,
Err(e) => {
eprintln!("Warning: could not read config ({e}); using defaults.");
Config::default()
}
}
}
fn cmd_start(sources: &str) {
println!("Synheart Sensor Agent v{VERSION}");
println!();
if !check_permission() {
eprintln!("Error: Insufficient permissions to capture input events.");
eprintln!();
#[cfg(target_os = "macos")]
{
eprintln!("To grant permission:");
eprintln!("1. Open System Preferences > Security & Privacy > Privacy");
eprintln!("2. Select 'Input Monitoring' in the left sidebar");
eprintln!("3. Add this application to the allowed list");
eprintln!("4. Restart the application");
}
#[cfg(target_os = "windows")]
{
eprintln!("Possible causes:");
eprintln!("1. The application may need to be run as Administrator");
eprintln!("2. Antivirus or security software may be blocking input hooks");
eprintln!("3. Group Policy may restrict low-level input access");
eprintln!();
eprintln!("Try running with elevated privileges (Run as Administrator).");
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
eprintln!("Input capture is not supported on this platform.");
}
std::process::exit(1);
}
let source_config = SourceConfig::from_csv(sources);
if !source_config.any_enabled() {
eprintln!("Error: At least one source must be enabled (keyboard or mouse)");
std::process::exit(1);
}
let config = load_config();
if let Err(e) = config.ensure_directories() {
eprintln!("Warning: Could not create directories: {e}");
}
println!("Starting collection...");
println!(
" Keyboard: {}",
if source_config.keyboard {
"enabled"
} else {
"disabled"
}
);
println!(
" Mouse: {}",
if source_config.mouse {
"enabled"
} else {
"disabled"
}
);
println!();
println!("Press Ctrl+C to stop");
println!();
let transparency_log =
create_shared_log_with_persistence(config.data_path.join("transparency.json"));
let collector_config = CollectorConfig {
capture_keyboard: source_config.keyboard,
capture_mouse: source_config.mouse,
};
let mut collector = Collector::new(collector_config);
let mut event_count: u64 = 0;
let mut last_status = std::time::Instant::now();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc_handler(r);
let config_path = Config::config_path();
let mut last_config_mtime = config_mtime(&config_path);
let mut paused = config.paused;
let mut last_config_check = std::time::Instant::now();
let mut last_log_save = std::time::Instant::now();
const LOG_SAVE_INTERVAL: Duration = Duration::from_secs(60);
let mut collector_died = false;
if paused {
println!("Collection is currently paused.");
println!("Run `synheart-sensor resume` to start collecting.");
println!();
} else if let Err(e) = collector.start() {
eprintln!("Error starting collector: {e}");
std::process::exit(1);
}
let receiver = collector.receiver().clone();
while running.load(Ordering::SeqCst) {
if last_config_check.elapsed() >= Duration::from_secs(1) {
let mtime = config_mtime(&config_path);
if mtime != last_config_mtime {
last_config_mtime = mtime;
if let Ok(cfg) = Config::load() {
if cfg.paused != paused {
paused = cfg.paused;
if paused {
println!();
println!("Pausing collection...");
collector.stop();
while receiver.try_recv().is_ok() {}
} else {
println!();
println!("Resuming collection...");
if let Err(e) = collector.start() {
eprintln!("Error resuming collector: {e}");
std::process::exit(1);
}
}
}
}
}
last_config_check = std::time::Instant::now();
}
if last_log_save.elapsed() >= LOG_SAVE_INTERVAL {
if let Err(e) = transparency_log.save() {
eprintln!("Warning: Could not save transparency log: {e}");
}
last_log_save = std::time::Instant::now();
}
if paused {
thread::sleep(Duration::from_millis(100));
continue;
}
if !collector.is_running() {
collector_died = true;
break;
}
match receiver.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
match &event {
SensorEvent::Keyboard(_) => transparency_log.record_keyboard_event(),
SensorEvent::Mouse(_) => transparency_log.record_mouse_event(),
SensorEvent::Shortcut(_) => transparency_log.record_shortcut_event(),
}
event_count += 1;
if last_status.elapsed() >= Duration::from_secs(10) {
let dropped = collector.dropped_count();
if dropped > 0 {
println!("[sensor] {event_count} events captured ({dropped} dropped)");
} else {
println!("[sensor] {event_count} events captured");
}
last_status = std::time::Instant::now();
}
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
eprintln!("Collector disconnected unexpectedly");
break;
}
}
}
while let Ok(event) = receiver.try_recv() {
match &event {
SensorEvent::Keyboard(_) => transparency_log.record_keyboard_event(),
SensorEvent::Mouse(_) => transparency_log.record_mouse_event(),
SensorEvent::Shortcut(_) => transparency_log.record_shortcut_event(),
}
}
println!();
println!("Stopping collection...");
collector.stop();
if let Err(e) = transparency_log.save() {
eprintln!("Warning: Could not save transparency log: {e}");
}
println!();
println!("{}", transparency_log.summary());
let dropped = collector.dropped_count();
if dropped > 0 {
println!("Events dropped (channel full): {dropped}");
}
if collector_died {
eprintln!();
eprintln!("Error: the capture thread stopped unexpectedly.");
eprintln!(
"Input capture permission may have been revoked; check `synheart-sensor status`."
);
std::process::exit(1);
}
}
fn config_mtime(path: &std::path::Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
#[cfg(feature = "server")]
fn cmd_serve(port: u16, token: Option<String>) {
use synheart_sensor_agent::server::ServerConfig;
let token = token.or_else(|| std::env::var("SYNHEART_SENSOR_TOKEN").ok());
println!("Synheart Sensor Agent v{VERSION}");
println!();
println!("Starting HTTP server for Chrome extension...");
println!(" Listen port: {port}");
println!(
" Authentication: {}",
if token.is_some() {
"bearer token required"
} else {
"disabled (loopback only)"
}
);
println!();
let config = load_config();
if let Err(e) = config.ensure_directories() {
eprintln!("Warning: Could not create directories: {e}");
}
let server_config = ServerConfig::new(port, config.data_path.clone()).with_token(token);
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
match synheart_sensor_agent::server::run(server_config).await {
Ok((addr, shutdown_tx)) => {
println!("Server listening on http://{addr}");
println!();
println!("POST data to: http://{addr}/collect");
println!();
println!("Press Ctrl+C to stop");
println!();
let _ = tokio::signal::ctrl_c().await;
println!();
println!("Shutting down server...");
let _ = shutdown_tx.send(());
}
Err(e) => {
eprintln!("Failed to start server: {e}");
std::process::exit(1);
}
}
});
}
fn cmd_pause() {
let mut config = load_config();
config.paused = true;
if let Err(e) = config.save() {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
println!("Collection paused. Use 'synheart-sensor resume' to continue.");
}
fn cmd_resume() {
let mut config = load_config();
config.paused = false;
if let Err(e) = config.save() {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
println!("Collection resumed.");
}
fn cmd_status() {
let config = load_config();
println!("Synheart Sensor Agent Status");
println!("============================");
println!();
let has_permission = check_permission();
println!(
"Input Capture Permission: {}",
if has_permission {
"Granted"
} else {
"Not Granted"
}
);
println!();
println!("Configuration:");
println!(
" Keyboard capture: {}",
if config.sources.keyboard {
"enabled"
} else {
"disabled"
}
);
println!(
" Mouse capture: {}",
if config.sources.mouse {
"enabled"
} else {
"disabled"
}
);
println!(" Paused: {}", config.paused);
println!();
let stats_path = config.data_path.join("transparency.json");
if stats_path.exists() {
if let Ok(content) = std::fs::read_to_string(&stats_path) {
if let Ok(stats) = serde_json::from_str::<serde_json::Value>(&content) {
println!("Cumulative Statistics:");
if let Some(kb) = stats.get("keyboard_events") {
println!(" Keyboard events: {kb}");
}
if let Some(mouse) = stats.get("mouse_events") {
println!(" Mouse events: {mouse}");
}
}
}
} else {
println!("No previous session data found.");
}
}
fn cmd_privacy() {
println!("{PRIVACY_DECLARATION}");
}
fn cmd_config() {
let config = load_config();
println!("Configuration");
println!("=============");
println!();
println!("Config file: {:?}", Config::config_path());
println!();
println!(
"{}",
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "Error".to_string())
);
}
fn ctrlc_handler(running: Arc<AtomicBool>) {
ctrlc::set_handler(move || {
running.store(false, Ordering::SeqCst);
})
.expect("Error setting Ctrl+C handler");
}