Skip to main content

mimick/
main.rs

1//! Handles application bootstrap, single-instance wiring, and daemon startup flow.
2//!
3//! Initialises GTK/Libadwaita, registers the D-Bus application name for
4//! single-instance enforcement, and decides whether to present the library
5//! window or settings window based on user configuration. Background sync,
6//! tray icon, and filesystem monitor are wired up before entering the
7//! main event loop.
8
9use gtk::prelude::*;
10use libadwaita as adw;
11
12use parking_lot::Mutex;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use tokio::sync::mpsc;
16
17mod api_client;
18mod app_context;
19mod autostart;
20mod cache_manager;
21mod config;
22mod diagnostics;
23mod library;
24mod logging;
25mod media_kinds;
26mod monitor;
27mod notifications;
28mod profile;
29mod queue_manager;
30mod remote_sync;
31mod runtime_env;
32mod sanitize;
33mod settings_window;
34mod sidecar;
35mod startup_scan;
36mod state_manager;
37mod sync_index;
38mod tray_icon;
39mod util;
40mod watch_path_display;
41
42use api_client::ImmichApiClient;
43use app_context::AppContext;
44use config::{Config, best_matching_watch_entry};
45use library::state::LibraryState;
46use library::thumbnail_cache::ThumbnailCache;
47use monitor::{Monitor, MonitorEvent};
48use queue_manager::{EnvironmentPolicy, FileTask, QueueManager};
49use settings_window::build_settings_window;
50use startup_scan::queue_unsynced_files;
51use state_manager::{AppState, StateManager};
52use sync_index::{ShardedSyncIndex, SyncDecision, SyncTarget};
53use tray_icon::build_tray;
54
55use flexi_logger::{Cleanup, Criterion, Duplicate, FileSpec, Logger, Naming, WriteMode};
56
57/// Shared application context reused by UI entry points and the shutdown path.
58static APP_CONTEXT: std::sync::OnceLock<Arc<AppContext>> = std::sync::OnceLock::new();
59
60/// Atomically check and clear a cross-thread boolean flag.
61/// Returns true if the flag was set, clearing it in the process.
62fn consume_flag(flag: &parking_lot::Mutex<bool>) -> bool {
63    let mut f = flag.lock();
64    if *f {
65        *f = false;
66        true
67    } else {
68        false
69    }
70}
71
72#[tokio::main]
73async fn main() {
74    // Mirror logs to stdout and to a rotating cache file for easier support/debugging.
75    let log_dir = profile::cache_dir()
76        .unwrap_or_else(|| std::path::PathBuf::from("/tmp").join(profile::dir_segment()));
77
78    // Named profiles (e.g. MIMICK_PROFILE=dev) default to verbose mimick logs
79    let default_log_spec = if profile::name().is_some() {
80        "mimick=debug,info"
81    } else {
82        "info"
83    };
84    let _logger = Logger::try_with_env_or_str(default_log_spec)
85        .expect("Failed to parse log level")
86        .log_to_file(
87            FileSpec::default()
88                .directory(log_dir)
89                .basename("mimick")
90                .suppress_timestamp() // "mimick.log" instead of "mimick_2026-03-09_10-33-35.log"
91                .suffix("log"),
92        )
93        .format_for_files(logging::detailed_plain_format)
94        .format_for_stdout(logging::detailed_colored_format)
95        .rotate(
96            Criterion::Size(2_000_000),
97            Naming::Numbers,
98            Cleanup::KeepLogFiles(5),
99        )
100        // Also print to stdout for systemd / terminal users
101        .duplicate_to_stdout(Duplicate::All)
102        .write_mode(WriteMode::Direct)
103        .start()
104        .expect("Failed to initialize logger");
105
106    if let Some(name) = profile::name() {
107        log::info!(
108            "Active profile: {} (state dirs use segment '{}')",
109            name,
110            profile::dir_segment()
111        );
112    }
113
114    gtk::gio::resources_register_include!("mimick.gresource")
115        .expect("Failed to register bundled GResource");
116
117    let app = adw::Application::builder()
118        .application_id(profile::application_id())
119        .flags(gtk::gio::ApplicationFlags::HANDLES_COMMAND_LINE)
120        .build();
121
122    let is_primary_instance = Arc::new(AtomicBool::new(false));
123    let is_primary_instance_clone = is_primary_instance.clone();
124
125    let shared_state: Arc<parking_lot::Mutex<AppState>> = Arc::new(parking_lot::Mutex::new({
126        let mut saved = StateManager::new().read_state();
127        // Any items left in the channel during shutdown were dropped, so we must
128        // sync total_queued down to processed_count to clear the stuck queue state.
129        saved.total_queued = saved.processed_count;
130        saved.queue_size = 0;
131        saved.failed_count = 0; // Will be repopulated from retries.json if any
132        saved.current_file = None;
133
134        for status in saved.folder_statuses.values_mut() {
135            status.pending_count = 0;
136        }
137        saved.reset_runtime_state();
138
139        // Reset volatile fields that shouldn't survive a restart
140        AppState {
141            status: "idle".to_string(),
142            active_workers: 0,
143            ..saved
144        }
145    }));
146
147    let shared_state_startup = shared_state.clone();
148
149    // Only the primary instance should initialize background services.
150    // Secondary launches remote-control the primary through GTK's single-instance support.
151    app.connect_startup(move |app| {
152        is_primary_instance_clone.store(true, Ordering::SeqCst);
153
154        log::info!("Mimick primary instance initializing");
155        // Always follow the desktop's light/dark preference.
156        adw::StyleManager::default().set_color_scheme(adw::ColorScheme::Default);
157
158        // Register global CSS for button animations and UI components
159        crate::library::style::ensure_registered();
160
161        thread_local! {
162            static APP_HOLD: std::cell::RefCell<Option<gtk::gio::ApplicationHoldGuard>> = const { std::cell::RefCell::new(None) };
163        }
164        APP_HOLD.with(|hold| {
165            *hold.borrow_mut() = Some(app.hold());
166        });
167
168        // Load config
169        let config = Config::new();
170        let watch_folder_count = config.data.watch_paths.len();
171        let live_watch_paths = Arc::new(Mutex::new(config.data.watch_paths.clone()));
172        log::info!(
173            "Config: internal={} external={} paths={:?}",
174            config.data.internal_url,
175            config.data.external_url,
176            config.watch_path_strings(),
177        );
178
179        {
180            let mut state = shared_state_startup.lock();
181            state.watched_folder_count = watch_folder_count;
182        }
183
184        let background_sync_enabled = config.data.background_sync_enabled;
185
186        let api_key = config.get_api_key().unwrap_or_default();
187        let runtime_internal_url = if config.data.internal_url_enabled {
188            config.data.internal_url.clone()
189        } else {
190            String::new()
191        };
192        let runtime_external_url = if config.data.external_url_enabled {
193            config.data.external_url.clone()
194        } else {
195            String::new()
196        };
197        if api_key.is_empty()
198            && (!runtime_internal_url.is_empty() || !runtime_external_url.is_empty())
199        {
200            log::warn!(
201                "Server URL is configured but no API key was found. \
202                 Library data will not load until an API key is set in Settings."
203            );
204        }
205
206        let api_client = Arc::new(ImmichApiClient::new(
207            runtime_internal_url,
208            runtime_external_url,
209            api_key,
210        ));
211        let sync_index = Arc::new(ShardedSyncIndex::new());
212
213        let sync_index_flusher = sync_index.clone();
214        tokio::spawn(async move {
215            loop {
216                tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
217                let _ = sync_index_flusher.flush();
218            }
219        });
220
221        let qm = Arc::new(QueueManager::new(
222            api_client.clone(),
223            config.data.upload_concurrency.max(1) as usize,
224            shared_state_startup.clone(),
225            sync_index.clone(),
226            EnvironmentPolicy {
227                pause_on_metered_network: config.data.pause_on_metered_network,
228                pause_on_battery_power: config.data.pause_on_battery_power,
229                quiet_hours_start: config.data.quiet_hours_start,
230                quiet_hours_end: config.data.quiet_hours_end,
231            },
232        ));
233
234        // Apply the user's notification preference before any notification can fire.
235        crate::notifications::set_enabled(config.data.notifications_enabled);
236
237        // Sync the RAW decode cache flag with the user's persisted preference.
238        crate::library::set_raw_cache_enabled(config.data.raw_decode_cache_enabled);
239        crate::library::set_raw_full_decode(config.data.raw_full_decode);
240
241        // Keep the watcher service alive, but optionally disable active folder watches.
242        let (tx, mut rx) = mpsc::channel(32);
243        let monitor_paths = if background_sync_enabled {
244            config.data.watch_paths.clone()
245        } else {
246            Vec::new()
247        };
248        let monitor = Monitor::new(monitor_paths, background_sync_enabled);
249        let monitor_handle = Arc::new(monitor.start(tx));
250        if background_sync_enabled {
251            log::info!("File monitor started");
252        } else {
253            log::info!("Background sync is disabled; monitor started with no active watches");
254        }
255
256        let startup_qm = qm.clone();
257        let startup_paths = config.data.watch_paths.clone();
258        let startup_sync_index = sync_index.clone();
259        let (manual_sync_tx, mut manual_sync_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
260        let thumbnail_cache = Arc::new(ThumbnailCache::new(api_client.clone()));
261        // One-shot startup prune across every cache directory. Runs on a
262        // blocking thread after a short delay so it does not contend with
263        // window setup or initial sync work.
264        let cache_cap_mb = config.data.cache_disk_cap_mb;
265        tokio::spawn(async move {
266            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
267            let cap_bytes = (cache_cap_mb as u64).saturating_mul(1024 * 1024);
268            let _ = tokio::task::spawn_blocking(move || {
269                cache_manager::prune_all_blocking(cap_bytes);
270            })
271            .await;
272        });
273        let library_state = Arc::new(parking_lot::Mutex::new(LibraryState::new()));
274        let shared_config = Arc::new(parking_lot::RwLock::new(config));
275        let ctx = Arc::new(AppContext {
276            config: shared_config.clone(),
277            state: shared_state_startup.clone(),
278            api_client: api_client.clone(),
279            queue_manager: qm.clone(),
280            monitor_handle: monitor_handle.clone(),
281            sync_index: sync_index.clone(),
282            live_watch_paths: live_watch_paths.clone(),
283            sync_now_tx: manual_sync_tx.clone(),
284            thumbnail_cache,
285            library_state,
286            library_timeline_active: std::sync::atomic::AtomicBool::new(false),
287            current_user_id: Arc::new(parking_lot::Mutex::new(None)),
288            expected_self_deletions: Arc::new(app_context::RecentSelfPaths::default()),
289            expected_self_downloads: Arc::new(app_context::RecentSelfPaths::default()),
290            reconcile_locks: Arc::new(app_context::ReconcileLocks::default()),
291            pending_deletions: Arc::new(app_context::PendingDeletions::default()),
292        });
293        let _ = APP_CONTEXT.set(ctx.clone());
294
295        let qm_clone = qm.clone();
296        let live_watch_paths_for_queue = live_watch_paths.clone();
297        let deletion_ctx = ctx.clone();
298        tokio::spawn(async move {
299            while let Some(event) = rx.recv().await {
300                match event {
301                    MonitorEvent::Ready { path, checksum, sidecar_path } => {
302                        if deletion_ctx.expected_self_downloads.consume(&path) {
303                            continue;
304                        }
305
306                        let (album_id, album_name, watch_path, folder_rules) = {
307                            let path_configs = live_watch_paths_for_queue.lock();
308                            best_matching_watch_entry(std::path::Path::new(&path), &path_configs)
309                                .map(|entry| match entry {
310                                    config::WatchPathEntry::WithConfig {
311                                        album_id,
312                                        album_name,
313                                        rules,
314                                        ..
315                                    } => (
316                                        album_id.clone(),
317                                        album_name.clone(),
318                                        entry.path().to_string(),
319                                        rules.clone(),
320                                    ),
321                                    config::WatchPathEntry::Simple(_) => {
322                                        (None, None, entry.path().to_string(), Default::default())
323                                    }
324                                })
325                                .unwrap_or((None, None, String::new(), Default::default()))
326                        };
327
328                        // Resolve XMP: per-folder override -> global default.
329                        let global_xmp = deletion_ctx.config.read().data.upload_xmp_sidecars;
330                        let sidecar_path = if folder_rules.xmp_sidecar_enabled(global_xmp) {
331                            sidecar_path
332                        } else {
333                            None
334                        };
335
336                        let target = SyncTarget {
337                            album_name: album_name.clone(),
338                            album_id: album_id.clone(),
339                        };
340                        let (reassociate_only, task_checksum) = match deletion_ctx
341                            .sync_index
342                            .sync_decision(std::path::Path::new(&path), &target)
343                        {
344                            Ok(SyncDecision::UpToDate) => {
345                                log::debug!("Skipping unchanged file: {}", path);
346                                continue;
347                            }
348                            Ok(SyncDecision::NeedsReassociate) => (
349                                true,
350                                deletion_ctx
351                                    .sync_index
352                                    .stored_checksum(&path)
353                                    .unwrap_or(checksum),
354                            ),
355                            Ok(SyncDecision::NeedsUpload) => (false, checksum),
356                            Err(err) => {
357                                log::warn!(
358                                    "Could not inspect sync index for '{}': {}; queuing anyway",
359                                    path,
360                                    err
361                                );
362                                (false, checksum)
363                            }
364                        };
365
366                        log::info!("Queuing: {} (sha1={})", path, task_checksum);
367
368                        let _ = qm_clone
369                            .add_to_queue(FileTask {
370                                path,
371                                watch_path,
372                                checksum: task_checksum,
373                                album_id,
374                                album_name,
375                                reassociate_only,
376                                skip_album: false,
377                                sidecar_path,
378                            })
379                            .await;
380                    }
381                    MonitorEvent::Deleted { path } => {
382                        if deletion_ctx.expected_self_deletions.consume(&path) {
383                            continue;
384                        }
385                        if let Some(request) =
386                            remote_sync::build_local_deletion_request(deletion_ctx.clone(), path).await
387                        {
388                            remote_sync::trash_remote_after_local_delete(deletion_ctx.clone(), request).await;
389                        }
390                    }
391                }
392            }
393        });
394
395        // The startup scan backfills anything that arrived while Mimick was not running.
396        if background_sync_enabled {
397            let shared_state_startup_task = shared_state_startup.clone();
398            let startup_api = ctx.api_client.clone();
399            let startup_ctx = ctx.clone();
400            let catchup_mode = shared_config.read().data.startup_catchup_mode.clone();
401            tokio::spawn(async move {
402                queue_unsynced_files(
403                    startup_paths,
404                    startup_qm,
405                    startup_sync_index,
406                    startup_api,
407                    catchup_mode,
408                    shared_state_startup_task,
409                    startup_ctx,
410                )
411                .await;
412            });
413        } else {
414            log::info!("Background sync is disabled; skipping startup catch-up scan");
415        }
416
417        if background_sync_enabled {
418            let reconciler_ctx = ctx.clone();
419            tokio::spawn(async move {
420                remote_sync::run_album_reconciler(reconciler_ctx).await;
421            });
422        }
423
424        let startup_state = shared_state_startup.clone();
425        let status_api = ctx.api_client.clone();
426        tokio::spawn(async move {
427            let connected = status_api.check_connection().await;
428            let route = status_api.active_route_label().await;
429            let latest_issue = status_api.latest_issue().await;
430
431            let mut state = startup_state.lock();
432            state.active_server_route = route;
433            if connected {
434                state.last_error = None;
435                state.last_error_guidance = None;
436            } else if let Some(issue) = latest_issue {
437                state.last_error = Some(issue.summary);
438                state.last_error_guidance = Some(issue.guidance);
439            }
440        });
441
442        let manual_qm = qm.clone();
443        let manual_sync_index = sync_index.clone();
444        let manual_sync_api = ctx.api_client.clone();
445        let shared_state_manual_task = shared_state_startup.clone();
446        let manual_config = shared_config.clone();
447        let manual_ctx = ctx.clone();
448        tokio::spawn(async move {
449            while manual_sync_rx.recv().await.is_some() {
450                let (watch_paths, catchup_mode) = {
451                    let cfg = manual_config.read();
452                    (cfg.data.watch_paths.clone(), cfg.data.startup_catchup_mode.clone())
453                };
454                queue_unsynced_files(
455                    watch_paths,
456                    manual_qm.clone(),
457                    manual_sync_index.clone(),
458                    manual_sync_api.clone(),
459                    catchup_mode,
460                    shared_state_manual_task.clone(),
461                    manual_ctx.clone(),
462                )
463                .await;
464            }
465        });
466
467        let app_clone2 = app.clone();
468        let app_clone3 = app.clone();
469
470        // Cross-thread flag: Tokio sets it; the GTK timer reads and clears it.
471        // Arc<Mutex<bool>> is Send + Sync, so it can cross the tokio::spawn boundary.
472        let settings_flag = Arc::new(parking_lot::Mutex::new(false));
473        let settings_flag_writer = settings_flag.clone(); // moves into tokio::spawn (Send ✓)
474        let library_flag = Arc::new(parking_lot::Mutex::new(false));
475        let library_flag_writer = library_flag.clone();
476        let quit_flag = Arc::new(parking_lot::Mutex::new(false));
477        let quit_flag_writer = quit_flag.clone(); // moves into tokio::spawn (Send ✓)
478        let pause_flag = Arc::new(parking_lot::Mutex::new(false));
479        let pause_flag_writer = pause_flag.clone();
480        let sync_now_flag = Arc::new(parking_lot::Mutex::new(false));
481        let sync_now_flag_writer = sync_now_flag.clone();
482
483        // GTK-side: poll the flag every 250ms on the main thread.
484        // The application handle stays on the GTK thread and never enters Tokio tasks.
485        glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
486            if consume_flag(&settings_flag) {
487                let ctx = APP_CONTEXT
488                    .get()
489                    .cloned()
490                    .expect("App context should be initialized before opening settings");
491                open_settings_window_now(&app_clone2, ctx);
492            }
493
494            if consume_flag(&library_flag) {
495                let ctx = APP_CONTEXT
496                    .get()
497                    .cloned()
498                    .expect("App context should be initialized before opening library");
499                open_library_window_now(&app_clone2, ctx);
500            }
501
502            if consume_flag(&quit_flag) {
503                app_clone3.quit();
504                return glib::ControlFlow::Break;
505            }
506
507            if consume_flag(&pause_flag) {
508                let qm = &APP_CONTEXT
509                    .get()
510                    .expect("App context should be initialized before pause handling")
511                    .queue_manager;
512                let paused = !qm.is_paused();
513                let reason = if paused {
514                    Some("Paused by user".to_string())
515                } else {
516                    None
517                };
518                qm.set_paused(paused, reason);
519            }
520
521            if consume_flag(&sync_now_flag) {
522                let tx = &APP_CONTEXT
523                    .get()
524                    .expect("App context should be initialized before manual sync handling")
525                    .sync_now_tx;
526                let _ = tx.send(());
527            }
528
529            glib::ControlFlow::Continue
530        });
531
532        // Tokio-side: build the tray and forward watch signals into the flag.
533        // Only *_writer flags (Send ✓) and watch receivers (Send ✓) are captured here.
534        let tray_library_enabled = shared_config.read().data.library_view_enabled;
535        tokio::spawn(async move {
536            log::info!("Starting system tray");
537            match build_tray(tray_library_enabled).await {
538                Ok(handles) => {
539                    let crate::tray_icon::TrayHandles {
540                        handle: _handle,
541                        mut settings_rx,
542                        mut library_rx,
543                        mut quit_rx,
544                        mut pause_rx,
545                        mut sync_now_rx,
546                    } = handles;
547                    loop {
548                        tokio::select! {
549                            res = settings_rx.changed() => {
550                                if res.is_err() {
551                                    break;
552                                }
553                                if *settings_rx.borrow() {
554                                    *settings_flag_writer.lock() = true;
555                                }
556                            }
557                            res = library_rx.changed() => {
558                                if res.is_err() {
559                                    break;
560                                }
561                                if *library_rx.borrow() {
562                                    *library_flag_writer.lock() = true;
563                                }
564                            }
565                            res = quit_rx.changed() => {
566                                if res.is_err() {
567                                    break;
568                                }
569                                if *quit_rx.borrow() {
570                                    *quit_flag_writer.lock() = true;
571                                }
572                            }
573                            res = pause_rx.changed() => {
574                                if res.is_err() {
575                                    break;
576                                }
577                                if *pause_rx.borrow() {
578                                    *pause_flag_writer.lock() = true;
579                                }
580                            }
581                            res = sync_now_rx.changed() => {
582                                if res.is_err() {
583                                    break;
584                                }
585                                if *sync_now_rx.borrow() {
586                                    *sync_now_flag_writer.lock() = true;
587                                }
588                            }
589                        }
590                    }
591                }
592                Err(e) => log::warn!("System tray failed to start: {:?}", e),
593            }
594        });
595    });
596
597    // Handle command line from both the primary and secondary instances.
598    app.connect_command_line(move |app, cmdline| {
599        let argv: Vec<String> = cmdline
600            .arguments()
601            .iter()
602            .filter_map(|a| a.to_str().map(|s| s.to_string()))
603            .collect();
604
605        let quit_requested = argv.contains(&"--quit".to_string());
606        if quit_requested {
607            app.quit();
608            return 0.into();
609        }
610
611        let ctx_early = APP_CONTEXT.get().cloned();
612        let want_settings = argv.contains(&"--settings".to_string());
613        let want_library = argv.contains(&"--library".to_string());
614        let want_upload = argv.contains(&"--upload".to_string());
615        let setup_required = ctx_early
616            .as_ref()
617            .map(|c| c.config.read().get_api_key().unwrap_or_default().is_empty())
618            .unwrap_or(true);
619        let secondary_activation = cmdline.is_remote();
620
621        let ctx_lookup = || {
622            APP_CONTEXT
623                .get()
624                .cloned()
625                .expect("App context should be initialized before command-line activation")
626        };
627
628        // Collect file path arguments (positional args that are not flags).
629        let file_args: Vec<std::path::PathBuf> = argv
630            .iter()
631            .skip(1) // skip binary name
632            .filter(|a| !a.starts_with("--"))
633            .map(std::path::PathBuf::from)
634            .filter(|p| crate::media_kinds::is_supported_path(p))
635            .collect();
636
637        if !file_args.is_empty() && !setup_required {
638            // Files were passed -- open the staging view.
639            crate::library::staging_view::build_staging_window(
640                app,
641                ctx_lookup(),
642                file_args,
643                want_upload,
644            );
645        } else if want_settings || setup_required {
646            open_settings_window_now(app, ctx_lookup());
647        } else if want_library {
648            open_library_window_now(app, ctx_lookup());
649        } else if secondary_activation
650            || !ctx_early
651                .as_ref()
652                .map(|c| c.config.read().data.background_sync_enabled)
653                .unwrap_or(false)
654        {
655            open_default_window(app, ctx_lookup());
656        }
657
658        app.activate();
659        0.into()
660    });
661
662    app.connect_activate(move |_app| {
663        log::debug!("App activated");
664    });
665
666    log::info!("GTK application starting up");
667
668    if is_primary_instance.load(Ordering::SeqCst) {
669        let quit_requested = Arc::new(AtomicBool::new(false));
670        let qr_signal = quit_requested.clone();
671        tokio::spawn(async move {
672            let mut sigterm =
673                match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
674                    Ok(s) => s,
675                    Err(err) => {
676                        log::warn!("Could not install SIGTERM handler: {}", err);
677                        return;
678                    }
679                };
680            tokio::select! {
681                res = tokio::signal::ctrl_c() => {
682                    if let Err(err) = res {
683                        log::warn!("SIGINT handler error: {}", err);
684                        return;
685                    }
686                    log::info!("Received SIGINT; requesting graceful shutdown.");
687                }
688                _ = sigterm.recv() => {
689                    log::info!("Received SIGTERM; requesting graceful shutdown.");
690                }
691            }
692            qr_signal.store(true, Ordering::SeqCst);
693        });
694
695        let app_for_quit = app.clone();
696        glib::timeout_add_local(std::time::Duration::from_millis(200), move || {
697            if quit_requested.load(Ordering::SeqCst) {
698                app_for_quit.quit();
699                glib::ControlFlow::Break
700            } else {
701                glib::ControlFlow::Continue
702            }
703        });
704    }
705
706    app.run();
707
708    // Persist final state and any pending retries on graceful shutdown.
709    if is_primary_instance.load(Ordering::SeqCst) {
710        if let Some(ctx) = APP_CONTEXT.get() {
711            ctx.queue_manager
712                .shutdown(std::time::Duration::from_secs(5))
713                .await;
714            ctx.queue_manager.flush_retries();
715            if let Err(err) = ctx.sync_index.flush() {
716                log::warn!("Failed to flush sync index on shutdown: {}", err);
717            }
718        }
719        let state = APP_CONTEXT
720            .get()
721            .map(|ctx| ctx.state.lock().clone())
722            .unwrap_or_else(|| shared_state.lock().clone());
723        StateManager::new().write_state(state);
724        log::info!("Mimick exiting");
725    }
726}
727
728/// Open whichever window the user prefers, presenting an existing instance if available.
729fn open_default_window(app: &adw::Application, ctx: Arc<AppContext>) {
730    if let Some(win) = find_window(app, "mimick-library-window")
731        .or_else(|| find_window(app, "mimick-settings-window"))
732    {
733        win.present();
734        return;
735    }
736    if ctx.config.read().data.library_view_enabled {
737        open_library_window_now(app, ctx);
738    } else {
739        open_settings_window_now(app, ctx);
740    }
741}
742
743/// Open Settings window or present existing settings instance.
744fn open_settings_window_now(app: &adw::Application, ctx: Arc<AppContext>) {
745    if let Some(win) = find_window(app, "mimick-settings-window") {
746        win.present();
747        return;
748    }
749    log::debug!("Opening settings window");
750    build_settings_window(app, ctx);
751}
752
753/// Open Library window, falling back to Settings if disabled.
754fn open_library_window_now(app: &adw::Application, ctx: Arc<AppContext>) {
755    if let Some(win) = find_window(app, "mimick-library-window") {
756        win.present();
757        return;
758    }
759    if !ctx.config.read().data.library_view_enabled {
760        log::info!("Library view is disabled in settings; opening Settings instead");
761        open_settings_window_now(app, ctx);
762        return;
763    }
764    log::debug!("Opening library window");
765    library::build_library_window(app, ctx);
766}
767
768/// Helper to look up active GTK window instances by widget name.
769fn find_window(app: &adw::Application, name: &str) -> Option<gtk::Window> {
770    app.windows().into_iter().find(|w| w.widget_name() == name)
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use crate::config::{FolderRules, WatchPathEntry};
777
778    #[test]
779    fn test_live_queue_matching_prefers_most_specific_watch_path() {
780        let entries = vec![
781            WatchPathEntry::WithConfig {
782                path: "/home/user/Pictures".into(),
783                album_id: Some("root-album".into()),
784                album_name: Some("Pictures".into()),
785                rules: FolderRules::default(),
786            },
787            WatchPathEntry::WithConfig {
788                path: "/home/user/Pictures/Trips".into(),
789                album_id: Some("trips-album".into()),
790                album_name: Some("Trips".into()),
791                rules: FolderRules::default(),
792            },
793        ];
794
795        let matched = best_matching_watch_entry(
796            std::path::Path::new("/home/user/Pictures/Trips/day1/photo.jpg"),
797            &entries,
798        )
799        .unwrap();
800
801        let config::WatchPathEntry::WithConfig { album_id, .. } = matched else {
802            panic!("expected configured watch entry");
803        };
804        assert_eq!(album_id.as_deref(), Some("trips-album"));
805        assert_eq!(matched.album_name(), Some("Trips"));
806    }
807}