Skip to main content

entracte_lib/
lib.rs

1mod camera;
2pub mod cli;
3mod config;
4mod diagnostics;
5mod dnd;
6mod hooks;
7mod ipc;
8mod pause_store;
9mod platform;
10mod renderer_log;
11mod scheduler;
12mod screen_time_store;
13mod secure_io;
14mod stats;
15mod supporter;
16#[cfg(test)]
17mod test_support;
18mod tray;
19mod updater;
20mod video;
21
22use scheduler::Scheduler;
23use tauri::{Manager, WindowEvent};
24use tauri_plugin_autostart::MacosLauncher;
25use tauri_plugin_log::{Target, TargetKind};
26
27pub fn should_hide_on_close(window_label: &str) -> bool {
28    window_label == "main"
29}
30
31#[cfg_attr(mobile, tauri::mobile_entry_point)]
32pub fn run() {
33    let log_level = if cfg!(debug_assertions) {
34        log::LevelFilter::Debug
35    } else {
36        log::LevelFilter::Info
37    };
38
39    let mut log_targets = vec![Target::new(TargetKind::LogDir {
40        file_name: Some("entracte".to_string()),
41    })];
42    if cfg!(debug_assertions) {
43        log_targets.push(Target::new(TargetKind::Stdout));
44        log_targets.push(Target::new(TargetKind::Stderr));
45    }
46
47    let logger = tauri_plugin_log::Builder::new()
48        .targets(log_targets)
49        .level(log_level)
50        .max_file_size(1024 * 1024)
51        .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(5))
52        .build();
53
54    tauri::Builder::default()
55        .on_window_event(|window, event| {
56            if let WindowEvent::CloseRequested { api, .. } = event {
57                if should_hide_on_close(window.label()) {
58                    api.prevent_close();
59                    let _ = window.hide();
60                }
61            }
62        })
63        .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
64            handle_cli_argv(app, argv);
65        }))
66        .plugin(logger)
67        .plugin(tauri_plugin_opener::init())
68        .plugin(tauri_plugin_dialog::init())
69        .plugin(tauri_plugin_notification::init())
70        .plugin(tauri_plugin_autostart::init(
71            MacosLauncher::LaunchAgent,
72            None,
73        ))
74        .invoke_handler(tauri::generate_handler![
75            scheduler::get_settings,
76            scheduler::update_settings,
77            scheduler::set_hooks,
78            scheduler::pause,
79            scheduler::resume,
80            scheduler::get_pause_info,
81            scheduler::end_break,
82            scheduler::trigger_test_break,
83            scheduler::postpone_break,
84            scheduler::skip_next_break,
85            scheduler::get_postpone_state,
86            scheduler::get_last_break_info,
87            scheduler::resume_last_break,
88            scheduler::get_break_stats,
89            scheduler::get_current_break,
90            scheduler::reset_break_stats,
91            scheduler::get_stats_digest,
92            scheduler::export_stats_csv,
93            scheduler::clear_event_log,
94            scheduler::get_idle_secs,
95            scheduler::get_screen_time,
96            scheduler::list_profiles,
97            scheduler::get_active_profile,
98            scheduler::set_active_profile,
99            scheduler::create_profile,
100            scheduler::duplicate_profile,
101            scheduler::rename_profile,
102            scheduler::delete_profile,
103            scheduler::reorder_profiles,
104            scheduler::reset_profile_to_defaults,
105            updater::check_for_update,
106            diagnostics::build_diagnostics_report,
107            platform::get_platform,
108            renderer_log::report_renderer_error,
109            get_supporter_status,
110            verify_supporter_key,
111            remove_supporter,
112        ])
113        .setup(|app| {
114            #[cfg(target_os = "macos")]
115            app.set_activation_policy(tauri::ActivationPolicy::Accessory);
116
117            let config_dir = app
118                .path()
119                .app_config_dir()
120                .expect("app_config_dir resolves");
121            let _ = secure_io::ensure_user_only_dir(&config_dir);
122            let config_path = config_dir.join("settings.json");
123            let pause_path = config_dir.join("pause.json");
124            let data_dir = app
125                .path()
126                .app_data_dir()
127                .unwrap_or_else(|_| config_dir.clone());
128            let _ = secure_io::ensure_user_only_dir(&data_dir);
129            if let Ok(log_dir) = app.path().app_log_dir() {
130                let _ = secure_io::ensure_user_only_dir(&log_dir);
131                let _ = secure_io::tighten_existing_files_in_dir(&log_dir);
132                // The log plugin rotates files asynchronously with the
133                // process umask, so a startup-only tighten misses every
134                // rotation that happens after boot. Re-tighten once a
135                // minute for the process lifetime.
136                secure_io::spawn_periodic_dir_tighten(log_dir, std::time::Duration::from_secs(60));
137            }
138            let events_path = data_dir.join("events.jsonl");
139            let _ = secure_io::tighten_existing_file(&events_path);
140            let screen_time_path = data_dir.join("screen_time.json");
141
142            let scheduler = Scheduler::new(config_path, pause_path, events_path, screen_time_path);
143            scheduler.spawn(app.handle().clone());
144            app.manage(scheduler);
145
146            let supporter_path = supporter::file_path(&data_dir);
147            app.manage(SupporterAppState {
148                path: supporter_path.clone(),
149                client: reqwest::Client::new(),
150            });
151            spawn_supporter_revalidation(supporter_path);
152
153            tray::setup(app.handle())?;
154
155            if let Err(e) = ipc::start_server(app.handle().clone(), data_dir.clone()) {
156                log::warn!("ipc: failed to start server: {e}");
157            }
158            Ok(())
159        })
160        .run(tauri::generate_context!())
161        .expect("error while running tauri application");
162}
163
164fn handle_cli_argv(app: &tauri::AppHandle, argv: Vec<String>) {
165    if let Some(w) = app.get_webview_window("main") {
166        let _ = w.show();
167        let _ = w.set_focus();
168    }
169    if argv.len() > 1 {
170        log::debug!(
171            "cli: single-instance second invocation received args {:?} \
172             — these route via the local TCP IPC channel, not single-instance forwarding",
173            &argv[1..]
174        );
175    }
176}
177
178pub struct SupporterAppState {
179    pub path: std::path::PathBuf,
180    pub client: reqwest::Client,
181}
182
183fn spawn_supporter_revalidation(path: std::path::PathBuf) {
184    tauri::async_runtime::spawn(async move {
185        let client = reqwest::Client::new();
186        loop {
187            let Some(record) = supporter::load(&path) else {
188                tokio::time::sleep(std::time::Duration::from_secs(60 * 60 * 24)).await;
189                continue;
190            };
191            if supporter::needs_revalidation(record.last_validated_at, chrono::Utc::now()) {
192                match supporter::validate_remote(&client, &record.license_key, &record.instance_id)
193                    .await
194                {
195                    Ok(true) => {
196                        let mut updated = record.clone();
197                        updated.last_validated_at = chrono::Utc::now();
198                        if let Err(e) = supporter::save(&path, &updated) {
199                            log::warn!("supporter: failed to persist validation timestamp: {e}");
200                        }
201                    }
202                    Ok(false) => {
203                        log::warn!("supporter: license no longer valid, removing local record");
204                        let _ = supporter::delete(&path);
205                    }
206                    Err(e) => {
207                        log::warn!("supporter: validate request failed: {e}");
208                    }
209                }
210            }
211            tokio::time::sleep(std::time::Duration::from_secs(60 * 60 * 24)).await;
212        }
213    });
214}
215
216#[tauri::command]
217async fn get_supporter_status(
218    state: tauri::State<'_, SupporterAppState>,
219) -> Result<supporter::SupporterStatus, String> {
220    let record = supporter::load(&state.path);
221    Ok(supporter::SupporterStatus::from_record(
222        record.as_ref(),
223        chrono::Utc::now(),
224    ))
225}
226
227#[tauri::command]
228async fn verify_supporter_key(
229    state: tauri::State<'_, SupporterAppState>,
230    license_key: String,
231) -> Result<supporter::SupporterStatus, String> {
232    let key = license_key.trim().to_string();
233    if key.is_empty() {
234        return Err("license key is empty".to_string());
235    }
236    let host = sysinfo::System::host_name().unwrap_or_else(|| "entracte-machine".to_string());
237    let instance_name = format!("entracte-{host}");
238    let instance_id = supporter::activate_remote(&state.client, &key, &instance_name).await?;
239    let now = chrono::Utc::now();
240    let record = supporter::SupporterRecord {
241        license_key: key,
242        instance_id,
243        activated_at: now,
244        last_validated_at: now,
245    };
246    supporter::save(&state.path, &record).map_err(|e| e.to_string())?;
247    Ok(supporter::SupporterStatus::from_record(Some(&record), now))
248}
249
250#[tauri::command]
251async fn remove_supporter(
252    state: tauri::State<'_, SupporterAppState>,
253) -> Result<supporter::SupporterStatus, String> {
254    supporter::delete(&state.path).map_err(|e| e.to_string())?;
255    Ok(supporter::SupporterStatus::from_record(
256        None,
257        chrono::Utc::now(),
258    ))
259}
260
261#[cfg(test)]
262mod tests {
263    use super::should_hide_on_close;
264
265    #[test]
266    fn main_window_hides_on_close() {
267        assert!(should_hide_on_close("main"));
268    }
269
270    #[test]
271    fn overlay_windows_destroy_on_close() {
272        assert!(!should_hide_on_close("overlay-0"));
273        assert!(!should_hide_on_close("overlay-1"));
274        assert!(!should_hide_on_close("overlay-7"));
275    }
276
277    #[test]
278    fn unknown_labels_destroy_on_close() {
279        assert!(!should_hide_on_close(""));
280        assert!(!should_hide_on_close("Main"));
281        assert!(!should_hide_on_close("main "));
282        assert!(!should_hide_on_close(" main"));
283        assert!(!should_hide_on_close("settings"));
284        assert!(!should_hide_on_close("preferences"));
285    }
286}