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
19pub const SETTINGS_KEY_CURRENT_SESSION: &str = "current-session";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum NetworkState {
25 Unavailable,
27 Available(gio::NetworkConnectivity),
29}
30
31impl NetworkState {
32 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 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 pub settings: gio::Settings,
69 pub system_settings: SystemSettings,
71 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 app.set_up_gactions();
103 app.set_up_accels();
104
105 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 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 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 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 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 pub fn settings(&self) -> gio::Settings {
213 self.imp().settings.clone()
214 }
215
216 pub fn system_settings(&self) -> SystemSettings {
218 self.imp().system_settings.clone()
219 }
220
221 pub fn session_list(&self) -> &SessionList {
223 &self.imp().session_list
224 }
225
226 fn set_up_gactions(&self) {
228 self.add_action_entries([
229 gio::ActionEntry::builder("quit")
231 .activate(|app: &Application, _, _| {
232 if let Some(window) = app.active_window() {
233 window.close();
236 }
237
238 app.quit();
239 })
240 .build(),
241 gio::ActionEntry::builder("about")
243 .activate(|app: &Application, _, _| {
244 app.show_about_dialog();
245 })
246 .build(),
247 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 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 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 dialog.add_credit_section(Some(&gettext("Name by")), &["Regina Bíró"]);
310
311 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 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 fn process_intent(&self, intent: impl Into<intent::AppIntent>) {
345 let intent = intent.into();
346 debug!("Processing intent {intent:?}");
347
348 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516#[allow(dead_code)]
517pub enum AppProfile {
518 Stable,
520 Beta,
522 Devel,
524}
525
526impl AppProfile {
527 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 pub fn should_use_devel_class(self) -> bool {
538 matches!(self, Self::Devel)
539 }
540
541 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}