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