1use std::{borrow::Cow, cell::RefCell, fmt, rc::Rc};
2
3use adw::{prelude::*, subclass::prelude::*};
4use gettextrs::gettext;
5use gtk::{gio, glib, glib::clone};
6use tracing::{debug, error, info, warn};
7
8use crate::{
9 GETTEXT_PACKAGE, Window, config,
10 intent::SessionIntent,
11 prelude::*,
12 session::{Session, SessionState},
13 session_list::{FailedSession, SessionInfo, SessionList},
14 spawn,
15 system_settings::SystemSettings,
16 toast,
17 utils::{BoundObjectWeakRef, LoadingState, matrix::MatrixIdUri},
18};
19
20pub(crate) const SETTINGS_KEY_CURRENT_SESSION: &str = "current-session";
22pub(crate) const APP_NAME: &str = "Fractal";
24pub(crate) const APP_HOMEPAGE_URL: &str = "https://gitlab.gnome.org/World/fractal/";
26
27mod imp {
28 use std::cell::Cell;
29
30 use super::*;
31
32 #[derive(Debug)]
33 pub struct Application {
34 pub(super) settings: gio::Settings,
36 pub(super) system_settings: SystemSettings,
38 pub(super) session_list: SessionList,
40 intent_handler: BoundObjectWeakRef<glib::Object>,
41 last_network_state: Cell<NetworkState>,
42 }
43
44 impl Default for Application {
45 fn default() -> Self {
46 Self {
47 settings: gio::Settings::new(config::APP_ID),
48 system_settings: Default::default(),
49 session_list: Default::default(),
50 intent_handler: Default::default(),
51 last_network_state: Default::default(),
52 }
53 }
54 }
55
56 #[glib::object_subclass]
57 impl ObjectSubclass for Application {
58 const NAME: &'static str = "Application";
59 type Type = super::Application;
60 type ParentType = adw::Application;
61 }
62
63 impl ObjectImpl for Application {
64 fn constructed(&self) {
65 self.parent_constructed();
66
67 self.set_up_gactions();
69 self.set_up_accels();
70
71 self.session_list.connect_error_notify(clone!(
73 #[weak(rename_to = imp)]
74 self,
75 move |session_list| {
76 if let Some(message) = session_list.error() {
77 let window = imp.present_main_window();
78 window.show_secret_error(&message);
79 }
80 }
81 ));
82
83 spawn!(clone!(
85 #[weak(rename_to = session_list)]
86 self.session_list,
87 async move {
88 session_list.restore_sessions().await;
89 }
90 ));
91
92 let network_monitor = gio::NetworkMonitor::default();
94 network_monitor.connect_network_changed(clone!(
95 #[weak(rename_to = imp)]
96 self,
97 move |network_monitor, _| {
98 let network_state = NetworkState::with_monitor(network_monitor);
99
100 if imp.last_network_state.get() == network_state {
101 return;
102 }
103
104 network_state.log();
105 imp.last_network_state.set(network_state);
106 }
107 ));
108 }
109 }
110
111 impl ApplicationImpl for Application {
112 fn activate(&self) {
113 self.parent_activate();
114
115 debug!("Application::activate");
116
117 self.present_main_window();
118 }
119
120 fn startup(&self) {
121 self.parent_startup();
122
123 gtk::Window::set_default_icon_name(crate::APP_ID);
125 }
126
127 fn open(&self, files: &[gio::File], _hint: &str) {
128 debug!("Application::open");
129
130 self.present_main_window();
131
132 if files.len() > 1 {
133 warn!("Trying to open several URIs, only the first one will be processed");
134 }
135
136 if let Some(uri) = files.first().map(FileExt::uri) {
137 self.process_uri(&uri);
138 } else {
139 debug!("No URI to open");
140 }
141 }
142 }
143
144 impl GtkApplicationImpl for Application {}
145 impl AdwApplicationImpl for Application {}
146
147 impl Application {
148 fn present_main_window(&self) -> Window {
152 let window = if let Some(window) = self.obj().active_window().and_downcast() {
153 window
154 } else {
155 Window::new(&self.obj())
156 };
157
158 window.present();
159 window
160 }
161
162 fn set_up_gactions(&self) {
164 self.obj().add_action_entries([
165 gio::ActionEntry::builder("quit")
167 .activate(|obj: &super::Application, _, _| {
168 if let Some(window) = obj.active_window() {
169 window.close();
172 }
173
174 obj.quit();
175 })
176 .build(),
177 gio::ActionEntry::builder("about")
179 .activate(|obj: &super::Application, _, _| {
180 obj.imp().show_about_dialog();
181 })
182 .build(),
183 gio::ActionEntry::builder(SessionIntent::SHOW_MATRIX_ID_ACTION_NAME)
186 .parameter_type(Some(&SessionIntent::static_variant_type()))
187 .activate(|obj: &super::Application, _, variant| {
188 debug!(
189 "`{}` action activated",
190 SessionIntent::SHOW_MATRIX_ID_APP_ACTION_NAME
191 );
192
193 let Some((session_id, intent)) =
194 variant.and_then(SessionIntent::show_matrix_id_from_variant)
195 else {
196 error!(
197 "Activated `{}` action without the proper payload",
198 SessionIntent::SHOW_MATRIX_ID_APP_ACTION_NAME
199 );
200 return;
201 };
202
203 obj.imp().process_session_intent(session_id, intent);
204 })
205 .build(),
206 gio::ActionEntry::builder(SessionIntent::SHOW_IDENTITY_VERIFICATION_ACTION_NAME)
209 .parameter_type(Some(&SessionIntent::static_variant_type()))
210 .activate(|obj: &super::Application, _, variant| {
211 debug!(
212 "`{}` action activated",
213 SessionIntent::SHOW_IDENTITY_VERIFICATION_APP_ACTION_NAME
214 );
215
216 let Some((session_id, intent)) = variant
217 .and_then(SessionIntent::show_identity_verification_from_variant)
218 else {
219 error!(
220 "Activated `{}` action without the proper payload",
221 SessionIntent::SHOW_IDENTITY_VERIFICATION_APP_ACTION_NAME
222 );
223 return;
224 };
225
226 obj.imp().process_session_intent(session_id, intent);
227 })
228 .build(),
229 ]);
230 }
231
232 fn set_up_accels(&self) {
234 let obj = self.obj();
235 obj.set_accels_for_action("app.quit", &["<Control>q"]);
236 obj.set_accels_for_action("window.close", &["<Control>w"]);
237 }
238
239 fn show_about_dialog(&self) {
241 let dialog = adw::AboutDialog::builder()
242 .application_name(APP_NAME)
243 .application_icon(config::APP_ID)
244 .developer_name(gettext("The Fractal Team"))
245 .license_type(gtk::License::Gpl30)
246 .website(APP_HOMEPAGE_URL)
247 .issue_url("https://gitlab.gnome.org/World/fractal/-/issues")
248 .support_url("https://matrix.to/#/#fractal:gnome.org")
249 .version(config::VERSION)
250 .copyright(gettext("© The Fractal Team"))
251 .developers([
252 "Alejandro Domínguez",
253 "Alexandre Franke",
254 "Bilal Elmoussaoui",
255 "Christopher Davis",
256 "Daniel García Moreno",
257 "Eisha Chen-yen-su",
258 "Jordan Petridis",
259 "Julian Sparber",
260 "Kévin Commaille",
261 "Saurav Sachidanand",
262 ])
263 .designers(["Tobias Bernard"])
264 .translator_credits(gettext("translator-credits"))
265 .build();
266
267 dialog.add_credit_section(Some(&gettext("Name by")), &["Regina Bíró"]);
269
270 dialog.connect_activate_link(clone!(
272 #[weak(rename_to = imp)]
273 self,
274 #[weak]
275 dialog,
276 #[upgrade_or]
277 false,
278 move |_, uri| {
279 if uri == "https://matrix.to/#/#fractal:gnome.org"
280 && imp.session_list.has_session_ready()
281 {
282 imp.process_uri(uri);
283 dialog.close();
284 return true;
285 }
286
287 false
288 }
289 ));
290
291 dialog.present(Some(&self.present_main_window()));
292 }
293
294 fn process_uri(&self, uri: &str) {
296 debug!(uri, "Processing URI…");
297 match MatrixIdUri::parse(uri) {
298 Ok(matrix_id) => {
299 self.select_session_for_intent(SessionIntent::ShowMatrixId(matrix_id));
300 }
301 Err(error) => warn!("Invalid Matrix URI: {error}"),
302 }
303 }
304
305 fn select_session_for_intent(&self, intent: SessionIntent) {
307 debug!(?intent, "Selecting session for intent…");
308
309 self.intent_handler.disconnect_signals();
311
312 if self.session_list.state() == LoadingState::Ready {
313 match self.session_list.n_items() {
314 0 => {
315 warn!("Cannot process intent with no logged in session");
316 }
317 1 => {
318 let session = self
319 .session_list
320 .first()
321 .expect("there should be one session");
322 self.process_session_intent(session.session_id(), intent);
323 }
324 _ => {
325 spawn!(clone!(
326 #[weak(rename_to = imp)]
327 self,
328 async move {
329 imp.ask_session_for_intent(intent).await;
330 }
331 ));
332 }
333 }
334 } else {
335 debug!(?intent, "Session list is not ready, queuing intent…");
336 let cell = Rc::new(RefCell::new(Some(intent)));
338 let handler = self.session_list.connect_state_notify(clone!(
339 #[weak(rename_to = imp)]
340 self,
341 #[strong]
342 cell,
343 move |session_list| {
344 if session_list.state() == LoadingState::Ready {
345 imp.intent_handler.disconnect_signals();
346
347 if let Some(intent) = cell.take() {
348 imp.select_session_for_intent(intent);
349 }
350 }
351 }
352 ));
353 self.intent_handler
354 .set(self.session_list.upcast_ref(), vec![handler]);
355 }
356 }
357
358 async fn ask_session_for_intent(&self, intent: SessionIntent) {
362 debug!(?intent, "Asking to select a session to process intent…");
363 let main_window = self.present_main_window();
364
365 let Some(session_id) = main_window.ask_session().await else {
366 warn!("No session selected to show intent");
367 return;
368 };
369
370 self.process_session_intent(session_id, intent);
371 }
372
373 fn process_session_intent(&self, session_id: String, intent: SessionIntent) {
376 let Some(session_info) = self.session_list.get(&session_id) else {
377 warn!(
378 session = session_id,
379 ?intent,
380 "Could not find session to process intent"
381 );
382 toast!(self.present_main_window(), gettext("Session not found"));
383 return;
384 };
385
386 debug!(session = session_id, ?intent, "Processing session intent…");
387
388 if session_info.is::<FailedSession>() {
389 warn!(
391 session = session_id,
392 ?intent,
393 "Could not process intent for failed session"
394 );
395 } else if let Some(session) = session_info.downcast_ref::<Session>() {
396 if session.state() == SessionState::Ready {
397 self.present_main_window()
398 .process_session_intent(session.session_id(), intent);
399 } else {
400 debug!(
401 session = session_id,
402 ?intent,
403 "Session is not ready, queuing intent…"
404 );
405 let cell = Rc::new(RefCell::new(Some((session_id, intent))));
407 let handler = session.connect_ready(clone!(
408 #[weak(rename_to = imp)]
409 self,
410 #[strong]
411 cell,
412 move |_| {
413 imp.intent_handler.disconnect_signals();
414
415 if let Some((session_id, intent)) = cell.take() {
416 imp.present_main_window()
417 .process_session_intent(&session_id, intent);
418 }
419 }
420 ));
421 self.intent_handler.set(session.upcast_ref(), vec![handler]);
422 }
423 } else {
424 debug!(
425 session = session_id,
426 ?intent,
427 "Session is still loading, queuing intent…"
428 );
429 let cell = Rc::new(RefCell::new(Some((session_id, intent))));
431 let handler = self.session_list.connect_items_changed(clone!(
432 #[weak(rename_to = imp)]
433 self,
434 #[strong]
435 cell,
436 move |session_list, pos, _, added| {
437 if added == 0 {
438 return;
439 }
440 let Some(session_id) = cell
441 .borrow()
442 .as_ref()
443 .map(|(session_id, _)| session_id.clone())
444 else {
445 return;
446 };
447
448 for i in pos..pos + added {
449 let Some(session_info) =
450 session_list.item(i).and_downcast::<SessionInfo>()
451 else {
452 break;
453 };
454
455 if session_info.session_id() == session_id {
456 imp.intent_handler.disconnect_signals();
457
458 if let Some((session_id, intent)) = cell.take() {
459 imp.process_session_intent(session_id, intent);
460 }
461 break;
462 }
463 }
464 }
465 ));
466 self.intent_handler
467 .set(self.session_list.upcast_ref(), vec![handler]);
468 }
469 }
470 }
471}
472
473glib::wrapper! {
474 pub struct Application(ObjectSubclass<imp::Application>)
476 @extends gio::Application, gtk::Application, adw::Application,
477 @implements gio::ActionMap, gio::ActionGroup;
478}
479
480impl Application {
481 pub fn new() -> Self {
482 glib::Object::builder()
483 .property("application-id", Some(config::APP_ID))
484 .property("flags", gio::ApplicationFlags::HANDLES_OPEN)
485 .property("resource-base-path", Some("/org/gnome/Fractal/"))
486 .build()
487 }
488
489 pub(crate) fn settings(&self) -> gio::Settings {
491 self.imp().settings.clone()
492 }
493
494 pub(crate) fn system_settings(&self) -> SystemSettings {
496 self.imp().system_settings.clone()
497 }
498
499 pub(crate) fn session_list(&self) -> &SessionList {
501 &self.imp().session_list
502 }
503
504 pub(crate) fn run(&self) {
506 info!("Fractal ({})", config::APP_ID);
507 info!("Version: {} ({})", config::VERSION, config::PROFILE);
508 info!("Datadir: {}", config::PKGDATADIR);
509
510 ApplicationExtManual::run(self);
511 }
512}
513
514impl Default for Application {
515 fn default() -> Self {
516 gio::Application::default()
517 .and_downcast::<Application>()
518 .expect("application should always be available")
519 }
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524#[allow(dead_code)]
525pub(crate) enum AppProfile {
526 Stable,
528 Beta,
530 Devel,
532}
533
534impl AppProfile {
535 pub(crate) fn as_str(&self) -> &str {
537 match self {
538 Self::Stable => "stable",
539 Self::Beta => "beta",
540 Self::Devel => "devel",
541 }
542 }
543
544 pub(crate) fn should_use_devel_class(self) -> bool {
546 matches!(self, Self::Devel)
547 }
548
549 pub(crate) fn dir_name(self) -> Cow<'static, str> {
551 match self {
552 AppProfile::Stable => Cow::Borrowed(GETTEXT_PACKAGE),
553 _ => Cow::Owned(format!("{GETTEXT_PACKAGE}-{self}")),
554 }
555 }
556}
557
558impl fmt::Display for AppProfile {
559 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560 f.write_str(self.as_str())
561 }
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566enum NetworkState {
567 Unavailable,
569 Available(gio::NetworkConnectivity),
571}
572
573impl NetworkState {
574 fn with_monitor(monitor: &gio::NetworkMonitor) -> Self {
576 if monitor.is_network_available() {
577 Self::Available(monitor.connectivity())
578 } else {
579 Self::Unavailable
580 }
581 }
582
583 fn log(self) {
585 match self {
586 Self::Unavailable => {
587 info!("Network is unavailable");
588 }
589 Self::Available(connectivity) => {
590 info!("Network connectivity is {connectivity:?}");
591 }
592 }
593 }
594}
595
596impl Default for NetworkState {
597 fn default() -> Self {
598 Self::Available(gio::NetworkConnectivity::Full)
599 }
600}