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    config, intent,
10    session::model::{Session, SessionState},
11    session_list::{FailedSession, SessionInfo, SessionList},
12    spawn,
13    system_settings::SystemSettings,
14    toast,
15    utils::{matrix::MatrixIdUri, BoundObjectWeakRef, LoadingState},
16    Window, GETTEXT_PACKAGE,
17};
18
19/// The key for the current session setting.
20pub const SETTINGS_KEY_CURRENT_SESSION: &str = "current-session";
21
22/// The state of the network.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum NetworkState {
25    /// The network is available.
26    Unavailable,
27    /// The network is available with the given connectivity.
28    Available(gio::NetworkConnectivity),
29}
30
31impl NetworkState {
32    /// Construct the network state with the given network monitor.
33    fn with_monitor(monitor: &gio::NetworkMonitor) -> Self {
34        if monitor.is_network_available() {
35            Self::Available(monitor.connectivity())
36        } else {
37            Self::Unavailable
38        }
39    }
40
41    /// Log this network state.
42    fn log(self) {
43        match self {
44            Self::Unavailable => {
45                info!("Network is unavailable");
46            }
47            Self::Available(connectivity) => {
48                info!("Network connectivity is {connectivity:?}");
49            }
50        }
51    }
52}
53
54impl Default for NetworkState {
55    fn default() -> Self {
56        Self::Available(gio::NetworkConnectivity::Full)
57    }
58}
59
60mod imp {
61    use std::cell::Cell;
62
63    use super::*;
64
65    #[derive(Debug)]
66    pub struct Application {
67        /// The application settings.
68        pub settings: gio::Settings,
69        /// The system settings.
70        pub system_settings: SystemSettings,
71        /// The list of logged-in sessions.
72        pub session_list: SessionList,
73        pub intent_handler: BoundObjectWeakRef<glib::Object>,
74        last_network_state: Cell<NetworkState>,
75    }
76
77    impl Default for Application {
78        fn default() -> Self {
79            Self {
80                settings: gio::Settings::new(config::APP_ID),
81                system_settings: Default::default(),
82                session_list: Default::default(),
83                intent_handler: Default::default(),
84                last_network_state: Default::default(),
85            }
86        }
87    }
88
89    #[glib::object_subclass]
90    impl ObjectSubclass for Application {
91        const NAME: &'static str = "Application";
92        type Type = super::Application;
93        type ParentType = adw::Application;
94    }
95
96    impl ObjectImpl for Application {
97        fn constructed(&self) {
98            self.parent_constructed();
99            let app = self.obj();
100
101            // Initialize actions and accelerators.
102            app.set_up_gactions();
103            app.set_up_accels();
104
105            // Listen to errors in the session list.
106            self.session_list.connect_error_notify(clone!(
107                #[weak]
108                app,
109                move |session_list| {
110                    if let Some(message) = session_list.error() {
111                        let window = app.present_main_window();
112                        window.show_secret_error(&message);
113                    }
114                }
115            ));
116
117            // Restore the sessions.
118            spawn!(clone!(
119                #[weak(rename_to = session_list)]
120                self.session_list,
121                async move {
122                    session_list.restore_sessions().await;
123                }
124            ));
125
126            // Watch the network to log its state.
127            let network_monitor = gio::NetworkMonitor::default();
128            network_monitor.connect_network_changed(clone!(
129                #[weak(rename_to = imp)]
130                self,
131                move |network_monitor, _| {
132                    let network_state = NetworkState::with_monitor(network_monitor);
133
134                    if imp.last_network_state.get() == network_state {
135                        return;
136                    }
137
138                    network_state.log();
139                    imp.last_network_state.set(network_state);
140                }
141            ));
142        }
143    }
144
145    impl ApplicationImpl for Application {
146        fn activate(&self) {
147            self.parent_activate();
148
149            debug!("Application::activate");
150
151            self.obj().present_main_window();
152        }
153
154        fn startup(&self) {
155            self.parent_startup();
156
157            // Set icons for shell
158            gtk::Window::set_default_icon_name(crate::APP_ID);
159        }
160
161        fn open(&self, files: &[gio::File], _hint: &str) {
162            debug!("Application::open");
163
164            self.obj().present_main_window();
165
166            if files.len() > 1 {
167                warn!("Trying to open several URIs, only the first one will be processed");
168            }
169
170            if let Some(uri) = files.first().map(FileExt::uri) {
171                self.obj().process_uri(&uri);
172            } else {
173                debug!("No URI to open");
174            }
175        }
176    }
177
178    impl GtkApplicationImpl for Application {}
179    impl AdwApplicationImpl for Application {}
180}
181
182glib::wrapper! {
183    pub struct Application(ObjectSubclass<imp::Application>)
184        @extends gio::Application, gtk::Application, adw::Application,
185        @implements gio::ActionMap, gio::ActionGroup;
186}
187
188impl Application {
189    pub fn new() -> Self {
190        glib::Object::builder()
191            .property("application-id", Some(config::APP_ID))
192            .property("flags", gio::ApplicationFlags::HANDLES_OPEN)
193            .property("resource-base-path", Some("/org/gnome/Fractal/"))
194            .build()
195    }
196
197    /// Get or create the main window and make sure it is visible.
198    ///
199    /// Returns the main window.
200    fn present_main_window(&self) -> Window {
201        let window = if let Some(window) = self.active_window().and_downcast() {
202            window
203        } else {
204            Window::new(self)
205        };
206
207        window.present();
208        window
209    }
210
211    /// The application settings.
212    pub fn settings(&self) -> gio::Settings {
213        self.imp().settings.clone()
214    }
215
216    /// The system settings.
217    pub fn system_settings(&self) -> SystemSettings {
218        self.imp().system_settings.clone()
219    }
220
221    /// The list of logged-in sessions.
222    pub fn session_list(&self) -> &SessionList {
223        &self.imp().session_list
224    }
225
226    /// Set up the application actions.
227    fn set_up_gactions(&self) {
228        self.add_action_entries([
229            // Quit
230            gio::ActionEntry::builder("quit")
231                .activate(|app: &Application, _, _| {
232                    if let Some(window) = app.active_window() {
233                        // This is needed to trigger the delete event
234                        // and saving the window state
235                        window.close();
236                    }
237
238                    app.quit();
239                })
240                .build(),
241            // About
242            gio::ActionEntry::builder("about")
243                .activate(|app: &Application, _, _| {
244                    app.show_about_dialog();
245                })
246                .build(),
247            // Show a room. This is the action triggered when clicking a notification about a message.
248            gio::ActionEntry::builder("show-room")
249                .parameter_type(Some(&intent::ShowRoomPayload::static_variant_type()))
250                .activate(|app: &Application, _, v| {
251                    let Some(payload) = v.and_then(glib::Variant::get::<intent::ShowRoomPayload>) else {
252                        error!("Triggered `show-room` action without the proper payload");
253                        return;
254                    };
255
256                    app.process_intent(intent::SessionIntent::ShowRoom(payload));
257                })
258                .build(),
259            // Show an identity verification. This is the action triggered when clicking a notification about a new verification.
260            gio::ActionEntry::builder("show-identity-verification")
261                .parameter_type(Some(&intent::ShowIdentityVerificationPayload::static_variant_type()))
262                .activate(|app: &Application, _, v| {
263                    let Some(payload) = v.and_then(glib::Variant::get::<intent::ShowIdentityVerificationPayload>) else {
264                        error!("Triggered `show-identity-verification` action without the proper payload");
265                        return;
266                    };
267
268                    app.process_intent(intent::SessionIntent::ShowIdentityVerification(payload));
269                })
270                .build(),
271        ]);
272    }
273
274    /// Sets up keyboard shortcuts for application and window actions.
275    fn set_up_accels(&self) {
276        self.set_accels_for_action("app.quit", &["<Control>q"]);
277        self.set_accels_for_action("win.show-help-overlay", &["<Control>question"]);
278        self.set_accels_for_action("window.close", &["<Control>w"]);
279    }
280
281    fn show_about_dialog(&self) {
282        let dialog = adw::AboutDialog::builder()
283            .application_name("Fractal")
284            .application_icon(config::APP_ID)
285            .developer_name(gettext("The Fractal Team"))
286            .license_type(gtk::License::Gpl30)
287            .website("https://gitlab.gnome.org/World/fractal/")
288            .issue_url("https://gitlab.gnome.org/World/fractal/-/issues")
289            .support_url("https://matrix.to/#/#fractal:gnome.org")
290            .version(config::VERSION)
291            .copyright(gettext("© The Fractal Team"))
292            .developers(vec![
293                "Alejandro Domínguez".to_string(),
294                "Alexandre Franke".to_string(),
295                "Bilal Elmoussaoui".to_string(),
296                "Christopher Davis".to_string(),
297                "Daniel García Moreno".to_string(),
298                "Eisha Chen-yen-su".to_string(),
299                "Jordan Petridis".to_string(),
300                "Julian Sparber".to_string(),
301                "Kévin Commaille".to_string(),
302                "Saurav Sachidanand".to_string(),
303            ])
304            .designers(vec!["Tobias Bernard".to_string()])
305            .translator_credits(gettext("translator-credits"))
306            .build();
307
308        // This can't be added via the builder
309        dialog.add_credit_section(Some(&gettext("Name by")), &["Regina Bíró"]);
310
311        // If the user wants our support room, try to open it ourselves.
312        dialog.connect_activate_link(clone!(
313            #[weak(rename_to = obj)]
314            self,
315            #[weak]
316            dialog,
317            #[upgrade_or]
318            false,
319            move |_, uri| {
320                if uri == "https://matrix.to/#/#fractal:gnome.org"
321                    && obj.session_list().has_session_ready()
322                {
323                    obj.process_uri(uri);
324                    dialog.close();
325                    return true;
326                }
327
328                false
329            }
330        ));
331
332        dialog.present(Some(&self.present_main_window()));
333    }
334
335    /// Process the given URI.
336    fn process_uri(&self, uri: &str) {
337        match MatrixIdUri::parse(uri) {
338            Ok(matrix_id) => self.process_intent(matrix_id),
339            Err(error) => warn!("Invalid Matrix URI: {error}"),
340        }
341    }
342
343    /// Process the given intent, as soon as possible.
344    fn process_intent(&self, intent: impl Into<intent::AppIntent>) {
345        let intent = intent.into();
346        debug!("Processing intent {intent:?}");
347
348        // We only handle a single intent at time, the latest one.
349        self.imp().intent_handler.disconnect_signals();
350
351        let session_list = self.session_list();
352
353        if session_list.state() == LoadingState::Ready {
354            match intent {
355                intent::AppIntent::WithSession(session_intent) => {
356                    self.process_session_intent(session_intent);
357                }
358                intent::AppIntent::ShowMatrixId(matrix_uri) => match session_list.n_items() {
359                    0 => {
360                        warn!("Cannot open URI with no logged in session");
361                    }
362                    1 => {
363                        let session = session_list.first().expect("There should be one session");
364                        let session_intent = intent::SessionIntent::with_matrix_uri(
365                            session.session_id(),
366                            matrix_uri,
367                        );
368                        self.process_session_intent(session_intent);
369                    }
370                    _ => {
371                        spawn!(clone!(
372                            #[weak(rename_to = obj)]
373                            self,
374                            async move {
375                                obj.choose_session_for_uri(matrix_uri).await;
376                            }
377                        ));
378                    }
379                },
380            }
381        } else {
382            // Wait for the list to be ready.
383            let cell = Rc::new(RefCell::new(Some(intent)));
384            let handler = session_list.connect_state_notify(clone!(
385                #[weak(rename_to = obj)]
386                self,
387                #[strong]
388                cell,
389                move |session_list| {
390                    if session_list.state() == LoadingState::Ready {
391                        obj.imp().intent_handler.disconnect_signals();
392
393                        if let Some(intent) = cell.take() {
394                            obj.process_intent(intent);
395                        }
396                    }
397                }
398            ));
399            self.imp()
400                .intent_handler
401                .set(session_list.upcast_ref(), vec![handler]);
402        }
403    }
404
405    /// Ask the user to choose a session to process the given Matrix ID URI.
406    ///
407    /// The session list needs to be ready.
408    async fn choose_session_for_uri(&self, matrix_uri: MatrixIdUri) {
409        let main_window = self.present_main_window();
410
411        let Some(session_id) = main_window.choose_session_for_uri().await else {
412            warn!("No session selected to show URI");
413            return;
414        };
415
416        let session_intent = intent::SessionIntent::with_matrix_uri(session_id, matrix_uri);
417        self.process_session_intent(session_intent);
418    }
419
420    /// Process the given for a session, as soon as the session is ready.
421    fn process_session_intent(&self, intent: intent::SessionIntent) {
422        let Some(session_info) = self.session_list().get(intent.session_id()) else {
423            warn!("Could not find session to process intent {intent:?}");
424            toast!(self.present_main_window(), gettext("Session not found"));
425            return;
426        };
427        if session_info.is::<FailedSession>() {
428            // We can't do anything, it should show an error screen.
429            warn!("Could not process intent {intent:?} for failed session");
430        } else if let Some(session) = session_info.downcast_ref::<Session>() {
431            if session.state() == SessionState::Ready {
432                self.present_main_window()
433                    .process_session_intent_ready(intent);
434            } else {
435                // Wait for the session to be ready.
436                let cell = Rc::new(RefCell::new(Some(intent)));
437                let handler = session.connect_ready(clone!(
438                    #[weak(rename_to = obj)]
439                    self,
440                    #[strong]
441                    cell,
442                    move |_| {
443                        obj.imp().intent_handler.disconnect_signals();
444
445                        if let Some(intent) = cell.take() {
446                            obj.present_main_window()
447                                .process_session_intent_ready(intent);
448                        }
449                    }
450                ));
451                self.imp()
452                    .intent_handler
453                    .set(session.upcast_ref(), vec![handler]);
454            }
455        } else {
456            // Wait for the session to be a `Session`.
457            let session_list = self.session_list();
458            let cell = Rc::new(RefCell::new(Some(intent)));
459            let handler = session_list.connect_items_changed(clone!(
460                #[weak(rename_to = obj)]
461                self,
462                #[strong]
463                cell,
464                move |session_list, pos, _, added| {
465                    if added == 0 {
466                        return;
467                    }
468                    let Some(session_id) =
469                        cell.borrow().as_ref().map(|i| i.session_id().to_owned())
470                    else {
471                        return;
472                    };
473
474                    for i in pos..pos + added {
475                        let Some(session_info) = session_list.item(i).and_downcast::<SessionInfo>()
476                        else {
477                            break;
478                        };
479
480                        if session_info.session_id() == session_id {
481                            obj.imp().intent_handler.disconnect_signals();
482
483                            if let Some(intent) = cell.take() {
484                                obj.process_session_intent(intent);
485                            }
486                            break;
487                        }
488                    }
489                }
490            ));
491            self.imp()
492                .intent_handler
493                .set(session_list.upcast_ref(), vec![handler]);
494        }
495    }
496
497    pub fn run(&self) {
498        info!("Fractal ({})", config::APP_ID);
499        info!("Version: {} ({})", config::VERSION, config::PROFILE);
500        info!("Datadir: {}", config::PKGDATADIR);
501
502        ApplicationExtManual::run(self);
503    }
504}
505
506impl Default for Application {
507    fn default() -> Self {
508        gio::Application::default()
509            .and_downcast::<Application>()
510            .unwrap()
511    }
512}
513
514/// The profile that was built.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516#[allow(dead_code)]
517pub enum AppProfile {
518    /// A stable release.
519    Stable,
520    /// A beta release.
521    Beta,
522    /// A development release.
523    Devel,
524}
525
526impl AppProfile {
527    /// The string representation of this `AppProfile`.
528    pub fn as_str(&self) -> &str {
529        match self {
530            Self::Stable => "stable",
531            Self::Beta => "beta",
532            Self::Devel => "devel",
533        }
534    }
535
536    /// Whether this `AppProfile` should use the `.devel` CSS class on windows.
537    pub fn should_use_devel_class(self) -> bool {
538        matches!(self, Self::Devel)
539    }
540
541    /// The name of the directory where to put data for this profile.
542    pub fn dir_name(self) -> Cow<'static, str> {
543        match self {
544            AppProfile::Stable => Cow::Borrowed(GETTEXT_PACKAGE),
545            _ => Cow::Owned(format!("{GETTEXT_PACKAGE}-{self}")),
546        }
547    }
548}
549
550impl fmt::Display for AppProfile {
551    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
552        f.write_str(self.as_str())
553    }
554}