Skip to main content

fractal/
application.rs

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
20/// The key for the current session setting.
21pub(crate) const SETTINGS_KEY_CURRENT_SESSION: &str = "current-session";
22/// The name of the application.
23pub(crate) const APP_NAME: &str = "Fractal";
24/// The URL of the homepage of the application.
25pub(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        /// The application settings.
35        pub(super) settings: gio::Settings,
36        /// The system settings.
37        pub(super) system_settings: SystemSettings,
38        /// The list of logged-in sessions.
39        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            // Initialize actions and accelerators.
68            self.set_up_gactions();
69            self.set_up_accels();
70
71            // Listen to errors in the session list.
72            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            // Restore the sessions.
84            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            // Watch the network to log its state.
93            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            // Set icons for shell
124            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        /// Get or create the main window and make sure it is visible.
149        ///
150        /// Returns the main window.
151        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        /// Set up the application actions.
163        fn set_up_gactions(&self) {
164            self.obj().add_action_entries([
165                // Quit
166                gio::ActionEntry::builder("quit")
167                    .activate(|obj: &super::Application, _, _| {
168                        if let Some(window) = obj.active_window() {
169                            // This is needed to trigger the close request and save the window
170                            // state.
171                            window.close();
172                        }
173
174                        obj.quit();
175                    })
176                    .build(),
177                // About
178                gio::ActionEntry::builder("about")
179                    .activate(|obj: &super::Application, _, _| {
180                        obj.imp().show_about_dialog();
181                    })
182                    .build(),
183                // Show a room. This is the action triggered when clicking a notification about a
184                // message.
185                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                // Show an identity verification. This is the action triggered when clicking a
207                // notification about a new verification.
208                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        /// Sets up keyboard shortcuts for application and window actions.
233        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        /// Show the dialog with information about the application.
240        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            // This can't be added via the builder
268            dialog.add_credit_section(Some(&gettext("Name by")), &["Regina Bíró"]);
269
270            // If the user wants our support room, try to open it ourselves.
271            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        /// Process the given URI.
295        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        /// Select a session to handle the given intent as soon as possible.
306        fn select_session_for_intent(&self, intent: SessionIntent) {
307            debug!(?intent, "Selecting session for intent…");
308
309            // We only handle a single intent at time, the latest one.
310            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                // Wait for the list to be ready.
337                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        /// Ask the user to choose a session to process the given Matrix ID URI.
359        ///
360        /// The session list needs to be ready.
361        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        /// Process the given intent for the given session, as soon as the
374        /// session is ready.
375        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                // We can't do anything, it should show an error screen.
390                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                    // Wait for the session to be ready.
406                    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                // Wait for the session to be a `Session`.
430                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    /// The Fractal application.
475    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    /// The application settings.
490    pub(crate) fn settings(&self) -> gio::Settings {
491        self.imp().settings.clone()
492    }
493
494    /// The system settings.
495    pub(crate) fn system_settings(&self) -> SystemSettings {
496        self.imp().system_settings.clone()
497    }
498
499    /// The list of logged-in sessions.
500    pub(crate) fn session_list(&self) -> &SessionList {
501        &self.imp().session_list
502    }
503
504    /// Run Fractal.
505    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/// The profile that was built.
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
524#[allow(dead_code)]
525pub(crate) enum AppProfile {
526    /// A stable release.
527    Stable,
528    /// A beta release.
529    Beta,
530    /// A development release.
531    Devel,
532}
533
534impl AppProfile {
535    /// The string representation of this `AppProfile`.
536    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    /// Whether this `AppProfile` should use the `.devel` CSS class on windows.
545    pub(crate) fn should_use_devel_class(self) -> bool {
546        matches!(self, Self::Devel)
547    }
548
549    /// The name of the directory where to put data for this profile.
550    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/// The state of the network.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566enum NetworkState {
567    /// The network is available.
568    Unavailable,
569    /// The network is available with the given connectivity.
570    Available(gio::NetworkConnectivity),
571}
572
573impl NetworkState {
574    /// Construct the network state with the given network monitor.
575    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    /// Log this network state.
584    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}