1use crate::autostart;
8use crate::config::{FolderRules, StartupCatchupMode, WatchPathEntry};
9use crate::diagnostics;
10use adw::prelude::*;
11use glib::clone;
12use gtk::prelude::*;
13use gtk::{Button, FileDialog, ListBox, ScrolledWindow};
14use libadwaita as adw;
15use std::cell::Cell;
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::path::Path;
19use std::rc::Rc;
20use std::sync::Arc;
21use std::time::Duration;
22
23use crate::app_context::AppContext;
24
25mod actions_ui;
26mod behavior;
27mod connectivity;
28mod library;
29mod queue_inspector;
30mod status;
31mod watch_folders;
32
33use queue_inspector::show_about_dialog;
34pub use queue_inspector::show_queue_inspector;
35use watch_folders::add_folder_row;
36
37struct FolderRowData {
39 path: String,
41 album_name: Rc<RefCell<String>>,
43 rules: Rc<RefCell<FolderRules>>,
45 action_row: adw::ExpanderRow,
47 base_subtitle: String,
49}
50
51const DEFAULT_ALBUM_LABEL: &str = "Default (Folder Name)";
52
53fn show_alert(parent: &impl gtk::prelude::IsA<gtk::Widget>, heading: &str, body: &str) {
55 let dialog = adw::AlertDialog::builder()
56 .heading(heading)
57 .body(body)
58 .build();
59 dialog.add_response("ok", "OK");
60 dialog.present(Some(parent));
61}
62
63fn format_sync_age(timestamp: Option<f64>) -> String {
65 let Some(timestamp) = timestamp else {
66 return "No successful sync yet".to_string();
67 };
68
69 let now = std::time::SystemTime::now()
70 .duration_since(std::time::UNIX_EPOCH)
71 .unwrap_or_default()
72 .as_secs_f64();
73 let elapsed = (now - timestamp).max(0.0);
74
75 if elapsed < 60.0 {
76 "Less than a minute ago".to_string()
77 } else if elapsed < 3600.0 {
78 format!("{} minute(s) ago", (elapsed / 60.0).floor() as u64)
79 } else if elapsed < 86_400.0 {
80 format!("{} hour(s) ago", (elapsed / 3600.0).floor() as u64)
81 } else {
82 format!("{} day(s) ago", (elapsed / 86_400.0).floor() as u64)
83 }
84}
85
86pub fn build_settings_window(app: &adw::Application, ctx: Arc<AppContext>) {
88 build_settings_window_with_parent(app, ctx, None);
89}
90
91pub fn build_settings_window_with_parent(
93 app: &adw::Application,
94 ctx: Arc<AppContext>,
95 parent: Option<&adw::ApplicationWindow>,
96) {
97 let shared_state = ctx.state.clone();
98 let api_client = ctx.api_client.clone();
99 let queue_manager = ctx.queue_manager.clone();
100 let monitor_handle = ctx.monitor_handle.clone();
101 let live_watch_paths = ctx.live_watch_paths.clone();
102 let sync_now_tx = ctx.sync_now_tx.clone();
103 let thumbnail_cache = ctx.thumbnail_cache.clone();
104 let shared_config = ctx.config.clone();
105 let mut window_builder = adw::ApplicationWindow::builder()
107 .application(app)
108 .title("Mimick")
109 .name("mimick-settings-window")
110 .default_width(520)
111 .default_height(780);
112 if let Some(parent) = parent {
113 window_builder = window_builder
114 .transient_for(parent)
115 .modal(true)
116 .destroy_with_parent(true);
117 }
118 let window = window_builder.build();
119 window.set_size_request(360, 480);
120
121 let view_stack = adw::ViewStack::builder()
122 .vexpand(true)
123 .hexpand(true)
124 .build();
125 let page_switcher = adw::ViewSwitcher::builder().stack(&view_stack).build();
126 let header_bar = adw::HeaderBar::builder()
127 .title_widget(&page_switcher)
128 .build();
129 let about_header_btn = Button::builder()
130 .icon_name("help-about-symbolic")
131 .tooltip_text("About Mimick")
132 .build();
133 let window_clone_about = window.clone();
134 about_header_btn.connect_clicked(move |_| {
135 show_about_dialog(&window_clone_about);
136 });
137 header_bar.pack_start(&about_header_btn);
138
139 let toolbar_view = adw::ToolbarView::builder().build();
140 toolbar_view.add_top_bar(&header_bar);
141 toolbar_view.set_content(Some(&view_stack));
142 window.set_content(Some(&toolbar_view));
143
144 let status_scroll = ScrolledWindow::builder()
145 .hscrollbar_policy(gtk::PolicyType::Never)
146 .vexpand(true)
147 .hexpand(true)
148 .build();
149 let settings_scroll = ScrolledWindow::builder()
150 .hscrollbar_policy(gtk::PolicyType::Never)
151 .vexpand(true)
152 .hexpand(true)
153 .build();
154 view_stack.add_titled_with_icon(
155 &status_scroll,
156 Some("status"),
157 "Status",
158 "dialog-information-symbolic",
159 );
160 view_stack.add_titled_with_icon(
161 &settings_scroll,
162 Some("settings"),
163 "Settings",
164 "emblem-system-symbolic",
165 );
166
167 let app_clone = app.clone();
168 let config = ctx.config.read().clone();
169
170 let status_page = adw::PreferencesPage::builder()
171 .title("Status")
172 .icon_name("dialog-information-symbolic")
173 .build();
174 status_scroll.set_child(Some(&status_page));
175
176 let settings_page = adw::PreferencesPage::builder()
177 .title("Settings")
178 .icon_name("emblem-system-symbolic")
179 .build();
180 settings_scroll.set_child(Some(&settings_page));
181
182 let is_unconfigured = config.get_api_key().unwrap_or_default().is_empty();
183 if is_unconfigured {
184 let welcome_group = adw::PreferencesGroup::builder()
185 .title("Welcome to Mimick!")
186 .description("Start by adding your API key, testing the connection, and choosing at least one folder. The key needs Asset (read, view, upload, update, download, delete), Album (read, create, update), and albumAsset (create, delete) permissions.")
187 .build();
188
189 let help_row = adw::ActionRow::builder()
190 .title("How to get an API Key")
191 .subtitle("Base sync: user.read, asset.upload/update, album.read/create, albumAsset.create. See docs for Library/deletion scopes.")
192 .activatable(true)
193 .build();
194
195 help_row.connect_activated(|_| {
196 let uri = "https://immich.app/docs/features/command-line-interface/#api-key";
197 if let Err(e) =
198 gtk::gio::AppInfo::launch_default_for_uri(uri, None::<>k::gio::AppLaunchContext>)
199 {
200 log::error!("Failed to open browser: {}", e);
201 }
202 });
203
204 welcome_group.add(&help_row);
205 settings_page.add(&welcome_group);
206 }
207
208 let status::StatusWidgets {
209 status_row,
210 progress_bar,
211 route_row,
212 folders_row,
213 queue_health_row,
214 last_sync_row,
215 error_row,
216 } = status::build_status_group(&status_page);
217
218 let connectivity::ConnectivityWidgets {
219 internal_switch,
220 external_switch,
221 internal_entry,
222 external_entry,
223 api_key_entry,
224 test_btn,
225 save_btn,
226 } = connectivity::build_connectivity_group(&settings_page, &window);
227
228 let api_client_for_test = api_client.clone();
230 test_btn.connect_clicked(clone!(
231 #[weak]
232 internal_switch,
233 #[weak]
234 external_switch,
235 #[weak]
236 internal_entry,
237 #[weak]
238 external_entry,
239 #[weak]
240 api_key_entry,
241 #[weak]
242 window,
243 #[weak]
244 test_btn,
245 move |btn| {
246 btn.set_sensitive(false);
247
248 let internal = if internal_switch.is_active() {
250 internal_entry.text().to_string()
251 } else {
252 String::new()
253 };
254 let external = if external_switch.is_active() {
255 external_entry.text().to_string()
256 } else {
257 String::new()
258 };
259 let _api_key = api_key_entry.text().to_string();
260
261 let (tx, mut rx) = tokio::sync::oneshot::channel::<(bool, bool)>();
262
263 let ping_client = api_client_for_test.clone();
267 let internal2 = internal.clone();
268 let external2 = external.clone();
269 tokio::spawn(async move {
270 let int_ok = if !internal2.is_empty() {
271 ping_client.ping_url(&internal2).await
272 } else {
273 false
274 };
275 let ext_ok = if !external2.is_empty() {
276 ping_client.ping_url(&external2).await
277 } else {
278 false
279 };
280 let _ = tx.send((int_ok, ext_ok));
281 });
282
283 glib::timeout_add_local(
285 Duration::from_millis(50),
286 clone!(
287 #[weak]
288 window,
289 #[weak]
290 test_btn,
291 #[upgrade_or]
292 glib::ControlFlow::Break,
293 move || {
294 match rx.try_recv() {
295 Ok((int_ok, ext_ok)) => {
296 test_btn.set_sensitive(true);
297
298 let int_label = if int_ok { "OK" } else { "FAILED" };
299 let ext_label = if ext_ok { "OK" } else { "FAILED" };
300 let mut report =
301 format!("Internal: {}\nExternal: {}", int_label, ext_label);
302 let heading = if int_ok || ext_ok {
303 if int_ok {
304 report.push_str("\n\nActive Mode: LAN");
305 } else {
306 report.push_str("\n\nActive Mode: WAN");
307 }
308 "Connection Successful"
309 } else {
310 report = "Could not connect to Immich at either address."
311 .to_string();
312 "Connection Failed"
313 };
314
315 show_alert(&window, heading, &report);
316
317 glib::ControlFlow::Break
318 }
319 Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
320 glib::ControlFlow::Continue
322 }
323 Err(_) => glib::ControlFlow::Break, }
325 }
326 ),
327 );
328 }
329 ));
330
331 let behavior::BehaviorWidgets {
332 startup_row,
333 background_sync_row,
334 metered_row,
335 battery_row,
336 notifications_row,
337 library_view_row,
338 catchup_row,
339 concurrency_row,
340 xmp_sidecar_row,
341 quiet_hours_row,
342 quiet_start_row,
343 quiet_end_row,
344 } = behavior::build_behavior_group(&settings_page);
345
346 quiet_hours_row.connect_active_notify(clone!(
348 #[weak]
349 quiet_start_row,
350 #[weak]
351 quiet_end_row,
352 move |row| {
353 quiet_start_row.set_sensitive(row.is_active());
354 quiet_end_row.set_sensitive(row.is_active());
355 }
356 ));
357
358 let library::LibraryWidgets {
359 library_group,
360 preview_full_row,
361 grid_quality_row,
362 raw_full_decode_row,
363 raw_cache_row,
364 disk_cache_row,
365 download_folder_row,
366 download_change_btn,
367 download_clear_btn,
368 border_width_row,
369 border_color_btn,
370 } = library::build_library_group(&settings_page);
371
372 let ctx_for_library = ctx.clone();
373 library_view_row.connect_active_notify(glib::clone!(
374 #[weak]
375 library_group,
376 move |row| {
377 let active = row.is_active();
378 library_group.set_visible(active);
379 let mut cfg = ctx_for_library.config.write();
380 if cfg.data.library_view_enabled != active {
381 cfg.data.library_view_enabled = active;
382 cfg.save();
383 }
384 }
385 ));
386
387 let ctx_for_preview = ctx.clone();
388 preview_full_row.connect_active_notify(move |row| {
389 let active = row.is_active();
390 let mut cfg = ctx_for_preview.config.write();
391 if cfg.data.library_preview_full_resolution != active {
392 cfg.data.library_preview_full_resolution = active;
393 cfg.save();
394 }
395 });
396
397 let initial_quality_idx = match ctx.config.read().data.library_grid_quality.as_str() {
398 "thumbnail" => 1,
399 "preview" => 2,
400 "fullsize" => 3,
401 _ => 0,
402 };
403 grid_quality_row.set_selected(initial_quality_idx);
404 let ctx_for_quality = ctx.clone();
405 grid_quality_row.connect_selected_notify(move |row| {
406 let value = match row.selected() {
407 1 => "thumbnail",
408 2 => "preview",
409 3 => "fullsize",
410 _ => "auto",
411 };
412 let mut cfg = ctx_for_quality.config.write();
413 if cfg.data.library_grid_quality != value {
414 cfg.data.library_grid_quality = value.to_string();
415 cfg.save();
416 }
417 });
418
419 raw_cache_row.set_sensitive(raw_full_decode_row.is_active());
420
421 let cache_row_ref = raw_cache_row.clone();
422 let ctx_for_raw_decode = ctx.clone();
423 raw_full_decode_row.connect_active_notify(move |row| {
424 let active = row.is_active();
425 cache_row_ref.set_sensitive(active);
426 crate::library::set_raw_full_decode(active);
427 let mut cfg = ctx_for_raw_decode.config.write();
428 if cfg.data.raw_full_decode != active {
429 cfg.data.raw_full_decode = active;
430 cfg.save();
431 }
432 });
433
434 let ctx_for_raw_cache = ctx.clone();
435 raw_cache_row.connect_active_notify(move |row| {
436 let active = row.is_active();
437 crate::library::set_raw_cache_enabled(active);
438 let mut cfg = ctx_for_raw_cache.config.write();
439 if cfg.data.raw_decode_cache_enabled != active {
440 cfg.data.raw_decode_cache_enabled = active;
441 cfg.save();
442 }
443 });
444
445 let pending_disk_cache_save: Rc<Cell<Option<glib::SourceId>>> = Rc::new(Cell::new(None));
446 let ctx_for_disk_cache = ctx.clone();
447 disk_cache_row.connect_value_notify(move |row| {
448 let new_value = row.value() as u32;
449 if let Some(id) = pending_disk_cache_save.take() {
450 id.remove();
451 }
452 let ctx_for_save = ctx_for_disk_cache.clone();
453 let pending = pending_disk_cache_save.clone();
454 let id = glib::timeout_add_local_once(Duration::from_millis(400), move || {
455 pending.set(None);
456 let mut cfg = ctx_for_save.config.write();
457 if cfg.data.cache_disk_cap_mb != new_value {
458 cfg.data.cache_disk_cap_mb = new_value;
459 cfg.save();
460 }
461 });
462 pending_disk_cache_save.set(Some(id));
463 });
464
465 if let Some(path) = ctx.config.read().data.download_target_path.as_deref() {
467 download_folder_row.set_subtitle(path);
468 download_clear_btn.set_visible(true);
469 }
470
471 let ctx_for_dl_change = ctx.clone();
472 let dl_row_for_change = download_folder_row.clone();
473 let dl_clear_for_change = download_clear_btn.clone();
474 download_change_btn.connect_clicked(clone!(
475 #[weak]
476 window,
477 move |_| {
478 let ctx = ctx_for_dl_change.clone();
479 let row = dl_row_for_change.clone();
480 let clear = dl_clear_for_change.clone();
481 let dialog = gtk::FileDialog::builder()
482 .title("Choose Download Folder")
483 .build();
484 dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |res| {
485 if let Some(path) = res.ok().and_then(|f| f.path()) {
486 let path_str = path.to_string_lossy().to_string();
487 row.set_subtitle(&path_str);
488 clear.set_visible(true);
489 let mut cfg = ctx.config.write();
490 cfg.data.download_target_path = Some(path_str);
491 cfg.save();
492 }
493 });
494 }
495 ));
496
497 let ctx_for_dl_clear = ctx.clone();
498 download_clear_btn.connect_clicked(clone!(
499 #[weak]
500 download_folder_row,
501 move |btn| {
502 download_folder_row.set_subtitle("Not set");
503 btn.set_visible(false);
504 let mut cfg = ctx_for_dl_clear.config.write();
505 cfg.data.download_target_path = None;
506 cfg.save();
507 }
508 ));
509
510 let folders_group = adw::PreferencesGroup::builder()
512 .title("Watch Folders")
513 .description("Pick folders to sync.")
514 .build();
515 settings_page.add(&folders_group);
516
517 let startup_state = Rc::new(RefCell::new(config.data.run_on_startup));
518 let background_sync_state = Rc::new(RefCell::new(config.data.background_sync_enabled));
519 let apply_in_flight = Rc::new(Cell::new(false));
520 let tracked_rows = Rc::new(RefCell::new(Vec::<FolderRowData>::new()));
521 let albums: Rc<RefCell<Vec<(String, String)>>> = Rc::new(RefCell::new(Vec::new()));
522
523 let apply_settings = Rc::new(clone!(
524 #[weak]
525 window,
526 #[weak]
527 startup_row,
528 #[weak]
529 internal_switch,
530 #[weak]
531 external_switch,
532 #[weak]
533 internal_entry,
534 #[weak]
535 external_entry,
536 #[weak]
537 api_key_entry,
538 #[weak]
539 metered_row,
540 #[weak]
541 battery_row,
542 #[weak]
543 notifications_row,
544 #[weak]
545 library_view_row,
546 #[weak]
547 preview_full_row,
548 #[weak]
549 raw_full_decode_row,
550 #[weak]
551 raw_cache_row,
552 #[weak]
553 disk_cache_row,
554 #[weak]
555 concurrency_row,
556 #[weak]
557 quiet_hours_row,
558 #[weak]
559 quiet_start_row,
560 #[weak]
561 quiet_end_row,
562 #[weak]
563 catchup_row,
564 #[weak]
565 background_sync_row,
566 #[weak]
567 xmp_sidecar_row,
568 #[weak]
569 border_width_row,
570 #[weak]
571 border_color_btn,
572 #[strong]
573 tracked_rows,
574 #[strong]
575 albums,
576 #[strong]
577 shared_state,
578 #[strong]
579 api_client,
580 #[strong]
581 queue_manager,
582 #[strong]
583 monitor_handle,
584 #[strong]
585 live_watch_paths,
586 #[strong]
587 sync_now_tx,
588 #[strong]
589 startup_state,
590 #[strong]
591 background_sync_state,
592 #[strong]
593 apply_in_flight,
594 #[strong]
595 shared_config,
596 move |include_connectivity: bool, show_success_ack: bool| {
597 if apply_in_flight.get() {
598 return;
599 }
600 apply_in_flight.set(true);
601
602 let (
603 mut internal_url_enabled,
604 mut external_url_enabled,
605 mut internal_url,
606 mut external_url,
607 mut api_key,
608 ) = {
609 let existing = shared_config.read();
610 (
611 existing.data.internal_url_enabled,
612 existing.data.external_url_enabled,
613 existing.data.internal_url.clone(),
614 existing.data.external_url.clone(),
615 existing.get_api_key().unwrap_or_default(),
616 )
617 };
618 if include_connectivity {
619 internal_url_enabled = internal_switch.is_active();
620 external_url_enabled = external_switch.is_active();
621 internal_url = internal_entry.text().to_string();
622 external_url = external_entry.text().to_string();
623 api_key = api_key_entry.text().to_string();
624
625 if internal_url_enabled
627 && !internal_url.trim().is_empty()
628 && let Err(err) = crate::sanitize::validate_http_url(&internal_url)
629 {
630 show_alert(&window, "Invalid Internal URL", &err);
631 apply_in_flight.set(false);
632 return;
633 }
634 if external_url_enabled
635 && !external_url.trim().is_empty()
636 && let Err(err) = crate::sanitize::validate_http_url(&external_url)
637 {
638 show_alert(&window, "Invalid External URL", &err);
639 apply_in_flight.set(false);
640 return;
641 }
642 }
643 let run_on_startup = startup_row.is_active();
644 let pause_on_metered_network = metered_row.is_active();
645 let pause_on_battery_power = battery_row.is_active();
646 let notifications_enabled = notifications_row.is_active();
647 let library_view_enabled = library_view_row.is_active();
648 let library_preview_full_resolution = preview_full_row.is_active();
649 let raw_decode_cache_enabled = raw_cache_row.is_active();
650 let raw_full_decode = raw_full_decode_row.is_active();
651 let cache_disk_cap_mb = disk_cache_row.value() as u32;
652 let upload_concurrency = concurrency_row.value() as u8;
653 let quiet_hours_enabled = quiet_hours_row.is_active();
654 let quiet_hours_start = quiet_hours_enabled.then(|| quiet_start_row.value() as u8);
655 let quiet_hours_end = quiet_hours_enabled.then(|| quiet_end_row.value() as u8);
656 let background_sync_enabled = background_sync_row.is_active();
657 let upload_xmp_sidecars = xmp_sidecar_row.is_active();
658 let grid_border_width = border_width_row.value() as f32;
659 let grid_border_color = border_color_btn.rgba().to_string();
660 let catchup_mode = match catchup_row.selected() {
661 1 => StartupCatchupMode::RecentOnly,
662 2 => StartupCatchupMode::NewFilesOnly,
663 _ => StartupCatchupMode::Full,
664 };
665
666 let mut watch_paths = Vec::new();
667 let albums_map: HashMap<String, String> = albums.borrow().iter().cloned().collect();
668 for row_data in tracked_rows.borrow().iter() {
669 let folder = row_data.path.clone();
670 let rules = row_data.rules.borrow().clone();
671 let has_rules = rules != FolderRules::default();
672 let album_name = row_data.album_name.borrow().clone();
673
674 let is_default = album_name.is_empty() || album_name == DEFAULT_ALBUM_LABEL;
675 let resolved_album_name = if is_default {
676 Path::new(&folder)
677 .file_name()
678 .and_then(|n| n.to_str())
679 .map(|s| s.to_string())
680 } else {
681 Some(album_name)
682 };
683
684 if is_default && !has_rules && resolved_album_name.is_none() {
685 watch_paths.push(WatchPathEntry::Simple(folder));
686 } else {
687 let album_id = resolved_album_name
688 .as_ref()
689 .and_then(|n| albums_map.get(n).cloned());
690 watch_paths.push(WatchPathEntry::WithConfig {
691 path: folder,
692 album_id,
693 album_name: resolved_album_name,
694 rules,
695 });
696 }
697 }
698
699 let runtime_internal_url = if internal_url_enabled {
700 internal_url.clone()
701 } else {
702 String::new()
703 };
704 let runtime_external_url = if external_url_enabled {
705 external_url.clone()
706 } else {
707 String::new()
708 };
709
710 let previous_startup = *startup_state.borrow();
711 let previous_background_sync = *background_sync_state.borrow();
712
713 glib::MainContext::default().spawn_local(clone!(
714 #[weak]
715 window,
716 #[weak]
717 startup_row,
718 #[strong]
719 shared_state,
720 #[strong]
721 api_client,
722 #[strong]
723 queue_manager,
724 #[strong]
725 monitor_handle,
726 #[strong]
727 albums,
728 #[strong]
729 live_watch_paths,
730 #[strong]
731 sync_now_tx,
732 #[strong]
733 startup_state,
734 #[strong]
735 background_sync_state,
736 #[strong]
737 apply_in_flight,
738 #[strong]
739 shared_config,
740 async move {
741 if run_on_startup != previous_startup {
742 match autostart::apply(&window, run_on_startup).await {
743 Ok(granted) if granted == run_on_startup => {}
744 Ok(_) => {
745 startup_row.set_active(previous_startup);
746 apply_in_flight.set(false);
747
748 show_alert(
749 &window,
750 "Startup Permission Needed",
751 "Mimick was not allowed to start automatically at login.",
752 );
753 return;
754 }
755 Err(err) => {
756 startup_row.set_active(previous_startup);
757 apply_in_flight.set(false);
758
759 show_alert(&window, "Could Not Update Startup Setting", &err);
760 return;
761 }
762 }
763 }
764
765 {
766 let mut new_config = shared_config.write();
767 new_config.data.internal_url_enabled = internal_url_enabled;
768 new_config.data.external_url_enabled = external_url_enabled;
769 new_config.data.internal_url = internal_url;
770 new_config.data.external_url = external_url;
771 new_config.data.watch_paths = watch_paths.clone();
772 new_config.data.run_on_startup = run_on_startup;
773 new_config.data.background_sync_enabled = background_sync_enabled;
774 new_config.data.pause_on_metered_network = pause_on_metered_network;
775 new_config.data.pause_on_battery_power = pause_on_battery_power;
776 new_config.data.notifications_enabled = notifications_enabled;
777 new_config.data.library_view_enabled = library_view_enabled;
778 new_config.data.library_preview_full_resolution =
779 library_preview_full_resolution;
780 new_config.data.raw_decode_cache_enabled = raw_decode_cache_enabled;
781 crate::library::set_raw_cache_enabled(raw_decode_cache_enabled);
782 new_config.data.raw_full_decode = raw_full_decode;
783 crate::library::set_raw_full_decode(raw_full_decode);
784 new_config.data.cache_disk_cap_mb = cache_disk_cap_mb;
785 new_config.data.startup_catchup_mode = catchup_mode;
786 new_config.data.upload_concurrency = upload_concurrency;
787 new_config.data.quiet_hours_start = quiet_hours_start;
788 new_config.data.quiet_hours_end = quiet_hours_end;
789 new_config.data.upload_xmp_sidecars = upload_xmp_sidecars;
790 new_config.data.grid_border_width = grid_border_width;
791 new_config.data.grid_border_color = grid_border_color.clone();
792
793 if include_connectivity
794 && !api_key.is_empty()
795 && let Err(detail) = new_config.set_api_key(&api_key)
796 {
797 apply_in_flight.set(false);
798
799 show_alert(&window, "Could Not Save API Key", &detail);
800 return;
801 }
802
803 if !new_config.save() {
804 apply_in_flight.set(false);
805
806 show_alert(
807 &window,
808 "Could Not Save Settings",
809 "Mimick could not write the updated configuration to disk.",
810 );
811 return;
812 }
813 }
814
815 *startup_state.borrow_mut() = run_on_startup;
816 *background_sync_state.borrow_mut() = background_sync_enabled;
817
818 api_client
819 .update_settings(
820 runtime_internal_url.clone(),
821 runtime_external_url.clone(),
822 api_key.clone(),
823 )
824 .await;
825
826 if include_connectivity && !api_key.is_empty() {
827 match api_client.get_all_albums().await {
828 Ok(fetched) => {
829 *albums.borrow_mut() = fetched;
830 }
831 Err(err) => {
832 log::warn!("Could not fetch albums after saving settings: {}", err);
833 }
834 }
835 }
836
837 queue_manager.set_worker_limit(upload_concurrency);
838 queue_manager.update_environment_policy(
839 crate::queue_manager::EnvironmentPolicy {
840 pause_on_metered_network,
841 pause_on_battery_power,
842 quiet_hours_start,
843 quiet_hours_end,
844 },
845 );
846
847 crate::notifications::set_enabled(notifications_enabled);
848
849 if previous_background_sync != background_sync_enabled {
850 let mut state = shared_state.lock();
851 if !background_sync_enabled && state.status != "uploading" && !state.paused
852 {
853 state.status = "idle".to_string();
854 state.pause_reason = None;
855 }
856 }
857
858 let monitor_paths = if background_sync_enabled {
859 watch_paths.clone()
860 } else {
861 Vec::new()
862 };
863 monitor_handle.replace_watch_paths(monitor_paths, background_sync_enabled);
864
865 *live_watch_paths.lock() = watch_paths.clone();
866
867 if background_sync_enabled
868 && previous_background_sync != background_sync_enabled
869 {
870 let _ = sync_now_tx.send(());
871 }
872
873 {
874 let mut state = shared_state.lock();
875 state.watched_folder_count = watch_paths.len();
876 let current_paths = watch_paths
877 .iter()
878 .map(|entry| entry.path().to_string())
879 .collect::<std::collections::HashSet<_>>();
880 state
881 .folder_statuses
882 .retain(|path, _| current_paths.contains(path));
883 }
884
885 apply_in_flight.set(false);
886 if show_success_ack {
887 show_alert(
888 &window,
889 "Settings Saved",
890 "Mimick saved the updated settings successfully.",
891 );
892 }
893 }
894 ));
895 }
896 ));
897
898 let auto_apply_settings: Rc<dyn Fn()> = Rc::new(clone!(
899 #[strong]
900 apply_settings,
901 move || {
902 (apply_settings)(false, false);
903 }
904 ));
905
906 let albums_ref = albums.clone();
910
911 let weak_win = window.downgrade();
917 let client = api_client.clone();
918
919 glib::MainContext::default().spawn_local(async move {
920 let fetched = client.get_all_albums().await.unwrap_or_default();
921
922 if weak_win.upgrade().is_none() {
925 log::debug!("Settings window closed during album fetch — discarding result.");
926 return;
927 }
928
929 *albums_ref.borrow_mut() = fetched.clone();
930
931 });
934
935 let folders_list = ListBox::builder()
937 .margin_top(12)
938 .selection_mode(gtk::SelectionMode::None)
939 .css_classes(vec!["boxed-list".to_string()])
940 .build();
941 folders_group.add(&folders_list);
942
943 let add_folder_btn = Button::builder().label("Add Folder").margin_top(12).build();
944 folders_group.add(&add_folder_btn);
945
946 let folder_default_catchup = config.data.startup_catchup_mode.clone();
947
948 for entry in &config.data.watch_paths {
950 add_folder_row(
951 &folders_list,
952 entry,
953 folder_default_catchup.clone(),
954 albums.clone(),
955 &tracked_rows,
956 auto_apply_settings.clone(),
957 );
958 }
959
960 let folders_list_clone = folders_list.clone();
961 let window_clone = window.clone();
962 let tracked_rows_clone = tracked_rows.clone();
963 let albums_clone = albums.clone();
964 let apply_settings_for_add = auto_apply_settings.clone();
965 let folder_default_catchup_for_add = folder_default_catchup.clone();
966
967 add_folder_btn.connect_clicked(move |_| {
968 let dialog = FileDialog::builder().title("Select Watch Folder").build();
969 let list_clone = folders_list_clone.clone();
970 let tracked_clone = tracked_rows_clone.clone();
971 let albums_ref = albums_clone.clone();
972 let apply_settings_for_add = apply_settings_for_add.clone();
973 let folder_default_catchup_for_add = folder_default_catchup_for_add.clone();
974
975 dialog.select_folder(
976 Some(&window_clone),
977 gtk::gio::Cancellable::NONE,
978 move |res| {
979 if let Ok(file) = res
980 && let Some(path) = file.path()
981 {
982 let path_str = path.to_string_lossy().to_string();
983 if tracked_clone.borrow().iter().any(|r| r.path == path_str) {
984 return;
985 }
986 add_folder_row(
987 &list_clone,
988 &WatchPathEntry::Simple(path_str),
989 folder_default_catchup_for_add.clone(),
990 albums_ref.clone(),
991 &tracked_clone,
992 apply_settings_for_add.clone(),
993 );
994 (apply_settings_for_add)();
995 }
996 },
997 );
998 });
999
1000 let actions_ui::ActionsWidgets {
1001 sync_now_btn,
1002 pause_btn,
1003 queue_btn,
1004 export_btn,
1005 clear_cache_btn,
1006 quit_btn,
1007 } = actions_ui::build_actions_group(&status_page, &settings_page);
1008
1009 pause_btn.set_label(if queue_manager.is_paused() {
1010 "Resume"
1011 } else {
1012 "Pause"
1013 });
1014
1015 let qm_for_inspector = queue_manager.clone();
1016 queue_btn.connect_clicked(clone!(
1017 #[weak]
1018 window,
1019 move |_| {
1020 show_queue_inspector(&window, qm_for_inspector.clone());
1021 }
1022 ));
1023
1024 let qm_for_pause = queue_manager.clone();
1025 pause_btn.connect_clicked(clone!(
1026 #[weak]
1027 pause_btn,
1028 move |_| {
1029 let paused = !qm_for_pause.is_paused();
1030 qm_for_pause.set_paused(paused, paused.then(|| "Paused by user".to_string()));
1031 pause_btn.set_label(if paused { "Resume" } else { "Pause" });
1032 }
1033 ));
1034
1035 sync_now_btn.connect_clicked(move |_| {
1036 let _ = sync_now_tx.send(());
1037 });
1038
1039 export_btn.connect_clicked(clone!(
1040 #[weak]
1041 window,
1042 #[strong]
1043 shared_state,
1044 #[strong]
1045 shared_config,
1046 move |_| {
1047 let dialog = FileDialog::builder()
1048 .title("Choose Diagnostics Export Folder")
1049 .build();
1050 let state = shared_state.clone();
1051 let config_ref = shared_config.clone();
1052 dialog.select_folder(
1053 Some(&window),
1054 gtk::gio::Cancellable::NONE,
1055 clone!(
1056 #[weak]
1057 window,
1058 move |res| {
1059 if let Ok(folder) = res
1060 && let Some(path) = folder.path()
1061 {
1062 let state_snapshot = state.lock().clone();
1063 let config_snapshot = config_ref.read().clone();
1064 glib::MainContext::default().spawn_local(clone!(
1065 #[weak]
1066 window,
1067 async move {
1068 let export_result = tokio::task::spawn_blocking(move || {
1069 diagnostics::export_bundle(
1070 &path,
1071 &state_snapshot,
1072 &config_snapshot,
1073 )
1074 })
1075 .await;
1076
1077 let (heading, body) = match export_result {
1078 Ok(Ok(bundle_dir)) => (
1079 "Diagnostics Exported",
1080 format!(
1081 "Saved diagnostics bundle to {}",
1082 crate::watch_path_display::display_watch_path_inline(
1083 &bundle_dir.parent().unwrap_or(&bundle_dir).to_string_lossy()
1084 )
1085 ),
1086 ),
1087 Ok(Err(err)) => (
1088 "Diagnostics Export Failed",
1089 format!("Could not write diagnostics bundle: {}", err),
1090 ),
1091 Err(err) => (
1092 "Diagnostics Export Failed",
1093 format!("Diagnostics task could not complete: {}", err),
1094 ),
1095 };
1096
1097 show_alert(&window, heading, &body);
1098 }
1099 ));
1100 }
1101 }
1102 ),
1103 );
1104 }
1105 ));
1106
1107 clear_cache_btn.connect_clicked(clone!(
1108 #[weak]
1109 window,
1110 move |_| {
1111 let _ = thumbnail_cache.clear();
1114 let window_for_done = window.clone();
1115 glib::MainContext::default().spawn_local(async move {
1116 let result = tokio::task::spawn_blocking(crate::cache_manager::clear_all_blocking)
1117 .await
1118 .map_err(|err| err.to_string())
1119 .and_then(|inner| inner);
1120 let (heading, body) = match result {
1121 Ok(()) => (
1122 "Cache Cleared",
1123 "Removed thumbnails, decoded RAW previews, EXIF, video, and preview caches."
1124 .to_string(),
1125 ),
1126 Err(err) => ("Could Not Clear Cache", err),
1127 };
1128 show_alert(&window_for_done, heading, &body);
1129 });
1130 }
1131 ));
1132
1133 quit_btn.connect_clicked(clone!(
1134 #[strong]
1135 app_clone,
1136 move |_| {
1137 app_clone.quit();
1138 }
1139 ));
1140
1141 save_btn.connect_clicked(clone!(
1142 #[strong]
1143 apply_settings,
1144 move |_| {
1145 (apply_settings)(true, true);
1146 }
1147 ));
1148
1149 internal_switch.set_active(config.data.internal_url_enabled);
1151 external_switch.set_active(config.data.external_url_enabled);
1152 internal_entry.set_text(&config.data.internal_url);
1153 external_entry.set_text(&config.data.external_url);
1154 internal_entry.set_sensitive(config.data.internal_url_enabled);
1155 external_entry.set_sensitive(config.data.external_url_enabled);
1156 startup_row.set_active(config.data.run_on_startup);
1157 metered_row.set_active(config.data.pause_on_metered_network);
1158 battery_row.set_active(config.data.pause_on_battery_power);
1159 background_sync_row.set_active(config.data.background_sync_enabled);
1160 notifications_row.set_active(config.data.notifications_enabled);
1161 library_view_row.set_active(config.data.library_view_enabled);
1162 preview_full_row.set_active(config.data.library_preview_full_resolution);
1163 raw_cache_row.set_active(config.data.raw_decode_cache_enabled);
1164 raw_full_decode_row.set_active(config.data.raw_full_decode);
1165 raw_cache_row.set_sensitive(config.data.raw_full_decode);
1166 if config.data.cache_disk_cap_mb > 0 {
1167 disk_cache_row.set_value(config.data.cache_disk_cap_mb as f64);
1168 }
1169 concurrency_row.set_value(config.data.upload_concurrency as f64);
1170 xmp_sidecar_row.set_active(config.data.upload_xmp_sidecars);
1171 border_width_row.set_value(config.data.grid_border_width as f64);
1172 if let Ok(color) = config.data.grid_border_color.parse::<gdk4::RGBA>() {
1173 border_color_btn.set_rgba(&color);
1174 }
1175 let qh_enabled = config.data.quiet_hours_start.is_some();
1176 quiet_hours_row.set_active(qh_enabled);
1177 quiet_start_row.set_value(config.data.quiet_hours_start.unwrap_or(22) as f64);
1178 quiet_end_row.set_value(config.data.quiet_hours_end.unwrap_or(7) as f64);
1179 quiet_start_row.set_sensitive(qh_enabled);
1180 quiet_end_row.set_sensitive(qh_enabled);
1181 catchup_row.set_selected(match config.data.startup_catchup_mode {
1182 StartupCatchupMode::Full => 0,
1183 StartupCatchupMode::RecentOnly => 1,
1184 StartupCatchupMode::NewFilesOnly => 2,
1185 });
1186
1187 if let Some(key) = config.get_api_key() {
1188 api_key_entry.set_text(&key);
1189 }
1190
1191 internal_switch.connect_active_notify(clone!(
1193 #[weak]
1194 external_switch,
1195 #[weak]
1196 internal_entry,
1197 #[weak]
1198 window,
1199 move |switch| {
1200 if !switch.is_active() && !external_switch.is_active() {
1201 switch.set_active(true);
1202 show_alert(
1203 &window,
1204 "Invalid Selection",
1205 "At least one URL (Internal or External) must be enabled.",
1206 );
1207 }
1208 internal_entry.set_sensitive(switch.is_active());
1209 }
1210 ));
1211
1212 external_switch.connect_active_notify(clone!(
1213 #[weak]
1214 internal_switch,
1215 #[weak]
1216 external_entry,
1217 #[weak]
1218 window,
1219 move |switch| {
1220 if !switch.is_active() && !internal_switch.is_active() {
1221 switch.set_active(true);
1222 show_alert(
1223 &window,
1224 "Invalid Selection",
1225 "At least one URL (Internal or External) must be enabled.",
1226 );
1227 }
1228 external_entry.set_sensitive(switch.is_active());
1229 }
1230 ));
1231
1232 startup_row.connect_active_notify(clone!(
1233 #[strong]
1234 auto_apply_settings,
1235 move |_| {
1236 (auto_apply_settings)();
1237 }
1238 ));
1239
1240 metered_row.connect_active_notify(clone!(
1241 #[strong]
1242 auto_apply_settings,
1243 move |_| {
1244 (auto_apply_settings)();
1245 }
1246 ));
1247
1248 battery_row.connect_active_notify(clone!(
1249 #[strong]
1250 auto_apply_settings,
1251 move |_| {
1252 (auto_apply_settings)();
1253 }
1254 ));
1255
1256 notifications_row.connect_active_notify(clone!(
1257 #[strong]
1258 auto_apply_settings,
1259 move |_| {
1260 (auto_apply_settings)();
1261 }
1262 ));
1263
1264 catchup_row.connect_selected_notify(clone!(
1265 #[strong]
1266 auto_apply_settings,
1267 move |_| {
1268 (auto_apply_settings)();
1269 }
1270 ));
1271
1272 concurrency_row.connect_value_notify(clone!(
1273 #[strong]
1274 auto_apply_settings,
1275 move |_| {
1276 (auto_apply_settings)();
1277 }
1278 ));
1279
1280 quiet_hours_row.connect_active_notify(clone!(
1281 #[strong]
1282 auto_apply_settings,
1283 move |_| {
1284 (auto_apply_settings)();
1285 }
1286 ));
1287
1288 quiet_start_row.connect_value_notify(clone!(
1289 #[strong]
1290 auto_apply_settings,
1291 move |_| {
1292 (auto_apply_settings)();
1293 }
1294 ));
1295
1296 quiet_end_row.connect_value_notify(clone!(
1297 #[strong]
1298 auto_apply_settings,
1299 move |_| {
1300 (auto_apply_settings)();
1301 }
1302 ));
1303
1304 background_sync_row.connect_active_notify(clone!(
1305 #[strong]
1306 auto_apply_settings,
1307 move |_| {
1308 (auto_apply_settings)();
1309 }
1310 ));
1311
1312 border_width_row.connect_value_notify(clone!(
1313 #[strong]
1314 auto_apply_settings,
1315 move |_| {
1316 (auto_apply_settings)();
1317 }
1318 ));
1319
1320 border_color_btn.connect_rgba_notify(clone!(
1321 #[strong]
1322 auto_apply_settings,
1323 move |_| {
1324 (auto_apply_settings)();
1325 }
1326 ));
1327
1328 window.connect_close_request(clone!(
1333 #[strong]
1334 app_clone,
1335 #[strong]
1336 ctx,
1337 move |_| {
1338 let bg_sync = ctx.config.read().data.background_sync_enabled;
1347 if !bg_sync && app_clone.windows().len() <= 1 {
1348 app_clone.quit();
1349 return glib::Propagation::Stop;
1350 }
1351 glib::Propagation::Proceed
1352 }
1353 ));
1354
1355 glib::timeout_add_local(
1358 Duration::from_millis(500),
1359 clone!(
1360 #[weak]
1361 status_row,
1362 #[weak]
1363 progress_bar,
1364 #[weak]
1365 route_row,
1366 #[weak]
1367 folders_row,
1368 #[weak]
1369 queue_health_row,
1370 #[weak]
1371 last_sync_row,
1372 #[weak]
1373 error_row,
1374 #[weak]
1375 pause_btn,
1376 #[strong]
1377 tracked_rows,
1378 #[upgrade_or]
1379 glib::ControlFlow::Break,
1380 move || {
1381 let (
1382 status,
1383 progress,
1384 processed,
1385 total,
1386 failed,
1387 current_file,
1388 paused,
1389 pause_reason,
1390 pending,
1391 route,
1392 watched_folder_count,
1393 last_successful_sync_at,
1394 last_error,
1395 last_error_guidance,
1396 folder_subtitles,
1397 ) = {
1398 let s = shared_state.lock();
1399 let folder_subtitles = tracked_rows
1400 .borrow()
1401 .iter()
1402 .map(|row_data| {
1403 let mut final_subtitle = row_data.base_subtitle.clone();
1404 if !final_subtitle.is_empty() {
1405 final_subtitle.push('\n');
1406 }
1407
1408 if let Some(folder_status) = s.folder_statuses.get(&row_data.path) {
1409 if let Some(err) = &folder_status.last_error {
1410 final_subtitle.push_str(&format!("Error: {}", err));
1411 } else {
1412 let mut txt =
1413 format!("Pending: {}", folder_status.pending_count);
1414 if let Some(t) = folder_status.last_sync_at {
1415 txt.push_str(&format!(
1416 " - Last Sync: {}",
1417 format_sync_age(Some(t))
1418 ));
1419 }
1420 final_subtitle.push_str(&txt);
1421 }
1422 } else {
1423 final_subtitle.push_str("Status: Idle");
1424 }
1425
1426 (row_data.action_row.clone(), final_subtitle)
1427 })
1428 .collect::<Vec<_>>();
1429
1430 (
1431 s.status.clone(),
1432 s.progress,
1433 s.processed_count,
1434 s.total_queued,
1435 s.failed_count,
1436 s.current_file.clone().unwrap_or_else(|| "...".to_string()),
1437 s.paused,
1438 s.pause_reason.clone(),
1439 s.queue_size,
1440 s.active_server_route.clone(),
1441 s.watched_folder_count,
1442 s.last_successful_sync_at,
1443 s.last_error.clone(),
1444 s.last_error_guidance.clone(),
1445 folder_subtitles,
1446 )
1447 }; pause_btn.set_label(if paused { "Resume" } else { "Pause" });
1450 route_row.set_subtitle(
1451 route
1452 .as_deref()
1453 .map(|route| match route {
1454 "LAN" => "Connected through LAN",
1455 "WAN" => "Connected through WAN",
1456 _ => "Connected through configured server",
1457 })
1458 .unwrap_or("Waiting for a successful connection check"),
1459 );
1460 folders_row.set_subtitle(&format!("{} configured", watched_folder_count));
1461 queue_health_row
1462 .set_subtitle(&format!("{} pending, {} waiting to retry", pending, failed));
1463 last_sync_row.set_subtitle(&format_sync_age(last_successful_sync_at));
1464 error_row.set_title(last_error.as_deref().unwrap_or("No recent errors"));
1465 error_row.set_subtitle(
1466 last_error_guidance
1467 .as_deref()
1468 .unwrap_or("Uploads are healthy."),
1469 );
1470 for (row, subtitle) in folder_subtitles {
1471 row.set_subtitle(&subtitle);
1472 }
1473
1474 if status == "paused" || paused {
1475 status_row.set_title("Paused");
1476 status_row.set_subtitle(
1477 pause_reason
1478 .as_deref()
1479 .unwrap_or("Sync has been temporarily paused."),
1480 );
1481 progress_bar.set_fraction((progress as f64) / 100.0);
1482 } else if status == "idle" {
1483 if failed > 0 {
1484 status_row.set_title("Offline / Waiting");
1485 status_row.set_subtitle(&format!("{} item(s) pending network", failed));
1486 progress_bar.set_fraction(1.0);
1487 } else {
1488 status_row.set_title("Idle");
1489 status_row.set_subtitle(&format!(
1490 "Successfully processed {} file(s)",
1491 processed.saturating_sub(failed)
1492 ));
1493 progress_bar.set_fraction(if processed > 0 { 1.0 } else { 0.0 });
1494 }
1495 } else if status == "uploading" {
1496 let filename = std::path::Path::new(¤t_file)
1497 .file_name()
1498 .map(|n| n.to_string_lossy())
1499 .unwrap_or_else(|| std::borrow::Cow::Borrowed("..."));
1500 status_row.set_title(&format!("Uploading ({}/{})", processed, total));
1501 status_row.set_subtitle(&filename);
1502 progress_bar.set_fraction((progress as f64) / 100.0);
1503 }
1504
1505 glib::ControlFlow::Continue
1506 }
1507 ),
1508 );
1509 window.present();
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514 use super::format_sync_age;
1515 use std::time::{SystemTime, UNIX_EPOCH};
1516
1517 #[test]
1518 fn test_format_sync_age_for_missing_timestamp() {
1519 assert_eq!(format_sync_age(None), "No successful sync yet");
1520 }
1521
1522 #[test]
1523 fn test_format_sync_age_for_recent_timestamp() {
1524 let now = SystemTime::now()
1525 .duration_since(UNIX_EPOCH)
1526 .unwrap_or_default()
1527 .as_secs_f64();
1528 assert_eq!(format_sync_age(Some(now - 30.0)), "Less than a minute ago");
1529 }
1530}