init
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
7086
src-tauri/Cargo.lock
generated
Normal file
36
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "chrono-snap"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "chrono_snap_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
xcap = "0.9.3"
|
||||
image = "0.25.10"
|
||||
chrono = "0.4.44"
|
||||
tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
anyhow = "1.0.102"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
17
src-tauri/capabilities/desktop.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"autostart:default",
|
||||
"dialog:default",
|
||||
"store:default",
|
||||
"fs:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
216
src-tauri/src/engine.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{interval, Duration};
|
||||
use tauri::{AppHandle, Manager};
|
||||
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>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_pause(state: tauri::State<'_, AppState>) -> bool {
|
||||
let current = state.is_paused.load(Ordering::SeqCst);
|
||||
state.is_paused.store(!current, Ordering::SeqCst);
|
||||
!current
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pause_state(state: tauri::State<'_, AppState>) -> bool {
|
||||
state.is_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_timeline(date: String, base_dir: String) -> Vec<serde_json::Value> {
|
||||
let mut results = Vec::new();
|
||||
let dir_path = PathBuf::from(base_dir).join(date);
|
||||
|
||||
if !dir_path.exists() || !dir_path.is_dir() {
|
||||
return results;
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(dir_path) {
|
||||
let mut paths: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("jpg"))
|
||||
.collect();
|
||||
|
||||
paths.sort();
|
||||
|
||||
for path in paths {
|
||||
if let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
// file_name format: "14-30-00" or "14-30-00_0"
|
||||
let time_str = file_name.replace("-", ":").replace("_", " ");
|
||||
results.push(serde_json::json!({
|
||||
"time": time_str,
|
||||
"path": path.to_string_lossy().to_string()
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
pub fn start_engine(app: AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let is_paused = state.is_paused.clone();
|
||||
|
||||
// Start Cleanup routine
|
||||
let app_clone = 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_clone).await {
|
||||
eprintln!("Cleanup error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Every 60 seconds
|
||||
let mut ticker = interval(Duration::from_secs(60));
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
if is_paused.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("Tick: capturing screen...");
|
||||
if let Err(e) = capture_screens(&app).await {
|
||||
eprintln!("Failed to capture screens: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
41
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
mod engine;
|
||||
mod tray;
|
||||
|
||||
use tauri::Manager;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--minimized"]),
|
||||
))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
engine::toggle_pause,
|
||||
engine::get_pause_state,
|
||||
engine::get_timeline
|
||||
])
|
||||
.setup(|app| {
|
||||
app.manage(engine::AppState {
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
});
|
||||
tray::create_tray(app.handle())?;
|
||||
engine::start_engine(app.handle().clone());
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
window.hide().unwrap();
|
||||
api.prevent_close();
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
chrono_snap_lib::run()
|
||||
}
|
||||
59
src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let open_i = MenuItem::with_id(app, "open", "打开主界面", true, None::<&str>)?;
|
||||
let toggle_i = MenuItem::with_id(app, "toggle", "暂停记录", true, None::<&str>)?;
|
||||
let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&open_i, &toggle_i, &quit_i])?;
|
||||
|
||||
let toggle_i_clone = toggle_i.clone();
|
||||
|
||||
TrayIconBuilder::with_id("main_tray")
|
||||
.tooltip("Chrono Snap")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||
"open" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"toggle" => {
|
||||
let state = app.state::<crate::engine::AppState>();
|
||||
let current = state.is_paused.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let new_state = !current;
|
||||
state.is_paused.store(new_state, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let text = if new_state { "恢复记录" } else { "暂停记录" };
|
||||
let _ = toggle_i_clone.set_text(text);
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
35
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "chrono-snap",
|
||||
"version": "0.1.0",
|
||||
"identifier": "top.volan.chrono-snap",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "chrono-snap",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||