1mod audio;
2mod camera;
3mod chores_store;
4pub mod cli;
5mod config;
6mod diagnostics;
7mod display;
8mod dnd;
9mod hooks;
10mod ipc;
11mod license_redact;
12mod media;
13mod notifications;
14mod pause_store;
15mod platform;
16mod plugin_store;
17mod plugins;
18mod proc;
19mod proc_match;
20mod renderer_log;
21mod scheduler;
22mod screen_time_store;
23mod secure_io;
24mod stats;
25pub mod supporter;
26#[cfg(test)]
27mod test_support;
28mod tray;
29mod updater;
30mod video;
31mod window;
32
33use scheduler::Scheduler;
34use tauri::{Manager, WindowEvent};
35use tauri_plugin_autostart::MacosLauncher;
36use tauri_plugin_log::{Target, TargetKind};
37
38pub fn should_hide_on_close(window_label: &str) -> bool {
39 window_label == "main"
40}
41
42#[cfg(any(target_os = "macos", test))]
45fn launch_agent_program_path(plist: &str) -> Option<String> {
46 let after_key = plist.split("ProgramArguments").nth(1)?;
47 let open = after_key.find("<string>")? + "<string>".len();
48 let rest = &after_key[open..];
49 let close = rest.find("</string>")?;
50 Some(rest[..close].trim().to_string())
51}
52
53#[cfg(any(target_os = "macos", test))]
60fn autostart_agent_is_stale(plist: &str, current_exe: &str) -> bool {
61 launch_agent_program_path(plist).is_some_and(|p| p != current_exe)
62}
63
64#[cfg(any(target_os = "macos", test))]
73fn is_dev_build_path(exe: &str) -> bool {
74 exe.contains("/target/debug/") || exe.contains("/target/release/")
75}
76
77#[cfg(target_os = "macos")]
82fn repoint_stale_autostart_agent(app: &tauri::App) {
83 use tauri_plugin_autostart::ManagerExt;
84 let manager = app.autolaunch();
85 if !manager.is_enabled().unwrap_or(false) {
86 return;
87 }
88 let Ok(home) = app.path().home_dir() else {
89 return;
90 };
91 let plist = home
92 .join("Library/LaunchAgents")
93 .join(format!("{}.plist", app.package_info().name));
94 let Ok(contents) = std::fs::read_to_string(&plist) else {
95 return;
96 };
97 let Ok(exe) = std::env::current_exe().and_then(|p| p.canonicalize()) else {
98 return;
99 };
100 let exe = exe.display().to_string();
101 if is_dev_build_path(&exe) {
106 log::debug!("autostart: running from a build tree; leaving the LaunchAgent untouched");
107 return;
108 }
109 if autostart_agent_is_stale(&contents, &exe) {
110 log::warn!(
111 "autostart: LaunchAgent points at a stale executable; repointing to current binary"
112 );
113 let _ = manager.disable();
114 if let Err(e) = manager.enable() {
115 log::warn!("autostart: failed to repoint LaunchAgent: {e}");
116 }
117 }
118}
119
120#[cfg_attr(mobile, tauri::mobile_entry_point)]
121pub fn run() {
122 display::init_display_threading();
129
130 let log_level = if cfg!(debug_assertions) {
131 log::LevelFilter::Debug
132 } else {
133 log::LevelFilter::Info
134 };
135
136 let mut log_targets = vec![Target::new(TargetKind::LogDir {
137 file_name: Some("entracte".to_string()),
138 })];
139 if cfg!(debug_assertions) {
140 log_targets.push(Target::new(TargetKind::Stdout));
141 log_targets.push(Target::new(TargetKind::Stderr));
142 }
143
144 let logger = tauri_plugin_log::Builder::new()
145 .targets(log_targets)
146 .level(log_level)
147 .max_file_size(1024 * 1024)
148 .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(5))
149 .build();
150
151 tauri::Builder::default()
152 .on_window_event(|window, event| {
153 if let WindowEvent::CloseRequested { api, .. } = event {
154 if should_hide_on_close(window.label()) {
155 api.prevent_close();
156 let _ = window.hide();
157 }
158 }
159 })
160 .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
161 handle_cli_argv(app, argv);
162 }))
163 .plugin(logger)
164 .plugin(tauri_plugin_opener::init())
165 .plugin(tauri_plugin_dialog::init())
166 .plugin(tauri_plugin_notification::init())
167 .plugin(tauri_plugin_updater::Builder::new().build())
168 .plugin(tauri_plugin_autostart::init(
169 MacosLauncher::LaunchAgent,
170 None,
171 ))
172 .plugin(tauri_plugin_global_shortcut::Builder::new().build())
173 .invoke_handler(tauri::generate_handler![
174 scheduler::get_settings,
175 scheduler::update_settings,
176 scheduler::get_onboarding_completed,
177 scheduler::complete_onboarding,
178 scheduler::set_hooks,
179 scheduler::test_hook,
180 scheduler::pause,
181 scheduler::resume,
182 scheduler::export_backup_to_path,
183 scheduler::import_backup_from_path,
184 scheduler::import_content_pack,
185 scheduler::export_content_pack,
186 scheduler::install_plugin,
187 scheduler::uninstall_plugin,
188 scheduler::list_plugins,
189 scheduler::get_pause_info,
190 scheduler::end_break,
191 scheduler::trigger_test_break,
192 scheduler::notify_overlay_rendered,
193 scheduler::postpone_break,
194 scheduler::skip_next_break,
195 scheduler::get_postpone_state,
196 scheduler::get_last_break_info,
197 scheduler::resume_last_break,
198 scheduler::start_long_break_now,
199 scheduler::get_break_stats,
200 scheduler::get_current_break,
201 scheduler::get_routines,
202 scheduler::reset_break_stats,
203 scheduler::get_stats_digest,
204 scheduler::export_stats_csv,
205 scheduler::clear_event_log,
206 scheduler::get_idle_secs,
207 scheduler::get_screen_time,
208 scheduler::get_chores,
209 scheduler::set_chores,
210 scheduler::list_profiles,
211 scheduler::get_active_profile,
212 scheduler::set_active_profile,
213 scheduler::create_profile,
214 scheduler::duplicate_profile,
215 scheduler::rename_profile,
216 scheduler::delete_profile,
217 scheduler::reorder_profiles,
218 scheduler::reset_profile_to_defaults,
219 updater::check_for_update,
220 diagnostics::build_diagnostics_report,
221 platform::get_platform,
222 platform::get_arch,
223 platform::get_platform_capabilities,
224 platform::get_locale,
225 window::close_pause_window,
226 renderer_log::report_renderer_error,
227 audio::play_sound,
228 audio::play_custom_sound,
229 audio::start_ambient,
230 audio::start_custom_ambient,
231 audio::preview_ambient,
232 audio::preview_custom_ambient,
233 audio::stop_ambient,
234 audio::stop_all_sounds,
235 get_supporter_status,
236 verify_supporter_key,
237 remove_supporter,
238 ])
239 .setup(|app| {
240 #[cfg(target_os = "macos")]
241 {
242 app.set_activation_policy(tauri::ActivationPolicy::Accessory);
243 repoint_stale_autostart_agent(app);
244 }
245
246 notifications::ensure_permission_requested(app.handle());
247
248 let config_dir = app
249 .path()
250 .app_config_dir()
251 .expect("app_config_dir resolves");
252 let _ = secure_io::ensure_user_only_dir(&config_dir);
253 let config_path = config_dir.join("settings.json");
254 let pause_path = config_dir.join("pause.json");
255 let data_dir = app
256 .path()
257 .app_data_dir()
258 .unwrap_or_else(|_| config_dir.clone());
259 let _ = secure_io::ensure_user_only_dir(&data_dir);
260 if let Ok(log_dir) = app.path().app_log_dir() {
261 let _ = secure_io::ensure_user_only_dir(&log_dir);
262 let _ = secure_io::tighten_existing_files_in_dir(&log_dir);
263 secure_io::spawn_periodic_dir_tighten(log_dir, std::time::Duration::from_secs(60));
268 }
269 let events_path = data_dir.join("events.jsonl");
270 let _ = secure_io::tighten_existing_file(&events_path);
271 let screen_time_path = data_dir.join("screen_time.json");
272 let chores_path = data_dir.join("chores.json");
273 secure_io::spawn_periodic_dir_tighten(
280 data_dir.clone(),
281 std::time::Duration::from_secs(60),
282 );
283
284 let scheduler = Scheduler::new(
285 config_path,
286 pause_path,
287 events_path,
288 screen_time_path,
289 chores_path,
290 );
291 let initial_settings = scheduler.settings.try_lock().map(|s| s.clone()).ok();
295 scheduler.spawn(app.handle().clone());
296 app.manage(scheduler);
297
298 if let Some(settings) = initial_settings {
299 scheduler::apply_hotkeys(app.handle(), &settings);
300 updater::spawn_startup_check(app.handle().clone(), &settings);
303 }
304
305 app.manage(audio::AudioPlayer::spawn());
306
307 let supporter_path = supporter::file_path(&data_dir);
308 app.manage(SupporterAppState {
309 path: supporter_path.clone(),
310 client: reqwest::Client::new(),
311 });
312 spawn_supporter_revalidation(supporter_path);
313
314 tray::setup(app.handle())?;
315
316 if let Err(e) = ipc::start_server(app.handle().clone(), data_dir.clone()) {
317 log::warn!("ipc: failed to start server: {e}");
318 }
319
320 diagnostics::log_startup_banner(app.handle());
321 Ok(())
322 })
323 .run(tauri::generate_context!())
324 .expect("error while running tauri application");
325}
326
327fn handle_cli_argv(app: &tauri::AppHandle, argv: Vec<String>) {
328 window::show_main_window(app);
329 if argv.len() > 1 {
330 log::debug!(
331 "cli: single-instance second invocation received args {:?} \
332 — these route via the local TCP IPC channel, not single-instance forwarding",
333 &argv[1..]
334 );
335 }
336}
337
338pub struct SupporterAppState {
339 pub path: std::path::PathBuf,
340 pub client: reqwest::Client,
341}
342
343fn spawn_supporter_revalidation(path: std::path::PathBuf) {
344 tauri::async_runtime::spawn(async move {
345 let client = reqwest::Client::new();
346 loop {
347 let Some(record) = supporter::load(&path) else {
348 tokio::time::sleep(std::time::Duration::from_secs(60 * 60 * 24)).await;
349 continue;
350 };
351 if supporter::needs_remote_revalidation(&record, chrono::Utc::now()) {
352 match supporter::validate_remote(&client, &record.license_key, &record.instance_id)
353 .await
354 {
355 Ok(true) => {
356 let mut updated = record.clone();
357 updated.last_validated_at = chrono::Utc::now();
358 if let Err(e) = supporter::save(&path, &updated) {
359 log::warn!("supporter: failed to persist validation timestamp: {e}");
360 }
361 }
362 Ok(false) => {
363 log::warn!("supporter: license no longer valid, removing local record");
364 let _ = supporter::delete(&path);
365 }
366 Err(e) => {
367 log::warn!("supporter: validate request failed: {e}");
368 }
369 }
370 }
371 tokio::time::sleep(std::time::Duration::from_secs(60 * 60 * 24)).await;
372 }
373 });
374}
375
376#[tauri::command]
377async fn get_supporter_status(
378 state: tauri::State<'_, SupporterAppState>,
379) -> Result<supporter::SupporterStatus, String> {
380 let record = supporter::load(&state.path);
381 Ok(supporter::SupporterStatus::from_record(
382 record.as_ref(),
383 chrono::Utc::now(),
384 ))
385}
386
387#[tauri::command]
388async fn verify_supporter_key(
389 state: tauri::State<'_, SupporterAppState>,
390 license_key: String,
391) -> Result<supporter::SupporterStatus, String> {
392 let host = sysinfo::System::host_name().unwrap_or_else(|| "entracte-machine".to_string());
393 let instance_name = format!("entracte-{host}");
394 let now = chrono::Utc::now();
395 let record = supporter::activate_with(
396 &state.path,
397 &state.client,
398 &license_key,
399 &instance_name,
400 now,
401 )
402 .await?;
403 Ok(supporter::SupporterStatus::from_record(Some(&record), now))
404}
405
406#[tauri::command]
407async fn remove_supporter(
408 state: tauri::State<'_, SupporterAppState>,
409) -> Result<supporter::SupporterStatus, String> {
410 supporter::delete(&state.path).map_err(|e| e.to_string())?;
411 Ok(supporter::SupporterStatus::from_record(
412 None,
413 chrono::Utc::now(),
414 ))
415}
416
417#[cfg(test)]
418mod tests {
419 use super::should_hide_on_close;
420
421 #[test]
422 fn main_window_hides_on_close() {
423 assert!(should_hide_on_close("main"));
424 }
425
426 #[test]
427 fn overlay_windows_destroy_on_close() {
428 assert!(!should_hide_on_close("overlay-0"));
429 assert!(!should_hide_on_close("overlay-1"));
430 assert!(!should_hide_on_close("overlay-7"));
431 }
432
433 #[test]
434 fn unknown_labels_destroy_on_close() {
435 assert!(!should_hide_on_close(""));
436 assert!(!should_hide_on_close("Main"));
437 assert!(!should_hide_on_close("main "));
438 assert!(!should_hide_on_close(" main"));
439 assert!(!should_hide_on_close("settings"));
440 assert!(!should_hide_on_close("preferences"));
441 }
442
443 const EXE: &str = "/Applications/Entracte.app/Contents/MacOS/entracte";
444
445 fn plist_for(program: &str) -> String {
446 format!(
447 "<?xml version=\"1.0\"?>\n<plist version=\"1.0\"><dict>\
448 <key>Label</key><string>Entracte</string>\
449 <key>ProgramArguments</key><array><string>{program}</string></array>\
450 <key>RunAtLoad</key><true/></dict></plist>"
451 )
452 }
453
454 #[test]
455 fn program_path_extracts_first_argument() {
456 assert_eq!(
457 super::launch_agent_program_path(&plist_for(EXE)).as_deref(),
458 Some(EXE)
459 );
460 }
461
462 #[test]
463 fn program_path_is_none_without_program_arguments() {
464 assert_eq!(super::launch_agent_program_path("<plist></plist>"), None);
465 }
466
467 #[test]
468 fn agent_is_stale_when_path_differs() {
469 let plist = plist_for("/Users/dev/workspace/breakly/target/debug/entracte");
470 assert!(super::autostart_agent_is_stale(&plist, EXE));
471 }
472
473 #[test]
474 fn agent_is_not_stale_when_path_matches() {
475 assert!(!super::autostart_agent_is_stale(&plist_for(EXE), EXE));
476 }
477
478 #[test]
479 fn agent_is_not_stale_without_program_arguments() {
480 assert!(!super::autostart_agent_is_stale("<plist/>", EXE));
481 }
482
483 #[test]
484 fn dev_build_path_detects_cargo_target_tree() {
485 assert!(super::is_dev_build_path(
486 "/Users/dev/workspace/entracte/src-tauri/target/release/bundle/macos/\
487 Entracte.app/Contents/MacOS/entracte"
488 ));
489 assert!(super::is_dev_build_path(
490 "/Users/dev/entracte/src-tauri/target/debug/entracte"
491 ));
492 }
493
494 #[test]
495 fn dev_build_path_false_for_installed_app() {
496 assert!(!super::is_dev_build_path(EXE));
497 assert!(!super::is_dev_build_path(
498 "/Users/me/Applications/Entracte.app/Contents/MacOS/entracte"
499 ));
500 }
501}