Files
chrono-snap/src-tauri/src/engine.rs
Julian Freeman b32d5ddbd3 support export
2026-03-22 19:05:57 -04:00

342 lines
12 KiB
Rust

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::time::{interval, Duration};
use tauri::{AppHandle, Manager, Emitter};
use xcap::Monitor;
use chrono::Local;
use std::fs;
use std::path::PathBuf;
use tauri_plugin_store::StoreExt;
pub struct AppState {
pub is_paused: Arc<AtomicBool>,
pub capture_interval_secs: std::sync::atomic::AtomicU64,
pub db_path: std::sync::Mutex<Option<String>>,
pub toggle_menu_item: std::sync::Mutex<Option<tauri::menu::MenuItem<tauri::Wry>>>,
}
#[tauri::command]
pub fn update_db_path(state: tauri::State<'_, AppState>, path: String) -> Result<(), String> {
crate::db::init_db(&path).map_err(|e| e.to_string())?;
let mut db_path = state.db_path.lock().unwrap();
*db_path = Some(path);
Ok(())
}
#[tauri::command]
pub fn get_tags(state: tauri::State<'_, AppState>) -> Result<Vec<crate::db::Tag>, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_tags(path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_tag(state: tauri::State<'_, AppState>, name: String, parent_id: Option<i64>, color: String) -> Result<i64, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::add_tag(path, &name, parent_id, &color).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_tag(state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::delete_tag(path, id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_events(state: tauri::State<'_, AppState>, date: String) -> Result<Vec<crate::db::Event>, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_events(path, &date).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_events_range(state: tauri::State<'_, AppState>, start_date: String, end_date: String) -> Result<Vec<crate::db::Event>, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::get_events_range(path, &start_date, &end_date).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_event(state: tauri::State<'_, AppState>, event: crate::db::Event) -> Result<i64, String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::save_event(path, event).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_event(state: tauri::State<'_, AppState>, id: i64) -> Result<(), String> {
let path = state.db_path.lock().unwrap();
let path = path.as_ref().ok_or("Database path not set")?;
crate::db::delete_event(path, id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_interval(state: tauri::State<'_, AppState>, seconds: u64) {
state.capture_interval_secs.store(seconds, Ordering::SeqCst);
}
#[tauri::command]
pub fn toggle_pause(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> bool {
let current = state.is_paused.load(Ordering::SeqCst);
let new_state = !current;
state.is_paused.store(new_state, Ordering::SeqCst);
// Sync with Tray
if let Some(item) = state.toggle_menu_item.lock().unwrap().as_ref() {
let text = if new_state { "恢复记录" } else { "暂停记录" };
let _ = item.set_text(text);
}
// Notify Frontend
let _ = app.emit("pause-state-changed", new_state);
new_state
}
#[tauri::command]
pub fn get_pause_state(state: tauri::State<'_, AppState>) -> bool {
state.is_paused.load(Ordering::SeqCst)
}
use base64::{engine::general_purpose, Engine as _};
#[tauri::command]
pub fn get_image_base64(path: String) -> Result<String, String> {
let bytes = fs::read(path).map_err(|e| e.to_string())?;
Ok(general_purpose::STANDARD.encode(bytes))
}
#[tauri::command]
pub fn get_timeline(date: String, base_dir: String) -> Vec<serde_json::Value> {
let mut results = Vec::new();
let base_path = PathBuf::from(base_dir);
// Parse current date
let current_date = match chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return results,
};
let next_date = current_date + chrono::Duration::days(1);
let next_date_str = next_date.format("%Y-%m-%d").to_string();
// Helper to process a directory with a time filter
let mut process_dir = |dir_date: &str, is_next_day: bool| {
let dir_path = base_path.join(dir_date);
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jpg") {
if let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) {
// file_name format: "14-30-00"
let parts: Vec<&str> = file_name.split('-').collect();
if parts.len() >= 1 {
if let Ok(hour) = parts[0].parse::<u32>() {
// Logic: If current day, must be >= 3. If next day, must be < 3.
let keep = if !is_next_day { hour >= 3 } else { hour < 3 };
if keep {
let time_str = file_name.replace("-", ":").replace("_", " ");
results.push(serde_json::json!({
"time": time_str,
"path": path.to_string_lossy().to_string(),
"isNextDay": is_next_day
}));
}
}
}
}
}
}
}
};
process_dir(&date, false);
process_dir(&next_date_str, true);
// Sort results by isNextDay (false first) then by time
results.sort_by(|a, b| {
let a_next = a["isNextDay"].as_bool().unwrap_or(false);
let b_next = b["isNextDay"].as_bool().unwrap_or(false);
if a_next != b_next {
a_next.cmp(&b_next)
} else {
a["time"].as_str().unwrap_or("").cmp(b["time"].as_str().unwrap_or(""))
}
});
results
}
pub fn start_engine(app: AppHandle) {
// Start Cleanup routine
let app_cleanup = app.clone();
tauri::async_runtime::spawn(async move {
// Run once on startup, then every 24 hours
let mut ticker = interval(Duration::from_secs(60 * 60 * 24));
loop {
ticker.tick().await;
if let Err(e) = run_cleanup(&app_cleanup).await {
eprintln!("Cleanup error: {}", e);
}
}
});
let app_capture = app.clone();
tauri::async_runtime::spawn(async move {
let state = app_capture.state::<AppState>();
let is_paused = state.is_paused.clone();
// Check frequently to catch the aligned second
let mut ticker = interval(Duration::from_millis(500));
let mut last_capture_time = 0;
loop {
ticker.tick().await;
if is_paused.load(Ordering::SeqCst) {
continue;
}
let interval_secs = state.capture_interval_secs.load(Ordering::SeqCst);
if interval_secs == 0 { continue; }
let now = Local::now();
let timestamp = now.timestamp() as u64;
// Trigger if aligned to interval and we haven't captured this exact second yet
if timestamp % interval_secs == 0 && timestamp != last_capture_time {
last_capture_time = timestamp;
println!("Tick: capturing screen at {}...", now.format("%H:%M:%S"));
if let Err(e) = capture_screens(&app_capture).await {
eprintln!("Failed to capture screens: {}", e);
} else {
let _ = app_capture.emit("refresh-timeline", ());
}
}
}
});
}
async fn run_cleanup(app: &AppHandle) -> anyhow::Result<()> {
let store = match app.store("config.json") {
Ok(s) => s,
Err(_) => return Ok(()),
};
let save_path_value = store.get("savePath");
let save_path_str = match save_path_value {
Some(serde_json::Value::String(s)) => s,
_ => return Ok(()),
};
let base_dir = PathBuf::from(save_path_str);
if !base_dir.exists() {
return Ok(());
}
let retain_days = store
.get("retainDays")
.and_then(|v| v.as_u64())
.unwrap_or(30) as i64;
let now = chrono::Local::now().naive_local().date();
if let Ok(entries) = fs::read_dir(&base_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(folder_name) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(folder_date) = chrono::NaiveDate::parse_from_str(folder_name, "%Y-%m-%d") {
let duration = now.signed_duration_since(folder_date);
if duration.num_days() > retain_days {
let _ = fs::remove_dir_all(&path);
}
}
}
}
}
}
Ok(())
}
async fn capture_screens(app: &AppHandle) -> anyhow::Result<()> {
let store = match app.store("config.json") {
Ok(s) => s,
Err(_) => return Ok(()),
};
let save_path_value = store.get("savePath");
let save_path_str = match save_path_value {
Some(serde_json::Value::String(s)) => s,
_ => return Ok(()),
};
let base_dir = PathBuf::from(save_path_str);
if !base_dir.exists() {
fs::create_dir_all(&base_dir)?;
}
let merge_screens = store
.get("mergeScreens")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let monitors = Monitor::all()?;
let now = Local::now();
let timestamp = now.format("%H-%M-%S").to_string();
let date_folder = now.format("%Y-%m-%d").to_string();
let save_dir = base_dir.join(date_folder);
if !save_dir.exists() {
fs::create_dir_all(&save_dir)?;
}
if merge_screens {
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
for m in &monitors {
let x = m.x()?;
let y = m.y()?;
let width = m.width()? as i32;
let height = m.height()? as i32;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x + width);
max_y = max_y.max(y + height);
}
let total_width = (max_x - min_x) as u32;
let total_height = (max_y - min_y) as u32;
let mut combined_image = image::RgbaImage::new(total_width, total_height);
for m in &monitors {
let capture = m.capture_image()?;
let offset_x = (m.x()? - min_x) as u32;
let offset_y = (m.y()? - min_y) as u32;
image::imageops::overlay(&mut combined_image, &capture, offset_x as i64, offset_y as i64);
}
let file_path = save_dir.join(format!("{}.jpg", timestamp));
let dynamic_image = image::DynamicImage::ImageRgba8(combined_image);
let rgb_image = dynamic_image.into_rgb8();
rgb_image.save_with_format(file_path, image::ImageFormat::Jpeg)?;
} else {
for (i, m) in monitors.iter().enumerate() {
let capture = m.capture_image()?;
let file_path = save_dir.join(format!("{}_{}.jpg", timestamp, i));
let dynamic_image = image::DynamicImage::ImageRgba8(capture);
let rgb_image = dynamic_image.into_rgb8();
rgb_image.save_with_format(file_path, image::ImageFormat::Jpeg)?;
}
}
Ok(())
}