Skip to main content

entracte_lib/
lib.rs

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/// Extract the executable path an autostart LaunchAgent will launch — the
43/// first `<string>` inside its `ProgramArguments` array.
44#[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/// True when an existing autostart LaunchAgent points at a different
54/// executable than the running one. The `auto-launch` crate's
55/// `is_enabled()` only checks that the plist exists — never that its path
56/// is current — and it keys the plist by product name, so a plist written
57/// by another build sharing that name (e.g. a dev binary) silently keeps
58/// launching the wrong executable. Detecting the mismatch lets us rewrite it.
59#[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/// True when the running executable lives inside a Cargo build tree
65/// (`.../target/debug/...` or `.../target/release/...`). Such a binary is a
66/// transient dev or CI build, never an installed app, so it must not claim
67/// the autostart LaunchAgent. Without this guard a locally-built bundle that
68/// repoints autostart to itself hijacks it from the installed app and
69/// re-asserts every login — the user then keeps launching the stale build
70/// (e.g. the pre-fix invisible overlay of #196/#226) instead of the release
71/// they installed.
72#[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/// Repoint the autostart LaunchAgent at the running executable when it was
78/// written by a different build. Only the path decisions carry logic
79/// ([`autostart_agent_is_stale`], [`is_dev_build_path`]); the rest is
80/// filesystem/plugin glue.
81#[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    // A build running from a Cargo target tree is never the installed app, so
102    // leave autostart alone: a locally-built bundle that claimed it would
103    // hijack the LaunchAgent from the real install and re-assert every login.
104    // The installed app repoints correctly when it next runs.
105    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    // First statement deliberately: Entracte polls the X11 idle counter from
123    // a tokio worker while GTK owns the main thread, and libX11 only
124    // serialises that if threading was initialised before any `Display` is
125    // opened (#333). Nothing above this line constructs a Tauri runtime, and
126    // `main.rs`'s CLI branches never reach `run()` and never start a second
127    // thread, so they cannot race Xlib whether locking is on or not.
128    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                // The log plugin rotates files asynchronously with the
264                // process umask, so a startup-only tighten misses every
265                // rotation that happens after boot. Re-tighten once a
266                // minute for the process lifetime.
267                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            // `stats::append_one` only sets mode at file creation, so
274            // a file recreated through migration / `cp` / restore
275            // would otherwise keep the process umask until next
276            // restart. Sweep the data dir at the same cadence as
277            // log_dir so events.jsonl, screen_time.json, and
278            // supporter.json converge back to 0o600 in-process.
279            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            // Snapshot the loaded settings before `spawn` so the run loop
292            // can't be holding the lock — used to register global hotkeys
293            // once the app is up.
294            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                // Opt-in: silently check for a newer build once at launch and
301                // post a non-intrusive notification if one is available (#238).
302                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}