Skip to main content

entracte_lib/
lib.rs

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/// Extract the executable path an autostart LaunchAgent will launch — the
42/// first `<string>` inside its `ProgramArguments` array.
43#[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/// True when an existing autostart LaunchAgent points at a different
53/// executable than the running one. The `auto-launch` crate's
54/// `is_enabled()` only checks that the plist exists — never that its path
55/// is current — and it keys the plist by product name, so a plist written
56/// by another build sharing that name (e.g. a dev binary) silently keeps
57/// launching the wrong executable. Detecting the mismatch lets us rewrite it.
58#[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/// True when the running executable lives inside a Cargo build tree
64/// (`.../target/debug/...` or `.../target/release/...`). Such a binary is a
65/// transient dev or CI build, never an installed app, so it must not claim
66/// the autostart LaunchAgent. Without this guard a locally-built bundle that
67/// repoints autostart to itself hijacks it from the installed app and
68/// re-asserts every login — the user then keeps launching the stale build
69/// (e.g. the pre-fix invisible overlay of #196/#226) instead of the release
70/// they installed.
71#[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/// Repoint the autostart LaunchAgent at the running executable when it was
77/// written by a different build. Only the path decisions carry logic
78/// ([`autostart_agent_is_stale`], [`is_dev_build_path`]); the rest is
79/// filesystem/plugin glue.
80#[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    // A build running from a Cargo target tree is never the installed app, so
101    // leave autostart alone: a locally-built bundle that claimed it would
102    // hijack the LaunchAgent from the real install and re-assert every login.
103    // The installed app repoints correctly when it next runs.
104    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                // The log plugin rotates files asynchronously with the
255                // process umask, so a startup-only tighten misses every
256                // rotation that happens after boot. Re-tighten once a
257                // minute for the process lifetime.
258                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            // `stats::append_one` only sets mode at file creation, so
265            // a file recreated through migration / `cp` / restore
266            // would otherwise keep the process umask until next
267            // restart. Sweep the data dir at the same cadence as
268            // log_dir so events.jsonl, screen_time.json, and
269            // supporter.json converge back to 0o600 in-process.
270            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            // Snapshot the loaded settings before `spawn` so the run loop
283            // can't be holding the lock — used to register global hotkeys
284            // once the app is up.
285            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                // Opt-in: silently check for a newer build once at launch and
292                // post a non-intrusive notification if one is available (#238).
293                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}