You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

571 lines
20 KiB

use std::{borrow::Cow, cell::RefCell, fmt, rc::Rc};
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{gio, glib, glib::clone};
use tracing::{debug, error, info, warn};
use crate::{
config,
intent::{SessionIntent, SessionIntentType},
prelude::*,
session::model::{Session, SessionState},
session_list::{FailedSession, SessionInfo, SessionList},
spawn,
system_settings::SystemSettings,
toast,
utils::{matrix::MatrixIdUri, BoundObjectWeakRef, LoadingState},
Window, GETTEXT_PACKAGE,
};
/// The key for the current session setting.
pub(crate) const SETTINGS_KEY_CURRENT_SESSION: &str = "current-session";
mod imp {
use std::cell::Cell;
use super::*;
#[derive(Debug)]
pub struct Application {
/// The application settings.
pub(super) settings: gio::Settings,
/// The system settings.
pub(super) system_settings: SystemSettings,
/// The list of logged-in sessions.
pub(super) session_list: SessionList,
intent_handler: BoundObjectWeakRef<glib::Object>,
last_network_state: Cell<NetworkState>,
}
impl Default for Application {
fn default() -> Self {
Self {
settings: gio::Settings::new(config::APP_ID),
system_settings: Default::default(),
session_list: Default::default(),
intent_handler: Default::default(),
last_network_state: Default::default(),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for Application {
const NAME: &'static str = "Application";
type Type = super::Application;
type ParentType = adw::Application;
}
impl ObjectImpl for Application {
fn constructed(&self) {
self.parent_constructed();
// Initialize actions and accelerators.
self.set_up_gactions();
self.set_up_accels();
// Listen to errors in the session list.
self.session_list.connect_error_notify(clone!(
#[weak(rename_to = imp)]
self,
move |session_list| {
if let Some(message) = session_list.error() {
let window = imp.present_main_window();
window.show_secret_error(&message);
}
}
));
// Restore the sessions.
spawn!(clone!(
#[weak(rename_to = session_list)]
self.session_list,
async move {
session_list.restore_sessions().await;
}
));
// Watch the network to log its state.
let network_monitor = gio::NetworkMonitor::default();
network_monitor.connect_network_changed(clone!(
#[weak(rename_to = imp)]
self,
move |network_monitor, _| {
let network_state = NetworkState::with_monitor(network_monitor);
if imp.last_network_state.get() == network_state {
return;
}
network_state.log();
imp.last_network_state.set(network_state);
}
));
}
}
impl ApplicationImpl for Application {
fn activate(&self) {
self.parent_activate();
debug!("Application::activate");
self.present_main_window();
}
fn startup(&self) {
self.parent_startup();
// Set icons for shell
gtk::Window::set_default_icon_name(crate::APP_ID);
}
fn open(&self, files: &[gio::File], _hint: &str) {
debug!("Application::open");
self.present_main_window();
if files.len() > 1 {
warn!("Trying to open several URIs, only the first one will be processed");
}
if let Some(uri) = files.first().map(FileExt::uri) {
self.process_uri(&uri);
} else {
debug!("No URI to open");
}
}
}
impl GtkApplicationImpl for Application {}
impl AdwApplicationImpl for Application {}
impl Application {
/// Get or create the main window and make sure it is visible.
///
/// Returns the main window.
fn present_main_window(&self) -> Window {
let window = if let Some(window) = self.obj().active_window().and_downcast() {
window
} else {
Window::new(&self.obj())
};
window.present();
window
}
/// Set up the application actions.
fn set_up_gactions(&self) {
self.obj().add_action_entries([
// Quit
gio::ActionEntry::builder("quit")
.activate(|obj: &super::Application, _, _| {
if let Some(window) = obj.active_window() {
// This is needed to trigger the delete event
// and saving the window state
window.close();
}
obj.quit();
})
.build(),
// About
gio::ActionEntry::builder("about")
.activate(|obj: &super::Application, _, _| {
obj.imp().show_about_dialog();
})
.build(),
// Show a room. This is the action triggered when clicking a notification about a
// message.
gio::ActionEntry::builder(SessionIntentType::ShowMatrixId.action_name())
.parameter_type(Some(&SessionIntentType::static_variant_type()))
.activate(|obj: &super::Application, _, variant| {
let Some((session_id, intent)) = SessionIntent::parse_with_session_id(
SessionIntentType::ShowMatrixId,
variant,
) else {
error!(
"Triggered `{}` action without the proper payload",
SessionIntentType::ShowMatrixId.action_name()
);
return;
};
obj.imp().process_session_intent(session_id, intent);
})
.build(),
// Show an identity verification. This is the action triggered when clicking a
// notification about a new verification.
gio::ActionEntry::builder(
SessionIntentType::ShowIdentityVerification.action_name(),
)
.parameter_type(Some(&SessionIntentType::static_variant_type()))
.activate(|obj: &super::Application, _, variant| {
let Some((session_id, intent)) = SessionIntent::parse_with_session_id(
SessionIntentType::ShowIdentityVerification,
variant,
) else {
error!(
"Triggered `{}` action without the proper payload",
SessionIntentType::ShowIdentityVerification.action_name()
);
return;
};
obj.imp().process_session_intent(session_id, intent);
})
.build(),
]);
}
/// Sets up keyboard shortcuts for application and window actions.
fn set_up_accels(&self) {
let obj = self.obj();
obj.set_accels_for_action("app.quit", &["<Control>q"]);
obj.set_accels_for_action("win.show-help-overlay", &["<Control>question"]);
obj.set_accels_for_action("window.close", &["<Control>w"]);
}
/// Show the dialog with information about the application.
fn show_about_dialog(&self) {
let dialog = adw::AboutDialog::builder()
.application_name("Fractal")
.application_icon(config::APP_ID)
.developer_name(gettext("The Fractal Team"))
.license_type(gtk::License::Gpl30)
.website("https://gitlab.gnome.org/World/fractal/")
.issue_url("https://gitlab.gnome.org/World/fractal/-/issues")
.support_url("https://matrix.to/#/#fractal:gnome.org")
.version(config::VERSION)
.copyright(gettext("© The Fractal Team"))
.developers([
"Alejandro Domínguez",
"Alexandre Franke",
"Bilal Elmoussaoui",
"Christopher Davis",
"Daniel García Moreno",
"Eisha Chen-yen-su",
"Jordan Petridis",
"Julian Sparber",
"Kévin Commaille",
"Saurav Sachidanand",
])
.designers(["Tobias Bernard"])
.translator_credits(gettext("translator-credits"))
.build();
// This can't be added via the builder
dialog.add_credit_section(Some(&gettext("Name by")), &["Regina Bíró"]);
// If the user wants our support room, try to open it ourselves.
dialog.connect_activate_link(clone!(
#[weak(rename_to = imp)]
self,
#[weak]
dialog,
#[upgrade_or]
false,
move |_, uri| {
if uri == "https://matrix.to/#/#fractal:gnome.org"
&& imp.session_list.has_session_ready()
{
imp.process_uri(uri);
dialog.close();
return true;
}
false
}
));
dialog.present(Some(&self.present_main_window()));
}
/// Process the given URI.
fn process_uri(&self, uri: &str) {
match MatrixIdUri::parse(uri) {
Ok(matrix_id) => {
self.select_session_for_intent(SessionIntent::ShowMatrixId(matrix_id));
}
Err(error) => warn!("Invalid Matrix URI: {error}"),
}
}
/// Select a session to handle the given intent as soon as possible.
fn select_session_for_intent(&self, intent: SessionIntent) {
debug!("Selecting session for intent {intent:?}");
// We only handle a single intent at time, the latest one.
self.intent_handler.disconnect_signals();
if self.session_list.state() == LoadingState::Ready {
match self.session_list.n_items() {
0 => {
warn!("Cannot open URI with no logged in session");
}
1 => {
let session = self
.session_list
.first()
.expect("There should be one session");
self.process_session_intent(session.session_id(), intent);
}
_ => {
spawn!(clone!(
#[weak(rename_to = obj)]
self,
async move {
obj.ask_session_for_intent(intent).await;
}
));
}
}
} else {
// Wait for the list to be ready.
let cell = Rc::new(RefCell::new(Some(intent)));
let handler = self.session_list.connect_state_notify(clone!(
#[weak(rename_to = imp)]
self,
#[strong]
cell,
move |session_list| {
if session_list.state() == LoadingState::Ready {
imp.intent_handler.disconnect_signals();
if let Some(intent) = cell.take() {
imp.select_session_for_intent(intent);
}
}
}
));
self.intent_handler
.set(self.session_list.upcast_ref(), vec![handler]);
}
}
/// Ask the user to choose a session to process the given Matrix ID URI.
///
/// The session list needs to be ready.
async fn ask_session_for_intent(&self, intent: SessionIntent) {
let main_window = self.present_main_window();
let Some(session_id) = main_window.ask_session().await else {
warn!("No session selected to show intent");
return;
};
self.process_session_intent(session_id, intent);
}
/// Process the given intent for the given session, as soon as the
/// session is ready.
fn process_session_intent(&self, session_id: String, intent: SessionIntent) {
debug!(session = session_id, "Processing session intent {intent:?}");
let Some(session_info) = self.session_list.get(&session_id) else {
warn!("Could not find session to process intent {intent:?}");
toast!(self.present_main_window(), gettext("Session not found"));
return;
};
if session_info.is::<FailedSession>() {
// We can't do anything, it should show an error screen.
warn!("Could not process intent {intent:?} for failed session");
} else if let Some(session) = session_info.downcast_ref::<Session>() {
if session.state() == SessionState::Ready {
self.present_main_window()
.process_session_intent(session.session_id(), intent);
} else {
// Wait for the session to be ready.
let cell = Rc::new(RefCell::new(Some((session_id, intent))));
let handler = session.connect_ready(clone!(
#[weak(rename_to = imp)]
self,
#[strong]
cell,
move |_| {
imp.intent_handler.disconnect_signals();
if let Some((session_id, intent)) = cell.take() {
imp.present_main_window()
.process_session_intent(&session_id, intent);
}
}
));
self.intent_handler.set(session.upcast_ref(), vec![handler]);
}
} else {
// Wait for the session to be a `Session`.
let cell = Rc::new(RefCell::new(Some((session_id, intent))));
let handler = self.session_list.connect_items_changed(clone!(
#[weak(rename_to = imp)]
self,
#[strong]
cell,
move |session_list, pos, _, added| {
if added == 0 {
return;
}
let Some(session_id) = cell
.borrow()
.as_ref()
.map(|(session_id, _)| session_id.clone())
else {
return;
};
for i in pos..pos + added {
let Some(session_info) =
session_list.item(i).and_downcast::<SessionInfo>()
else {
break;
};
if session_info.session_id() == session_id {
imp.intent_handler.disconnect_signals();
if let Some((session_id, intent)) = cell.take() {
imp.process_session_intent(session_id, intent);
}
break;
}
}
}
));
self.intent_handler
.set(self.session_list.upcast_ref(), vec![handler]);
}
}
}
}
glib::wrapper! {
/// The Fractal application.
pub struct Application(ObjectSubclass<imp::Application>)
@extends gio::Application, gtk::Application, adw::Application,
@implements gio::ActionMap, gio::ActionGroup;
}
impl Application {
pub fn new() -> Self {
glib::Object::builder()
.property("application-id", Some(config::APP_ID))
.property("flags", gio::ApplicationFlags::HANDLES_OPEN)
.property("resource-base-path", Some("/org/gnome/Fractal/"))
.build()
}
/// The application settings.
pub(crate) fn settings(&self) -> gio::Settings {
self.imp().settings.clone()
}
/// The system settings.
pub(crate) fn system_settings(&self) -> SystemSettings {
self.imp().system_settings.clone()
}
/// The list of logged-in sessions.
pub(crate) fn session_list(&self) -> &SessionList {
&self.imp().session_list
}
/// Run Fractal.
pub(crate) fn run(&self) {
info!("Fractal ({})", config::APP_ID);
info!("Version: {} ({})", config::VERSION, config::PROFILE);
info!("Datadir: {}", config::PKGDATADIR);
ApplicationExtManual::run(self);
}
}
impl Default for Application {
fn default() -> Self {
gio::Application::default()
.and_downcast::<Application>()
.expect("application should always be available")
}
}
/// The profile that was built.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) enum AppProfile {
/// A stable release.
Stable,
/// A beta release.
Beta,
/// A development release.
Devel,
}
impl AppProfile {
/// The string representation of this `AppProfile`.
pub(crate) fn as_str(&self) -> &str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
Self::Devel => "devel",
}
}
/// Whether this `AppProfile` should use the `.devel` CSS class on windows.
pub(crate) fn should_use_devel_class(self) -> bool {
matches!(self, Self::Devel)
}
/// The name of the directory where to put data for this profile.
pub(crate) fn dir_name(self) -> Cow<'static, str> {
match self {
AppProfile::Stable => Cow::Borrowed(GETTEXT_PACKAGE),
_ => Cow::Owned(format!("{GETTEXT_PACKAGE}-{self}")),
}
}
}
impl fmt::Display for AppProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The state of the network.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NetworkState {
/// The network is available.
Unavailable,
/// The network is available with the given connectivity.
Available(gio::NetworkConnectivity),
}
impl NetworkState {
/// Construct the network state with the given network monitor.
fn with_monitor(monitor: &gio::NetworkMonitor) -> Self {
if monitor.is_network_available() {
Self::Available(monitor.connectivity())
} else {
Self::Unavailable
}
}
/// Log this network state.
fn log(self) {
match self {
Self::Unavailable => {
info!("Network is unavailable");
}
Self::Available(connectivity) => {
info!("Network connectivity is {connectivity:?}");
}
}
}
}
impl Default for NetworkState {
fn default() -> Self {
Self::Available(gio::NetworkConnectivity::Full)
}
}