diff --git a/INSTALL b/INSTALL index 44962ecb..da17adce 100644 --- a/INSTALL +++ b/INSTALL @@ -46,6 +46,7 @@ * webkit2gtk-4.0 * gcr-3 * enchant + * libunwind * messaging-menu (optional; enables support for Ubuntu Unity messaging menu) * unity (optional; enables support for Ubuntu Unity launcher) @@ -67,7 +68,7 @@ desktop-file-utils gnome-doc-utils libcanberra-devel libgee-devel \ glib2-devel gmime-devel gtk3-devel libnotify-devel sqlite-devel \ webkitgtk4-devel libsecret-devel libxml2-devel vala-tools \ - gcr-devel enchant-devel + gcr-devel enchant-devel libunwind-devel * Installing dependencies on Ubuntu/Debian @@ -86,7 +87,8 @@ cmake desktop-file-utils gnome-doc-utils libcanberra-dev \ libgee-0.8-dev libglib2.0-dev libgmime-2.6-dev libgtk-3-dev \ libsecret-1-dev libxml2-dev libnotify-dev libsqlite3-dev \ - libwebkit2gtk-4.0-dev libgcr-3-dev libenchant-dev + libwebkit2gtk-4.0-dev libgcr-3-dev libenchant-dev \ + libunwind-dev And for Ubuntu Unity integration: diff --git a/bindings/vapi/libunwind.vapi b/bindings/vapi/libunwind.vapi new file mode 100644 index 00000000..cb74e40a --- /dev/null +++ b/bindings/vapi/libunwind.vapi @@ -0,0 +1,91 @@ +/* + * Based on version from Sentry-GLib: https://github.com/arteymix/sentry-glib + * Courtesy of Guillaume Poirier-Morency + * + * Copyright (C) 1996 X Consortium + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE X CONSORTIUM BE LIABLE FOR + * ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * Except as contained in this notice, the name of the X Consortium + * shall not be used in advertising or otherwise to promote the sale, + * use or other dealings in this Software without prior written + * authorization from the X Consortium. + * + * X Window System is a trademark of X Consortium, Inc. + */ + +[CCode (cprefix = "UNW_", lower_case_cprefix = "unw_", cheader_filename = "libunwind.h")] +namespace Unwind +{ + + [CCode (cname = "unw_context_t")] + public struct Context + { + [CCode (cname = "unw_getcontext")] + public Context (); + } + + [CCode (cname = "unw_proc_info_t")] + public struct ProcInfo + { + void* start_ip; + void* end_ip; + void* lsda; + void* handler; + void* gp; + long flags; + int format; + } + + [CCode (cname = "unw_frame_regnum_t")] + public enum Reg + { + IP, + SP, + EH + } + + [CCode (cname = "unw_cursor_t", cprefix = "unw_")] + public struct Cursor + { + public Cursor.local (Context ctx); + public int get_proc_info (out ProcInfo pip); + public int get_proc_name (uint8[] bufp, out long offp = null); + public int get_reg (Reg reg, out void* valp); + public int step (); + } + + [CCode (cname = "unw_error_t", cprefix = "UNW_E", has_type_id = false)] + public enum Error + { + SUCCESS, + UNSPEC, + NOMEM, + BADREG, + READONLYREG, + STOPUNWIND, + INVALIDIP, + BADFRAME, + INVAL, + BADVERSION, + NOINFO + } + +} diff --git a/debian/control b/debian/control index 61ffd315..61db4e6f 100644 --- a/debian/control +++ b/debian/control @@ -23,6 +23,7 @@ Build-Depends: debhelper (>= 8), gnome-doc-utils, libgcr-3-dev (>= 3.10.1), libenchant-dev (>= 1.6.0) + libunwind8-dev (>= 1.1) Standards-Version: 3.8.3 Homepage: https://wiki.gnome.org/Apps/Geary @@ -45,6 +46,7 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, libgcr-base-3-1 (>= 3.10.1), libgcr-ui-3-1 (>= 3.10.1), libenchant1c2a (>= 1.6.0) + libunwind8 (>= 1.1) Description: Email application Geary is an email application built around conversations, for the GNOME 3 desktop. It allows you to read, find and send email with a diff --git a/po/POTFILES.in b/po/POTFILES.in index 62cb81d9..002f3205 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -31,6 +31,7 @@ src/client/components/folder-popover.vala src/client/components/icon-factory.vala src/client/components/main-toolbar.vala src/client/components/main-window.vala +src/client/components/main-window-info-bar.vala src/client/components/monitored-progress-bar.vala src/client/components/monitored-spinner.vala src/client/components/search-bar.vala @@ -128,6 +129,7 @@ src/engine/api/geary-folder.vala src/engine/api/geary-logging.vala src/engine/api/geary-named-flag.vala src/engine/api/geary-named-flags.vala +src/engine/api/geary-problem-report.vala src/engine/api/geary-progress-monitor.vala src/engine/api/geary-revokable.vala src/engine/api/geary-search-folder.vala @@ -414,6 +416,7 @@ ui/login.glade ui/main-toolbar.ui ui/main-toolbar-menus.ui ui/main-window.ui +ui/main-window-info-bar.ui ui/password-dialog.glade ui/preferences-dialog.ui ui/remove_confirm.glade diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ca97d352..ecfbcb09 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,6 +37,7 @@ engine/api/geary-folder-supports-remove.vala engine/api/geary-logging.vala engine/api/geary-named-flag.vala engine/api/geary-named-flags.vala +engine/api/geary-problem-report.vala engine/api/geary-progress-monitor.vala engine/api/geary-revokable.vala engine/api/geary-search-folder.vala @@ -345,6 +346,7 @@ client/components/folder-popover.vala client/components/icon-factory.vala client/components/main-toolbar.vala client/components/main-window.vala +client/components/main-window-info-bar.vala client/components/monitored-progress-bar.vala client/components/monitored-spinner.vala client/components/search-bar.vala @@ -513,6 +515,7 @@ pkg_check_modules(DEPS REQUIRED webkit2gtk-web-extension-4.0>=${TARGET_WEBKIT} javascriptcoregtk-4.0>=${TARGET_WEBKIT} enchant>=1.6 + libunwind-generic>=1.1 ${EXTRA_CLIENT_PKG_CONFIG} ) @@ -530,6 +533,7 @@ set(ENGINE_PACKAGES gio-2.0 glib-2.0 gmime-2.6 + libunwind javascriptcore-4.0 libxml-2.0 posix diff --git a/src/client/application/geary-controller.vala b/src/client/application/geary-controller.vala index 27b7a657..ea27f2db 100644 --- a/src/client/application/geary-controller.vala +++ b/src/client/application/geary-controller.vala @@ -869,44 +869,72 @@ public class GearyController : Geary.BaseObject { debug("Error updating stored passwords: %s", e.message); } } - - private void on_report_problem(Geary.Account account, Geary.Account.Problem problem, Error? err) { - debug("Reported problem: %s Error: %s", problem.to_string(), err != null ? err.message : "(N/A)"); - - switch (problem) { - case Geary.Account.Problem.DATABASE_FAILURE: - case Geary.Account.Problem.HOST_UNREACHABLE: - case Geary.Account.Problem.NETWORK_UNAVAILABLE: - // TODO - break; - - case Geary.Account.Problem.RECV_EMAIL_LOGIN_FAILED: - case Geary.Account.Problem.SEND_EMAIL_LOGIN_FAILED: - // At this point, we've prompted them for the password and - // they've hit cancel, so there's not much for us to do here. - close_account(account); - break; - case Geary.Account.Problem.SEND_EMAIL_DELIVERY_FAILURE: - handle_outbox_failure(StatusBar.Message.OUTBOX_SEND_FAILURE); - break; + private void report_problem(Geary.ProblemReport report) { + debug("Problem reported: %s", report.to_string()); - case Geary.Account.Problem.SEND_EMAIL_SAVE_FAILED: + if (!(report.error is IOError.CANCELLED)) { + if (report.problem_type == Geary.ProblemType.SEND_EMAIL_SAVE_FAILED) { handle_outbox_failure(StatusBar.Message.OUTBOX_SAVE_SENT_MAIL_FAILED); - break; - - case Geary.Account.Problem.CONNECTION_FAILURE: - ErrorDialog dialog = new ErrorDialog(main_window, - _("Error connecting to the server"), - _("Geary encountered an error while connecting to the server. Please try again in a few moments.")); - dialog.run(); - break; - - default: - assert_not_reached(); + } else { + MainWindowInfoBar info_bar = new MainWindowInfoBar.for_problem(report); + info_bar.retry.connect(on_retry_problem); + this.main_window.show_infobar(info_bar); + } } } - + + private void on_retry_problem(MainWindowInfoBar info_bar) { + Geary.ServiceProblemReport? service_report = + info_bar.report as Geary.ServiceProblemReport; + Error retry_err = null; + if (service_report != null) { + Geary.Account? account = null; + try { + account = this.application.engine.get_account_instance( + service_report.account + ); + } catch (Error err) { + debug("Error getting account for error retry: %s", err.message); + } + + if (account != null && account.is_open()) { + switch (service_report.service_type) { + case Geary.Service.IMAP: + account.start_incoming_client.begin((obj, ret) => { + try { + account.start_incoming_client.end(ret); + } catch (Error err) { + retry_err = err; + } + }); + break; + + case Geary.Service.SMTP: + account.start_outgoing_client.begin((obj, ret) => { + try { + account.start_outgoing_client.end(ret); + } catch (Error err) { + retry_err = err; + } + }); + break; + } + + if (retry_err != null) { + report_problem( + new Geary.ServiceProblemReport( + Geary.ProblemType.GENERIC_ERROR, + service_report.account, + service_report.service_type, + retry_err + ) + ); + } + } + } + } + private void handle_outbox_failure(StatusBar.Message message) { bool activate_message = false; try { @@ -949,7 +977,11 @@ public class GearyController : Geary.BaseObject { } } } - + + private void on_report_problem(Geary.Account account, Geary.ProblemReport problem) { + report_problem(problem); + } + private void on_account_email_removed(Geary.Folder folder, Gee.Collection ids) { if (folder.special_folder_type == Geary.SpecialFolderType.OUTBOX) { main_window.status_bar.deactivate_message(StatusBar.Message.OUTBOX_SEND_FAILURE); @@ -2820,5 +2852,5 @@ public class GearyController : Geary.BaseObject { }); } } -} +} diff --git a/src/client/components/main-window-info-bar.vala b/src/client/components/main-window-info-bar.vala new file mode 100644 index 00000000..985e0467 --- /dev/null +++ b/src/client/components/main-window-info-bar.vala @@ -0,0 +1,324 @@ +/* + * Copyright 2017 Michael Gratton + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** + * Displays application-wide or important account-related messages. + */ +[GtkTemplate (ui = "/org/gnome/Geary/main-window-info-bar.ui")] +public class MainWindowInfoBar : Gtk.InfoBar { + + + private enum ResponseType { COPY, DETAILS, RETRY; } + + /** If reporting a problem, returns the problem report else null. */ + public Geary.ProblemReport? report { get; private set; default = null; } + + /** Emitted when the user clicks the Retry button, if any. */ + public signal void retry(); + + + [GtkChild] + private Gtk.Label title; + + [GtkChild] + private Gtk.Label description; + + [GtkChild] + private Gtk.Grid problem_details; + + [GtkChild] + private Gtk.TextView detail_text; + + + public MainWindowInfoBar.for_problem(Geary.ProblemReport report) { + Gtk.MessageType type = Gtk.MessageType.WARNING; + string title = ""; + string descr = ""; + string? retry = null; + bool show_generic = false; + bool show_close = false; + + if (report is Geary.ServiceProblemReport) { + Geary.ServiceProblemReport service_report = (Geary.ServiceProblemReport) report; + Geary.Endpoint endpoint = service_report.endpoint; + string account = service_report.account.display_name; + string server = endpoint.remote_address.hostname; + + if (report.problem_type == Geary.ProblemType.CONNECTION_ERROR && + service_report.service_type == Geary.Service.IMAP) { + // Translators: String substitution is the account name + title = _("Problem connecting to incoming server for %s".printf(account)); + // Translators: String substitution is the server name + descr = _("Could not connect to %s, check your Internet access and the server name and try again").printf(server); + retry = _("Retry connecting now"); + + } else if (report.problem_type == Geary.ProblemType.CONNECTION_ERROR && + service_report.service_type == Geary.Service.SMTP) { + // Translators: String substitution is the account name + title = _("Problem connecting to outgoing server for %s".printf(account)); + // Translators: String substitution is the server name + descr = _("Could not connect to %s, check your Internet access and the server name and try again").printf(server); + retry = _("Try reconnecting now"); + retry = _("Retry connecting now"); + + } else if (report.problem_type == Geary.ProblemType.NETWORK_ERROR && + service_report.service_type == Geary.Service.IMAP) { + // Translators: String substitution is the account name + title = _("Problem with connection to incoming server for %s").printf(account); + // Translators: String substitution is the server name + descr = _("Network error talking to %s, check your Internet access try again").printf(server); + retry = _("Try reconnecting"); + + } else if (report.problem_type == Geary.ProblemType.NETWORK_ERROR && + service_report.service_type == Geary.Service.SMTP) { + // Translators: String substitution is the account name + title = _("Problem with connection to outgoing server for %s").printf(account); + // Translators: String substitution is the server name + descr = _("Network error talking to %s, check your Internet access try again").printf(server); + retry = _("Try reconnecting"); + + } else if (report.problem_type == Geary.ProblemType.SERVER_ERROR && + service_report.service_type == Geary.Service.IMAP) { + // Translators: String substitution is the account name + title = _("Problem communicating with incoming server for %s").printf(account); + // Translators: String substitution is the server name + descr = _("Geary did not understand a message from %s or vice versa, please file a bug report").printf(server); + retry = _("Try reconnecting"); + + } else if (report.problem_type == Geary.ProblemType.SERVER_ERROR && + service_report.service_type == Geary.Service.SMTP) { + title = _("Problem communicating with outgoing mail server"); + // Translators: First string substitution is the server + // name, second is the account name + descr = _("Could now communicate with %s for %s, server name and try again in a moment").printf(server, account); + retry = _("Try reconnecting"); + + } else if (report.problem_type == Geary.ProblemType.LOGIN_FAILED && + service_report.service_type == Geary.Service.IMAP) { + // Translators: String substitution is the account name + title = _("Incoming mail server password required for %s").printf(account); + descr = _("Messages cannot be received without the correct password."); + retry = _("Retry receiving email, you will be prompted for a password"); + + } else if (report.problem_type == Geary.ProblemType.LOGIN_FAILED && + service_report.service_type == Geary.Service.SMTP) { + // Translators: String substitution is the account name + title = _("Outgoing mail server password required for %s").printf(account); + descr = _("Messages cannot be sent without the correct password."); + retry = _("Retry sending queued messages, you will be prompted for a password"); + + } else if (report.problem_type == Geary.ProblemType.GENERIC_ERROR && + service_report.service_type == Geary.Service.IMAP) { + // Translators: String substitution is the account name + title = _("A problem occurred checking mail for %s").printf(account); + descr = _("Something went wrong, please file a bug report if the problem persists"); + retry = _("Try reconnecting"); + + } else if (report.problem_type == Geary.ProblemType.GENERIC_ERROR && + service_report.service_type == Geary.Service.SMTP) { + // Translators: String substitution is the account name + title = _("A problem occurred sending mail for %s").printf(account); + descr = _("Something went wrong, please file a bug report if the problem persists"); + retry = _("Retry sending queued messages"); + + } else { + debug("Un-handled service problem report: %s".printf(report.to_string())); + show_generic = true; + } + } else if (report is Geary.AccountProblemReport) { + Geary.AccountProblemReport account_report = (Geary.AccountProblemReport) report; + string account = account_report.account.display_name; + if (report.problem_type == Geary.ProblemType.DATABASE_FAILURE) { + type = Gtk.MessageType.ERROR; + title = _("A database problem has occurred"); + // Translators: String substitution is the account name + descr = _("Messages for %s must be downloaded again.").printf(account); + show_close = true; + + } else { + debug("Un-handled account problem report: %s".printf(report.to_string())); + show_generic = true; + } + } else { + debug("Un-handled generic problem report: %s".printf(report.to_string())); + show_generic = true; + } + + if (show_generic) { + title = _("Geary has encountered a problem"); + descr = _("Please check the technical details and report the problem if it persists."); + show_close = true; + } + + this(type, title, descr, show_close); + this.report = report; + + if (this.report.error != null) { + Gtk.Button details = add_button(_("_Details"), ResponseType.DETAILS); + details.tooltip_text = _("View technical details about the error"); + } + + if (retry != null) { + Gtk.Button retry_btn = add_button(_("_Retry"), ResponseType.RETRY); + retry_btn.tooltip_text = retry; + } + } + + protected MainWindowInfoBar(Gtk.MessageType type, + string title, + string description, + bool show_close) { + this.message_type = type; + this.title.label = title; + + // Set the label and tooltip for the description in case it is + // long enough to be ellipsized + this.description.label = description; + this.description.tooltip_text = description; + + this.show_close_button = show_close; + } + + private string format_details() { + Geary.ServiceProblemReport? service_report = this.report as Geary.ServiceProblemReport; + Geary.AccountProblemReport? account_report = this.report as Geary.AccountProblemReport; + + StringBuilder details = new StringBuilder(); + details.append_printf( + "Geary version: %s\n", + GearyApplication.VERSION + ); + details.append_printf( + "GTK version: %u.%u.%u\n", + Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version() + ); + details.append_printf( + "Desktop: %s\n", + Environment.get_variable("XDG_CURRENT_DESKTOP") ?? "Unknown" + ); + details.append_printf( + "Problem type: %s\n", + this.report.problem_type.to_string() + ); + if (account_report != null) { + details.append_printf( + "Account type: %s\n", + account_report.account.service_provider.to_string() + ); + } + if (service_report != null) { + details.append_printf( + "Service type: %s\n", + service_report.service_type.to_string() + ); + details.append_printf( + "Endpoint: %s\n", + service_report.endpoint.to_string() + ); + } + if (this.report.error == null) { + details.append("No error reported"); + } else { + details.append_printf("Error type: %s\n", this.report.format_error_type()); + details.append_printf("Message: %s\n", this.report.error.message); + } + if (this.report.backtrace != null) { + details.append("Back trace:\n"); + foreach (Geary.ProblemReport.StackFrame frame in this.report.backtrace) { + details.append_printf(" - %s\n", frame.to_string()); + } + } + return details.str; + } + + private void show_details() { + this.detail_text.buffer.text = format_details(); + + // Would love to construct the dialog in Builder, but we to + // construct the dialog manually since we can't adjust the + // Headerbar setting afterwards. If the user re-clicks on the + // Details button to re-show it, a whole bunch of GTK + // criticals are spewed and the dialog appears b0rked, so just + // do it from scratch ever time anyway. + bool use_header = Gtk.Settings.get_default().gtk_dialogs_use_header; + Gtk.DialogFlags flags = Gtk.DialogFlags.MODAL; + if (use_header) { + flags |= Gtk.DialogFlags.USE_HEADER_BAR; + } + Gtk.Dialog dialog = new Gtk.Dialog.with_buttons( + _("Details"), // same as the button + get_toplevel() as Gtk.Window, + flags, + null + ); + dialog.set_default_size(600, -1); + dialog.get_content_area().add(this.problem_details); + + Gtk.HeaderBar? header_bar = dialog.get_header_bar() as Gtk.HeaderBar; + use_header = (header_bar != null); + if (use_header) { + header_bar.show_close_button = true; + } else { + dialog.add_button(_("_Close"), Gtk.ResponseType.CLOSE); + } + + Gtk.Widget copy = dialog.add_button( + _("Copy to Clipboard"), ResponseType.COPY + ); + copy.tooltip_text = + _("Copy technical details to clipboard for pasting into an email or bug report"); + + + dialog.set_default_response(ResponseType.COPY); + dialog.response.connect(on_details_response); + dialog.show(); + copy.grab_focus(); + } + + private void copy_details() { + get_clipboard(Gdk.SELECTION_CLIPBOARD).set_text(format_details(), -1); + } + + [GtkCallback] + private void on_info_bar_response(int response) { + switch(response) { + case ResponseType.DETAILS: + show_details(); + break; + + case ResponseType.RETRY: + retry(); + this.hide(); + break; + + default: + this.hide(); + break; + } + } + + [GtkCallback] + private void on_hide() { + this.parent.remove(this); + } + + private void on_details_response(Gtk.Dialog dialog, int response) { + switch(response) { + case ResponseType.COPY: + copy_details(); + break; + + default: + // fml + dialog.get_content_area().remove(this.problem_details); + dialog.hide(); + break; + } + } + + +} diff --git a/src/client/components/main-window.vala b/src/client/components/main-window.vala index 13fedf7c..4161fa3a 100644 --- a/src/client/components/main-window.vala +++ b/src/client/components/main-window.vala @@ -50,6 +50,12 @@ public class MainWindow : Gtk.ApplicationWindow { [GtkChild] private Gtk.ScrolledWindow conversation_list_scrolled; + // This is a frame so users can use F6/Shift-F6 to get to it + [GtkChild] + private Gtk.Frame info_bar_frame; + + [GtkChild] + private Gtk.Grid info_bar_container; /** Fired when the shift key is pressed or released. */ public signal void on_shift_key(bool pressed); @@ -72,6 +78,11 @@ public class MainWindow : Gtk.ApplicationWindow { on_change_orientation(); } + public void show_infobar(MainWindowInfoBar info_bar) { + this.info_bar_container.add(info_bar); + this.info_bar_frame.show(); + } + private void load_config(Configuration config) { // This code both loads AND saves the pane positions with live updating. This is more // resilient against crashes because the value in dconf changes *immediately*, and @@ -422,4 +433,14 @@ public class MainWindow : Gtk.ApplicationWindow { } return Gdk.EVENT_STOP; } + + [GtkCallback] + private void on_info_bar_container_remove() { + // Ensure the info bar frame is hidden when the last info bar + // is removed from the container. + if (this.info_bar_container.get_children().length() == 0) { + this.info_bar_frame.hide(); + } + } + } diff --git a/src/engine/api/geary-account.vala b/src/engine/api/geary-account.vala index c26db1d1..a7fd3434 100644 --- a/src/engine/api/geary-account.vala +++ b/src/engine/api/geary-account.vala @@ -21,17 +21,11 @@ */ public abstract class Geary.Account : BaseObject { - public enum Problem { - CONNECTION_FAILURE, - DATABASE_FAILURE, - HOST_UNREACHABLE, - NETWORK_UNAVAILABLE, - RECV_EMAIL_LOGIN_FAILED, - SEND_EMAIL_DELIVERY_FAILURE, - SEND_EMAIL_ERROR, - SEND_EMAIL_LOGIN_FAILED, - SEND_EMAIL_SAVE_FAILED, - } + + + /** Number of times to attempt re-authentication. */ + internal const uint AUTH_ATTEMPTS_MAX = 3; + public Geary.AccountInformation information { get; protected set; } @@ -46,8 +40,8 @@ public abstract class Geary.Account : BaseObject { public signal void closed(); public signal void email_sent(Geary.RFC822.Message rfc822); - - public signal void report_problem(Geary.Account.Problem problem, Error? err); + + public signal void report_problem(Geary.ProblemReport problem); public signal void contacts_loaded(); @@ -131,64 +125,7 @@ public abstract class Geary.Account : BaseObject { this.name = name; this.information = information; } - - protected virtual void notify_folders_available_unavailable(Gee.List? available, - Gee.List? unavailable) { - folders_available_unavailable(available, unavailable); - } - protected virtual void notify_folders_added_removed(Gee.List? added, - Gee.List? removed) { - folders_added_removed(added, removed); - } - - protected virtual void notify_folders_contents_altered(Gee.Collection altered) { - folders_contents_altered(altered); - } - - protected virtual void notify_email_appended(Geary.Folder folder, Gee.Collection ids) { - email_appended(folder, ids); - } - - protected virtual void notify_email_inserted(Geary.Folder folder, Gee.Collection ids) { - email_inserted(folder, ids); - } - - protected virtual void notify_email_removed(Geary.Folder folder, Gee.Collection ids) { - email_removed(folder, ids); - } - - protected virtual void notify_email_locally_complete(Geary.Folder folder, - Gee.Collection ids) { - email_locally_complete(folder, ids); - } - - protected virtual void notify_email_discovered(Geary.Folder folder, - Gee.Collection ids) { - email_discovered(folder, ids); - } - - protected virtual void notify_email_flags_changed(Geary.Folder folder, - Gee.Map flag_map) { - email_flags_changed(folder, flag_map); - } - - protected virtual void notify_opened() { - opened(); - } - - protected virtual void notify_closed() { - closed(); - } - - protected virtual void notify_email_sent(RFC822.Message message) { - email_sent(message); - } - - protected virtual void notify_report_problem(Geary.Account.Problem problem, Error? err) { - report_problem(problem, err); - } - /** * A utility method to sort a Gee.Collection of {@link Folder}s by their {@link FolderPath}s * to ensure they comport with {@link folders_available_unavailable} and @@ -244,7 +181,27 @@ public abstract class Geary.Account : BaseObject { * Unlike most methods in Account, this should only be called when the Account is closed. */ public abstract async void rebuild_async(Cancellable? cancellable = null) throws Error; - + + /** + * Starts delivery of messages to the outgoing server. + * + * Outgoing delivery will be started by default when the account + * is opened. This method is mostly useful when re-starting it + * after an error has occurred. + */ + public abstract async void start_outgoing_client() + throws Error; + + /** + * Starts receiving messages from the incoming server. + * + * The incoming client will be started by default when the account + * is opened. This method is mostly useful when re-starting it + * after an error has occurred. + */ + public abstract async void start_incoming_client() + throws Error; + /** * Lists all the currently-available folders found under the parent path * unless it's null, in which case it lists all the root folders. If the @@ -395,5 +352,86 @@ public abstract class Geary.Account : BaseObject { public virtual string to_string() { return name; } -} + /** Fires a {@link opened}} signal. */ + protected virtual void notify_opened() { + opened(); + } + + /** Fires a {@link closed}} signal. */ + protected virtual void notify_closed() { + closed(); + } + + /** Fires a {@link folders_available_unavailable}} signal. */ + protected virtual void notify_folders_available_unavailable(Gee.List? available, + Gee.List? unavailable) { + folders_available_unavailable(available, unavailable); + } + + /** Fires a {@link folders_added_removed}} signal. */ + protected virtual void notify_folders_added_removed(Gee.List? added, + Gee.List? removed) { + folders_added_removed(added, removed); + } + + /** Fires a {@link folders_contents_altered}} signal. */ + protected virtual void notify_folders_contents_altered(Gee.Collection altered) { + folders_contents_altered(altered); + } + + /** Fires a {@link email_appended}} signal. */ + protected virtual void notify_email_appended(Geary.Folder folder, Gee.Collection ids) { + email_appended(folder, ids); + } + + /** Fires a {@link email_inserted}} signal. */ + protected virtual void notify_email_inserted(Geary.Folder folder, Gee.Collection ids) { + email_inserted(folder, ids); + } + + /** Fires a {@link email_removed}} signal. */ + protected virtual void notify_email_removed(Geary.Folder folder, Gee.Collection ids) { + email_removed(folder, ids); + } + + /** Fires a {@link email_locally_complete}} signal. */ + protected virtual void notify_email_locally_complete(Geary.Folder folder, + Gee.Collection ids) { + email_locally_complete(folder, ids); + } + + /** Fires a {@link email_discovered}} signal. */ + protected virtual void notify_email_discovered(Geary.Folder folder, + Gee.Collection ids) { + email_discovered(folder, ids); + } + + /** Fires a {@link email_flags_changed}} signal. */ + protected virtual void notify_email_flags_changed(Geary.Folder folder, + Gee.Map flag_map) { + email_flags_changed(folder, flag_map); + } + + protected virtual void notify_email_sent(RFC822.Message message) { + email_sent(message); + } + + /** Fires a {@link report_problem}} signal for this account. */ + protected virtual void notify_report_problem(ProblemReport report) { + report_problem(report); + } + + /** + * Fires a {@link report_problem}} signal for this account. + */ + protected virtual void notify_account_problem(ProblemType type, Error? err) { + report_problem(new AccountProblemReport(type, this.information, err)); + } + + /** Fires a {@link report_problem}} signal for a service for this account. */ + protected virtual void notify_service_problem(ProblemType type, Service service_type, Error? err) { + report_problem(new ServiceProblemReport(type, this.information, service_type, err)); + } + +} diff --git a/src/engine/api/geary-endpoint.vala b/src/engine/api/geary-endpoint.vala index 596754e6..14121dca 100644 --- a/src/engine/api/geary-endpoint.vala +++ b/src/engine/api/geary-endpoint.vala @@ -127,7 +127,7 @@ public class Geary.Endpoint : BaseObject { this.remote_address = new NetworkAddress(host_specifier, default_port); this.flags = flags; this.timeout_sec = timeout_sec; - this.connectivity = new ConnectivityManager(this); + this.connectivity = new ConnectivityManager(this.remote_address); } private SocketClient get_socket_client() { diff --git a/src/engine/api/geary-folder.vala b/src/engine/api/geary-folder.vala index 63772d0f..2fd549d9 100644 --- a/src/engine/api/geary-folder.vala +++ b/src/engine/api/geary-folder.vala @@ -552,12 +552,11 @@ public abstract class Geary.Folder : BaseObject { */ public abstract async Geary.Email fetch_email_async(Geary.EmailIdentifier email_id, Geary.Email.Field required_fields, ListFlags flags, Cancellable? cancellable = null) throws Error; - + /** * Used for debugging. Should not be used for user-visible labels. */ public virtual string to_string() { - return "%s:%s".printf(account.to_string(), path.to_string()); + return "%s:%s".printf(this.account.information.id, this.path.to_string()); } } - diff --git a/src/engine/api/geary-problem-report.vala b/src/engine/api/geary-problem-report.vala new file mode 100644 index 00000000..938fd4e3 --- /dev/null +++ b/src/engine/api/geary-problem-report.vala @@ -0,0 +1,222 @@ +/* + * Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2017 Michael Gratton + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +/** Describes available problem types. */ +public enum Geary.ProblemType { + + + /** Indicates an engine problem not covered by one of the other types. */ + GENERIC_ERROR, + + /** Indicates an error opening, using or closing the account database. */ + DATABASE_FAILURE, + + /** Indicates a problem establishing a connection. */ + CONNECTION_ERROR, + + /** Indicates a problem caused by a network operation. */ + NETWORK_ERROR, + + /** Indicates a non-network related server error. */ + SERVER_ERROR, + + /** Indicates credentials supplied for authentication were rejected. */ + LOGIN_FAILED, + + /** Indicates an outgoing message was sent, but not saved. */ + SEND_EMAIL_SAVE_FAILED; + + + /** Determines the appropriate problem type for an IOError. */ + public static ProblemType for_ioerror(IOError error) { + if (error is IOError.CONNECTION_REFUSED || + error is IOError.HOST_NOT_FOUND || + error is IOError.HOST_UNREACHABLE || + error is IOError.NETWORK_UNREACHABLE) { + return ProblemType.CONNECTION_ERROR; + } + + if (error is IOError.CONNECTION_CLOSED || + error is IOError.NOT_CONNECTED) { + return ProblemType.NETWORK_ERROR; + } + + return ProblemType.GENERIC_ERROR; + } + +} + +/** + * Describes a error that the engine encountered, for reporting to the client. + */ +public class Geary.ProblemReport : Object { + + + /** + * Represents an individual stack frame in a call back-trace. + */ + public class StackFrame { + + + /** Name of the function being called. */ + public string name = "unknown"; + + + internal StackFrame(Unwind.Cursor frame) { + uint8 proc_name[256]; + int ret = -frame.get_proc_name(proc_name); + if (ret == Unwind.Error.SUCCESS || + ret == Unwind.Error.NOMEM) { + this.name = (string) proc_name; + } + } + + public string to_string() { + return this.name; + } + + } + + + /** Describes the type of being reported. */ + public ProblemType problem_type { get; private set; } + + /** The exception caused the problem, if any. */ + public Error? error { get; private set; default = null; } + + /** A back trace from when the problem report was constructed. */ + public Gee.List? backtrace = null; + + + public ProblemReport(ProblemType type, Error? error) { + this.problem_type = type; + this.error = error; + + if (error != null) { + // Some kind of exception occurred, so build a trace. This + // is far from perfect, but at least we will know where it + // was getting caught. + this.backtrace = new Gee.LinkedList(); + Unwind.Context trace = Unwind.Context(); + Unwind.Cursor cursor = Unwind.Cursor.local(trace); + + // This misses the first frame, but that's this + // constructor call, so we don't really care. + while (cursor.step() != 0) { + this.backtrace.add(new StackFrame(cursor)); + } + } + } + + /** Returns a string representation of the report, for debugging only. */ + public string to_string() { + return "%s: %s".printf( + this.problem_type.to_string(), + format_full_error() ?? "no error reported" + ); + } + + /** Returns a string representation of the error type, for debugging. */ + public string? format_error_type() { + string type = null; + if (this.error != null) { + const string QUARK_SUFFIX = "-quark"; + string ugly_domain = this.error.domain.to_string(); + if (ugly_domain.has_suffix(QUARK_SUFFIX)) { + ugly_domain = ugly_domain.substring( + 0, ugly_domain.length - QUARK_SUFFIX.length + ); + } + StringBuilder nice_domain = new StringBuilder(); + string separator = (ugly_domain.index_of("_") != -1) ? "_" : "-"; + foreach (string part in ugly_domain.split(separator)) { + if (part.length > 0) { + if (part == "io") { + nice_domain.append("IO"); + } else { + nice_domain.append(part.up(1)); + nice_domain.append(part.substring(1)); + } + } + } + + type = "%s %i".printf(nice_domain.str, this.error.code); + } + return type; + } + + /** Returns a string representation of the complete error, for debugging. */ + public string? format_full_error() { + string error = null; + if (this.error != null) { + error = String.is_empty(this.error.message) + ? "%s: no message specified".printf(format_error_type()) + : "%s: \"%s\"".printf(format_error_type(), this.error.message); + } + return error; + } + +} + +/** + * Describes an account-related error that the engine encountered. + */ +public class Geary.AccountProblemReport : ProblemReport { + + + /** The account related to the problem report. */ + public AccountInformation account { get; private set; } + + + public AccountProblemReport(ProblemType type, AccountInformation account, Error? error) { + base(type, error); + this.account = account; + } + + /** Returns a string representation of the report, for debugging only. */ + public new string to_string() { + return "%s: %s".printf(this.account.id, base.to_string()); + } + +} + +/** + * Describes a service-related error that the engine encountered. + */ +public class Geary.ServiceProblemReport : AccountProblemReport { + + + /** The service related to the problem report. */ + public Service service_type { get; private set; } + + /** The endpoint for the report's service type. */ + public Endpoint endpoint { + owned get { + return (this.service_type == Service.IMAP) + ? this.account.get_imap_endpoint() + : this.account.get_smtp_endpoint(); + } + } + + + public ServiceProblemReport(ProblemType type, AccountInformation account, Service service_type, Error? error) { + base(type, account, error); + this.service_type = service_type; + } + + /** Returns a string representation of the report, for debugging only. */ + public new string to_string() { + return "%s: %s: %s: %s".printf( + this.account.id, + this.service_type.to_string(), + this.problem_type.to_string(), + format_full_error() ?? "no error reported" + ); + } + +} diff --git a/src/engine/imap-db/imap-db-account.vala b/src/engine/imap-db/imap-db-account.vala index 870f63a7..6870dfa7 100644 --- a/src/engine/imap-db/imap-db-account.vala +++ b/src/engine/imap-db/imap-db-account.vala @@ -233,11 +233,10 @@ private class Geary.ImapDB.Account : BaseObject { public Account(Geary.AccountInformation account_information) { this.account_information = account_information; - contact_store = new ImapEngine.ContactStore(this); - - name = "IMAP database account for %s".printf(account_information.imap_credentials.user); + this.contact_store = new ImapEngine.ContactStore(this); + this.name = account_information.id + ":db"; } - + private void check_open() throws Error { if (db == null) throw new EngineError.OPEN_REQUIRED("Database not open"); diff --git a/src/engine/imap-db/outbox/smtp-outbox-folder.vala b/src/engine/imap-db/outbox/smtp-outbox-folder.vala index c991a2a5..4211392c 100644 --- a/src/engine/imap-db/outbox/smtp-outbox-folder.vala +++ b/src/engine/imap-db/outbox/smtp-outbox-folder.vala @@ -25,9 +25,6 @@ private class Geary.SmtpOutboxFolder : // loaded, connections to settle, pigs to fly, etc. private const uint START_TIMEOUT = 4; - // Number of times to retry sending after auth failures - private const uint AUTH_ERROR_MAX_RETRY = 3; - private class OutboxRow { public int64 id; @@ -95,7 +92,7 @@ private class Geary.SmtpOutboxFolder : public signal void email_sent(Geary.RFC822.Message rfc822); /** Fired if a user-notifiable problem occurs. */ - public signal void report_problem(Geary.Account.Problem problem, Error? err); + public signal void report_problem(ProblemReport report); // Requires the Database from the get-go because it runs a background task that access it @@ -113,9 +110,84 @@ private class Geary.SmtpOutboxFolder : ); } - // create_email_async() requires the Outbox be open according to contract, but enqueuing emails - // for background delivery can happen at any time, so this is the mechanism to do so. - // email_count is the number of emails in the Outbox after enqueueing the message. + /** + * Starts delivery of messages in the outbox. + */ + public async void start_postman_async() { + debug("Starting outbox postman with %u messages queued", this.outbox_queue.size); + if (this.queue_cancellable != null) { + debug("Postman already started, not starting another"); + return; + } + + Cancellable cancellable = this.queue_cancellable = new Cancellable(); + uint send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC; + + // Start the send queue. + while (!cancellable.is_cancelled()) { + // yield until a message is ready + OutboxRow? row = null; + bool row_handled = false; + try { + row = yield this.outbox_queue.recv_async(cancellable); + row_handled = yield postman_send(row, cancellable); + } catch (SmtpError err) { + ProblemType problem = ProblemType.GENERIC_ERROR; + if (err is SmtpError.AUTHENTICATION_FAILED) { + problem = ProblemType.LOGIN_FAILED; + } else if (err is SmtpError.STARTTLS_FAILED) { + problem = ProblemType.CONNECTION_ERROR; + } else if (err is SmtpError.NOT_CONNECTED) { + problem = ProblemType.NETWORK_ERROR; + } else if (err is SmtpError.PARSE_ERROR || + err is SmtpError.SERVER_ERROR || + err is SmtpError.NOT_SUPPORTED) { + problem = ProblemType.SERVER_ERROR; + } + notify_report_problem(problem, err); + cancellable.cancel(); + } catch (IOError err) { + notify_report_problem(ProblemType.for_ioerror(err), err); + cancellable.cancel(); + } catch (Error err) { + notify_report_problem(ProblemType.GENERIC_ERROR, err); + cancellable.cancel(); + } + + if (row_handled) { + // send was good, reset nap length + send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC; + } else { + // send was bad, try sending again later + if (row != null) { + this.outbox_queue.send(row); + } + + if (!cancellable.is_cancelled()) { + debug("Outbox napping for %u seconds...", send_retry_seconds); + // Take a brief nap before continuing to allow + // connection problems to resolve. + yield Geary.Scheduler.sleep_async(send_retry_seconds); + send_retry_seconds = Geary.Numeric.uint_ceiling( + send_retry_seconds * 2, + MAX_SEND_RETRY_INTERVAL_SEC + ); + } + } + } + + this.queue_cancellable = null; + debug("Exiting outbox postman"); + } + + /** + * Queues a message in the outbox for delivery. + * + * This should be used instead of {@link create_email_async()}, + * since that requires the Outbox be open according to contract, + * but enqueuing emails for background delivery can happen at any + * time, so this is the mechanism to do so. + */ public async SmtpOutboxEmailIdentifier enqueue_email_async(Geary.RFC822.Message rfc822, Cancellable? cancellable) throws Error { debug("Queuing message for sending: %s", @@ -385,64 +457,6 @@ private class Geary.SmtpOutboxFolder : return row_to_email(row); } - private async void start_postman_async() { - debug("Starting outbox postman with %u messages queued", this.outbox_queue.size); - if (this.queue_cancellable != null) { - debug("Postman already started, not starting another"); - return; - } - - Cancellable cancellable = this.queue_cancellable = new Cancellable(); - uint send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC; - - // Start the send queue. - while (!cancellable.is_cancelled()) { - // yield until a message is ready - OutboxRow? row = null; - try { - row = yield this.outbox_queue.recv_async(cancellable); - if (yield postman_send(row, cancellable)) { - // send was good, reset nap length - send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC; - } else { - // send was bad, try sending again later - this.outbox_queue.send(row); - - if (!cancellable.is_cancelled()) { - debug("Outbox napping for %u seconds...", send_retry_seconds); - // Take a brief nap before continuing to allow - // connection problems to resolve. - yield Geary.Scheduler.sleep_async(send_retry_seconds); - send_retry_seconds = Geary.Numeric.uint_ceiling( - send_retry_seconds * 2, - MAX_SEND_RETRY_INTERVAL_SEC - ); - } - } - } catch (Error err) { - // A hard error occurred. This will cause the postman - // to exit but we still want to re-queue the row in - // case it restarts. - if (row != null) { - this.outbox_queue.send(row); - } - if (!(err is IOError.CANCELLED)) { - debug("Outbox postman error: %s", err.message); - if (err is SmtpError.AUTHENTICATION_FAILED) { - report_problem(Geary.Account.Problem.SEND_EMAIL_LOGIN_FAILED, err); - } else { - report_problem(Geary.Account.Problem.SEND_EMAIL_ERROR, err); - } - } - // Get out of here - cancellable.cancel(); - } - } - - this.queue_cancellable = null; - debug("Exiting outbox postman"); - } - // Returns true if row was successfully processed, else false private async bool postman_send(OutboxRow row, Cancellable cancellable) throws Error { @@ -481,8 +495,8 @@ private class Geary.SmtpOutboxFolder : // We immediately retry auth errors after the prompting // the user, but if they get it wrong enough times or // cancel we have no choice other than to stop the postman - int attempts = 0; - while (!mail_sent && ++attempts <= AUTH_ERROR_MAX_RETRY) { + uint attempts = 0; + while (!mail_sent && ++attempts <= Geary.Account.AUTH_ATTEMPTS_MAX) { try { debug("Outbox postman: Sending \"%s\" (ID:%s)...", message_subject(message), row.outbox_id.to_string()); @@ -491,8 +505,14 @@ private class Geary.SmtpOutboxFolder : } catch (Error send_err) { debug("Outbox postman send error: %s", send_err.message); if (send_err is SmtpError.AUTHENTICATION_FAILED) { - // At this point we may already have a password in memory -- but it's incorrect. - // Delete the current password, prompt the user for a new one, and try again. + if (attempts == Geary.Account.AUTH_ATTEMPTS_MAX) { + throw send_err; + } + + // At this point we may already have a + // password in memory -- but it's incorrect. + // Delete the current password, prompt the + // user for a new one, and try again. bool user_confirmed = false; try { user_confirmed = yield account.fetch_passwords_async( @@ -503,18 +523,19 @@ private class Geary.SmtpOutboxFolder : } if (!user_confirmed) { - // The user cancelled hence they don't - // want to be prompted again, so report it - // and bail out. + // The user cancelled and hence they don't + // want to be prompted again, so bail out. throw send_err; } } else if (send_err is TlsError) { - // up to application to be aware of problem via Geary.Engine, but do nap and - // try later + // up to application to be aware of problem + // via Geary.Engine, but do nap and try later debug("TLS connection warnings connecting to %s, user must confirm connection to continue", this.smtp_endpoint.to_string()); + break; } else { - report_problem(Geary.Account.Problem.SEND_EMAIL_DELIVERY_FAILURE, send_err); + // not much else we can do - just bail out + throw send_err; } } } @@ -544,7 +565,7 @@ private class Geary.SmtpOutboxFolder : yield save_sent_mail_async(message, cancellable); } catch (Error err) { debug("Outbox postman: Error saving sent mail: %s", err.message); - report_problem(Geary.Account.Problem.SEND_EMAIL_SAVE_FAILED, err); + notify_report_problem(ProblemType.SEND_EMAIL_SAVE_FAILED, err); return false; } } @@ -856,10 +877,15 @@ private class Geary.SmtpOutboxFolder : return stmt.exec_get_modified(cancellable) > 0; } + private void notify_report_problem(ProblemType problem, Error? err) { + report_problem(new ServiceProblemReport(problem, this.account.information, Service.SMTP, err)); + } + private void on_account_opened() { this.fill_outbox_queue.begin(); this.smtp_endpoint.connectivity.notify["is-reachable"].connect(on_reachable_changed); - if (this.smtp_endpoint.connectivity.is_reachable) { + this.smtp_endpoint.connectivity.address_error_reported.connect(on_connectivity_error); + if (this.smtp_endpoint.connectivity.is_reachable.is_certain()) { this.start_timer.start(); } else { this.smtp_endpoint.connectivity.check_reachable.begin(); @@ -869,18 +895,23 @@ private class Geary.SmtpOutboxFolder : private void on_account_closed() { this.stop_postman(); this.smtp_endpoint.connectivity.notify["is-reachable"].disconnect(on_reachable_changed); + this.smtp_endpoint.connectivity.address_error_reported.disconnect(on_connectivity_error); } private void on_reachable_changed() { - if (this.smtp_endpoint.connectivity.is_reachable) { + if (this.smtp_endpoint.connectivity.is_reachable.is_certain()) { if (this.queue_cancellable == null) { this.start_timer.start(); } } else { this.start_timer.reset(); - if (this.queue_cancellable != null) { - stop_postman(); - } + stop_postman(); } } + + private void on_connectivity_error(Error error) { + stop_postman(); + notify_report_problem(ProblemType.CONNECTION_ERROR, error); + } + } diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala b/src/engine/imap-engine/imap-engine-generic-account.vala index 1d729d94..455d2919 100644 --- a/src/engine/imap-engine/imap-engine-generic-account.vala +++ b/src/engine/imap-engine/imap-engine-generic-account.vala @@ -29,7 +29,6 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { = new Gee.HashMap(); private Gee.HashSet in_refresh_unseen = new Gee.HashSet(); private AccountSynchronizer sync; - private bool awaiting_credentials = false; private Cancellable? enumerate_folder_cancellable = null; private TimeoutManager refresh_folder_timer; @@ -43,10 +42,9 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { this.remote = remote; this.remote.ready.connect(on_remote_ready); + this.remote.report_problem.connect(notify_report_problem); this.local = local; - - this.remote.login_failed.connect(on_login_failed); this.local.contacts_loaded.connect(() => { contacts_loaded(); }); this.local.email_sent.connect(on_email_sent); @@ -264,7 +262,31 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { message("%s: Rebuild complete", to_string()); } - + + /** + * This starts the outbox postman running. + */ + public override async void start_outgoing_client() + throws Error { + check_open(); + this.local.outbox.start_postman_async.begin(); + } + + /** + * This closes then reopens the IMAP account. + */ + public override async void start_incoming_client() + throws Error { + check_open(); + try { + yield this.remote.close_async(); + } catch (Error err) { + debug("Ignoring error closing IMAP account for restart: %s", err.message); + } + + yield this.remote.open_async(); + } + // Subclasses should implement this to return their flavor of a MinimalFolder with the // appropriate interfaces attached. The returned folder should have its SpecialFolderType // set using either the properties from the local folder or its path. @@ -425,10 +447,12 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { successful = true; } + } catch (ImapError err) { + notify_account_problem(ProblemType.SERVER_ERROR, err); + } catch (IOError err) { + notify_account_problem(ProblemType.for_ioerror(err), err); } catch (Error err) { - if (!(err is IOError.CANCELLED)) { - report_problem(Geary.Account.Problem.CONNECTION_FAILURE, err); - } + notify_account_problem(ProblemType.GENERIC_ERROR, err); } this.enumerate_folder_cancellable = null; @@ -935,44 +959,8 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account { return yield local.get_containing_folders_async(ids, cancellable); } - private void on_login_failed(Geary.Credentials? credentials, Geary.Imap.StatusResponse? response) { - if (awaiting_credentials) - return; // We're already asking for the password. - - awaiting_credentials = true; - do_login_failed_async.begin(credentials, response, () => { awaiting_credentials = false; }); - } - private void on_remote_ready() { this.enumerate_folders_async.begin(); } - private async void do_login_failed_async(Geary.Credentials? credentials, Geary.Imap.StatusResponse? response) { - bool reask_password = true; - try { - reask_password = ( - response == null || - response.response_code == null || - response.response_code.get_response_code_type().value != Geary.Imap.ResponseCodeType.UNAVAILABLE - ); - } catch (ImapError ierr) { - debug("Unable to parse ResponseCode %s: %s", response.response_code.to_string(), - ierr.message); - } - // login can fail due to an invalid password hence we should re-ask it - // but it can also fail due to server inaccessibility, for instance "[UNAVAILABLE] / - // Maximum number of connections from user+IP exceeded". In that case, resetting password seems unneeded. - if (reask_password) { - try { - if (yield information.fetch_passwords_async(ServiceFlag.IMAP, true)) - return; - } catch (Error e) { - debug("Error prompting for IMAP password: %s", e.message); - } - notify_report_problem(Geary.Account.Problem.RECV_EMAIL_LOGIN_FAILED, null); - } else { - notify_report_problem(Geary.Account.Problem.CONNECTION_FAILURE, null); - } - } } - diff --git a/src/engine/imap/api/imap-account.vala b/src/engine/imap/api/imap-account.vala index 4bf89f03..ac3d746c 100644 --- a/src/engine/imap/api/imap-account.vala +++ b/src/engine/imap/api/imap-account.vala @@ -33,8 +33,9 @@ private class Geary.Imap.Account : BaseObject { public bool is_ready { get { return this.session_mgr.is_ready; } } private string name; - private AccountInformation account_information; + private AccountInformation account; private ClientSessionManager session_mgr; + private uint authentication_failures = 0; private ClientSession? account_session = null; private Nonblocking.Mutex account_session_mutex = new Nonblocking.Mutex(); private Nonblocking.Mutex cmd_mutex = new Nonblocking.Mutex(); @@ -43,6 +44,7 @@ private class Geary.Imap.Account : BaseObject { private Gee.List? status_collector = null; private Gee.List? server_data_collector = null; + /** * Fired after opening when the account has a working connection. * @@ -51,27 +53,31 @@ private class Geary.Imap.Account : BaseObject { */ public signal void ready(); - public signal void login_failed(Geary.Credentials? cred, StatusResponse? response); + /** Fired if a user-notifiable problem occurs. */ + public signal void report_problem(ProblemReport report); - public Account(Geary.AccountInformation account_information) { - name = "IMAP Account for %s".printf(account_information.imap_credentials.to_string()); - this.account_information = account_information; - this.session_mgr = new ClientSessionManager(account_information); + + public Account(Geary.AccountInformation account) { + this.name = account.id + ":imap"; + this.account = account; + this.session_mgr = new ClientSessionManager(account); this.session_mgr.ready.connect(on_session_ready); + this.session_mgr.connection_failed.connect(on_connection_failed); this.session_mgr.login_failed.connect(on_login_failed); } - - private void check_open() throws Error { - if (!is_open) - throw new EngineError.OPEN_REQUIRED("Imap.Account not open"); - } - + public async void open_async(Cancellable? cancellable = null) throws Error { if (is_open) throw new EngineError.ALREADY_OPEN("Imap.Account already open"); - + + // Reset this so we start trying to authenticate again + this.authentication_failures = 0; + + // This will cause the session manager to open at least one + // connection. We can't attempt to claim one straight away + // since we might not be online. yield session_mgr.open_async(cancellable); - + is_open = true; } @@ -569,22 +575,27 @@ private class Geary.Imap.Account : BaseObject { return responses; } + private void check_open() throws Error { + if (!is_open) + throw new EngineError.OPEN_REQUIRED("Imap.Account not open"); + } + private inline Imap.Folder newUnselectableFolder(FolderPath path, MailboxAttributes attrs) { return new Imap.Folder( path, new Imap.FolderProperties(0, 0, 0, null, null, attrs), this.session_mgr ); } + private void notify_report_problem(ProblemType problem, Error? err) { + report_problem(new ServiceProblemReport(problem, this.account, Service.IMAP, err)); + } + [NoReturn] private void throw_not_found(Geary.FolderPath? path) throws EngineError { throw new EngineError.NOT_FOUND("Folder %s not found on %s", (path != null) ? path.to_string() : "root", session_mgr.to_string()); } - private void on_login_failed(StatusResponse? response) { - login_failed(account_information.imap_credentials, response); - } - private void on_list_data(MailboxInformation mailbox_info) { if (list_collector != null) list_collector.add(mailbox_info); @@ -605,10 +616,75 @@ private class Geary.Imap.Account : BaseObject { } private void on_session_ready() { + // Now have a valid session, so credentials must be good + this.authentication_failures = 0; ready(); } + private void on_connection_failed(Error error) { + // There was an error connecting to the IMAP host + this.authentication_failures = 0; + if (error is ImapError.UNAUTHENTICATED) { + // This is effectively a login failure + on_login_failed(null); + } else { + notify_report_problem(ProblemType.CONNECTION_ERROR, error); + } + } + + private void on_login_failed(Geary.Imap.StatusResponse? response) { + this.authentication_failures++; + if (this.authentication_failures >= Geary.Account.AUTH_ATTEMPTS_MAX) { + // We have tried auth too many times, so bail out + notify_report_problem(ProblemType.LOGIN_FAILED, null); + } else { + // login can fail due to an invalid password hence we + // should re-ask it but it can also fail due to server + // inaccessibility, for instance "[UNAVAILABLE] / Maximum + // number of connections from user+IP exceeded". In that + // case, resetting password seems unneeded. + bool reask_password = false; + Error? login_error = null; + try { + reask_password = ( + response == null || + response.response_code == null || + response.response_code.get_response_code_type().value != Geary.Imap.ResponseCodeType.UNAVAILABLE + ); + } catch (ImapError err) { + login_error = err; + debug("Unable to parse ResponseCode %s: %s", response.response_code.to_string(), + err.message); + } + + if (!reask_password) { + // Either the server was unavailable, or we were unable to + // parse the login response. Either way, indicate a + // non-login error. + notify_report_problem(ProblemType.SERVER_ERROR, login_error); + } else { + // Now, we should ask the user for their password + this.account.fetch_passwords_async.begin( + ServiceFlag.IMAP, true, + (obj, ret) => { + try { + if (this.account.fetch_passwords_async.end(ret)) { + // Have a new password, so try that + this.session_mgr.credentials_updated(); + } else { + // User cancelled, so indicate a login problem + notify_report_problem(ProblemType.LOGIN_FAILED, null); + } + } catch (Error err) { + notify_report_problem(ProblemType.GENERIC_ERROR, err); + } + }); + } + } + } + public string to_string() { return name; } + } diff --git a/src/engine/imap/transport/imap-client-session-manager.vala b/src/engine/imap/transport/imap-client-session-manager.vala index ef876012..302b9140 100644 --- a/src/engine/imap/transport/imap-client-session-manager.vala +++ b/src/engine/imap/transport/imap-client-session-manager.vala @@ -1,16 +1,15 @@ /* - * Copyright 2017 Michael Gratton * Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2017 Michael Gratton * * This software is licensed under the GNU Lesser General Public License * (version 2.1 or later). See the COPYING file in this distribution. */ public class Geary.Imap.ClientSessionManager : BaseObject { + private const int DEFAULT_MIN_POOL_SIZE = 1; private const int POOL_START_TIMEOUT_SEC = 4; - private const int POOL_RETRY_MIN_TIMEOUT_SEC = 1; - private const int POOL_RETRY_MAX_TIMEOUT_SEC = 10; private const int POOL_STOP_TIMEOUT_SEC = 1; /** Determines if the manager has been opened. */ @@ -69,7 +68,6 @@ public class Geary.Imap.ClientSessionManager : BaseObject { private bool untrusted_host = false; private TimeoutManager pool_start; - private TimeoutManager pool_retry; private TimeoutManager pool_stop; /** @@ -81,12 +79,15 @@ public class Geary.Imap.ClientSessionManager : BaseObject { */ public signal void ready(); + /** Fired when a network or non-auth error occurs opening a session. */ + public signal void connection_failed(Error err); + /** Fired when an authentication error occurs opening a session. */ public signal void login_failed(StatusResponse? response); + public ClientSessionManager(AccountInformation account_information) { this.account_information = account_information; - this.account_information.notify["imap-credentials"].connect(on_imap_credentials_notified); // NOTE: This works because AccountInformation guarantees the IMAP endpoint not to change // for the lifetime of the AccountInformation object; if this ever changes, will need to @@ -100,11 +101,6 @@ public class Geary.Imap.ClientSessionManager : BaseObject { () => { this.adjust_session_pool.begin(); } ); - this.pool_retry = new TimeoutManager.seconds( - POOL_RETRY_MIN_TIMEOUT_SEC, - () => { this.adjust_session_pool.begin(); } - ); - this.pool_stop = new TimeoutManager.seconds( POOL_STOP_TIMEOUT_SEC, () => { this.force_disconnect_all.begin(); } @@ -115,7 +111,6 @@ public class Geary.Imap.ClientSessionManager : BaseObject { if (is_open) warning("Destroying opened ClientSessionManager"); - this.account_information.notify["imap-credentials"].disconnect(on_imap_credentials_notified); this.endpoint.untrusted_host.disconnect(on_imap_untrusted_host); this.endpoint.notify[Endpoint.PROP_TRUST_UNTRUSTED_HOST].disconnect(on_imap_trust_untrusted_host); } @@ -124,10 +119,12 @@ public class Geary.Imap.ClientSessionManager : BaseObject { if (is_open) throw new EngineError.ALREADY_OPEN("ClientSessionManager already open"); - is_open = true; + this.is_open = true; + this.authentication_failed = false; this.endpoint.connectivity.notify["is-reachable"].connect(on_connectivity_change); - if (this.endpoint.connectivity.is_reachable) { + this.endpoint.connectivity.address_error_reported.connect(on_connectivity_error); + if (this.endpoint.connectivity.is_reachable.is_certain()) { this.adjust_session_pool.begin(); } else { this.endpoint.connectivity.check_reachable.begin(); @@ -142,10 +139,10 @@ public class Geary.Imap.ClientSessionManager : BaseObject { this.is_ready = false; this.pool_start.reset(); - this.pool_retry.reset(); this.pool_stop.reset(); this.endpoint.connectivity.notify["is-reachable"].disconnect(on_connectivity_change); + this.endpoint.connectivity.address_error_reported.disconnect(on_connectivity_error); // to avoid locking down the sessions table while scheduling disconnects, make a copy // and work off of that @@ -172,14 +169,20 @@ public class Geary.Imap.ClientSessionManager : BaseObject { break; } } - - private void on_imap_credentials_notified() { - authentication_failed = false; - - if (is_open) - adjust_session_pool.begin(); + + /** + * Informs the manager that the account's IMAP credentials have changed. + * + * This will reset the manager's authentication state and if open, + * attempt to open a connection to the server. + */ + public void credentials_updated() { + this.authentication_failed = false; + if (this.is_open) { + this.adjust_session_pool.begin(); + } } - + private void check_open() throws Error { if (!is_open) throw new EngineError.OPEN_REQUIRED("ClientSessionManager is not open"); @@ -203,9 +206,18 @@ public class Geary.Imap.ClientSessionManager : BaseObject { && this.is_open && !this.authentication_failed && !this.untrusted_host - && this.endpoint.connectivity.is_reachable) { - pending_sessions++; - create_new_authorized_session.begin(null, on_created_new_authorized_session); + && this.endpoint.connectivity.is_reachable.is_certain()) { + this.pending_sessions++; + create_new_authorized_session.begin( + null, + (obj, res) => { + this.pending_sessions--; + try { + this.create_new_authorized_session.end(res); + } catch (Error err) { + connection_failed(err); + } + }); } try { @@ -215,24 +227,6 @@ public class Geary.Imap.ClientSessionManager : BaseObject { } } - private void on_created_new_authorized_session(Object? source, AsyncResult result) { - this.pending_sessions--; - - try { - this.create_new_authorized_session.end(result); - this.pool_retry.reset(); - } catch (Error err) { - debug("Unable to create authorized session to %s: %s", endpoint.to_string(), err.message); - - // try again after a slight delay and bump up delay - this.pool_retry.start(); - this.pool_retry.interval = (this.pool_retry.interval * 2).clamp( - POOL_RETRY_MIN_TIMEOUT_SEC, - POOL_RETRY_MAX_TIMEOUT_SEC - ); - } - } - private async ClientSession create_new_authorized_session(Cancellable? cancellable) throws Error { if (authentication_failed) throw new ImapError.UNAUTHENTICATED("Invalid ClientSessionManager credentials"); @@ -240,9 +234,9 @@ public class Geary.Imap.ClientSessionManager : BaseObject { if (untrusted_host) throw new ImapError.UNAVAILABLE("Untrusted host %s", endpoint.to_string()); - if (!this.endpoint.connectivity.is_reachable) + if (!this.endpoint.connectivity.is_reachable.is_certain()) throw new ImapError.UNAVAILABLE("Host at %s is unreachable", endpoint.to_string()); - + ClientSession new_session = new ClientSession(endpoint); // add session to pool before launching all the connect activity so error cases can properly @@ -296,9 +290,6 @@ public class Geary.Imap.ClientSessionManager : BaseObject { ready(); } - // reset delay - this.pool_retry.interval = POOL_RETRY_MIN_TIMEOUT_SEC; - // do this after logging in new_session.enable_keepalives(selected_keepalive_sec, unselected_keepalive_sec, selected_with_idle_keepalive_sec); @@ -557,7 +548,7 @@ public class Geary.Imap.ClientSessionManager : BaseObject { } private void on_connectivity_change() { - bool is_reachable = this.endpoint.connectivity.is_reachable; + bool is_reachable = this.endpoint.connectivity.is_reachable.is_certain(); if (is_reachable) { this.pool_start.start(); this.pool_stop.reset(); @@ -567,7 +558,13 @@ public class Geary.Imap.ClientSessionManager : BaseObject { this.pool_start.reset(); this.pool_stop.start(); } - this.pool_retry.reset(); + } + + private void on_connectivity_error(Error error) { + this.is_ready = false; + this.pool_start.reset(); + this.pool_stop.start(); + connection_failed(error); } /** diff --git a/src/engine/smtp/smtp-error.vala b/src/engine/smtp/smtp-error.vala index 97f34f29..e7d3bd80 100644 --- a/src/engine/smtp/smtp-error.vala +++ b/src/engine/smtp/smtp-error.vala @@ -4,14 +4,33 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ -public errordomain Geary.SmtpError { - PARSE_ERROR, - STARTTLS_FAILED, - AUTHENTICATION_FAILED, - SERVER_ERROR, - ALREADY_CONNECTED, - NOT_CONNECTED, - REQUIRED_FIELD, - NOT_SUPPORTED -} +/** + * Thrown when an error occurs communicating with a SMTP server. + */ +public errordomain Geary.SmtpError { + + /** The client already has a connection to the server. */ + ALREADY_CONNECTED, + + /** The credentials presented for authentication were rejected. */ + AUTHENTICATION_FAILED, + + /** The client does not have a connection to the server. */ + NOT_CONNECTED, + + /** The server does not support an SMTP feature required by the engine. */ + NOT_SUPPORTED, + + /** A response from the server could not be parsed. */ + PARSE_ERROR, + + /** A message could not be sent because a field required by SMTP was missing. */ + REQUIRED_FIELD, + + /** The server reported an error. */ + SERVER_ERROR, + + /** Establishing STARTTLS was attempted, but failed. */ + STARTTLS_FAILED +} diff --git a/src/engine/util/util-connectivity-manager.vala b/src/engine/util/util-connectivity-manager.vala index cf84fcc4..821fec90 100644 --- a/src/engine/util/util-connectivity-manager.vala +++ b/src/engine/util/util-connectivity-manager.vala @@ -6,13 +6,13 @@ */ /** - * Keeps track of network connectivity changes for an endpoint. + * Keeps track of network connectivity changes for a network address. * * This class is a convenience API for the GIO NetworkMonitor. Since * when connecting and disconnecting from a network, multiple * network-changed signals may be sent, this class coalesces these as * best as possible so the rest of the engine is only notified once - * when an endpoint becomes reachable, and once when it becomes + * when an address becomes reachable, and once when it becomes * unreachable. * * Note this class is not thread safe and should only be invoked from @@ -20,15 +20,22 @@ */ public class Geary.ConnectivityManager : BaseObject { - private const uint CHECK_QUIESCENCE_MS = 60 * 1000; - /** Determines if the managed endpoint is currently reachable. */ - public bool is_reachable { get; private set; default = false; } + /** The address being monitored. */ + public NetworkAddress address { get; private set; default = null; } - // Weak to avoid a circular ref with the endpoint - private weak Endpoint endpoint; + /** Determines if the managed address is currently reachable. */ + public Trillian is_reachable { get; private set; default = Geary.Trillian.UNKNOWN; } + + /** + * Determines if a the address's network address name is valid. + * + * This will become certain if the address becomes reachable, and + * will become impossible if a fatal address error is reported. + */ + public Trillian is_valid { get; private set; default = Geary.Trillian.UNKNOWN; } private NetworkMonitor monitor; @@ -41,10 +48,21 @@ public class Geary.ConnectivityManager : BaseObject { /** - * Constructs a new manager for a specific endpoint. + * Fired when a fatal error was reported checking the address. + * + * This is typically caused by an an authoritative DNS name not + * found error, but may be anything else that indicates that the + * address will be unusable as-is without some kind of user or + * server administrator intervention. */ - public ConnectivityManager(Endpoint endpoint) { - this.endpoint = endpoint; + public signal void address_error_reported(Error error); + + + /** + * Constructs a new manager for a specific address. + */ + public ConnectivityManager(NetworkAddress address) { + this.address = address; this.monitor = NetworkMonitor.get_default(); this.monitor.network_changed.connect(on_network_changed); @@ -59,7 +77,7 @@ public class Geary.ConnectivityManager : BaseObject { } /** - * Starts checking if the manager's endpoint is reachable. + * Starts checking if the manager's address is reachable. * * This will cancel any existing check, and start a new one * running, updating the `is_reachable` property on completion. @@ -76,13 +94,12 @@ public class Geary.ConnectivityManager : BaseObject { Cancellable cancellable = new Cancellable(); this.existing_check = cancellable; - string endpoint = this.endpoint.to_string(); - bool is_reachable = this.is_reachable; + string endpoint = to_address_string(); + bool is_reachable = false; try { debug("Checking if %s reachable...", endpoint); is_reachable = yield this.monitor.can_reach_async( - this.endpoint.remote_address, - cancellable + this.address, cancellable ); this.next_check = get_real_time() + CHECK_QUIESCENCE_MS; } catch (Error err) { @@ -95,11 +112,19 @@ public class Geary.ConnectivityManager : BaseObject { is_reachable = true; debug("Assuming %s is reachable, despite network unavailability", endpoint); + } else if (err is ResolverError.TEMPORARY_FAILURE) { + // This often happens when networking is coming back + // online, may because the interface is up but has not + // been assigned an address yet? Since we should get + // another network change when the interface is + // configured, just ignore it. + debug("Ignoring: %s", err.message); } else if (!(err is IOError.CANCELLED)) { // Service is unreachable debug("Error checking %s reachable, treating as unreachable: %s", - endpoint, err.message); - is_reachable = false; + endpoint, err.message); + set_invalid(); + address_error_reported(err); } } finally { if (!cancellable.is_cancelled()) { @@ -137,7 +162,7 @@ public class Geary.ConnectivityManager : BaseObject { // // Otherwise, schedule a delayed check to work around the // issue in Bug 776042. - if (!this.is_reachable || + if (this.is_reachable.is_uncertain() || this.existing_check != null || this.next_check <= get_real_time()) { this.check_reachable.begin(); @@ -153,13 +178,38 @@ public class Geary.ConnectivityManager : BaseObject { private inline void set_reachable(bool reachable) { // Coalesce changes to is_reachable, since Vala <= 0.34 always // fires notify signals on set, even if the value doesn't - // change. 0.36 fixes that, so pull this out when we can + // change. 0.36 fixes that, so pull this test out when we can // depend on that as a minimum. - if (this.is_reachable != reachable) { + if ((reachable && !this.is_reachable.is_certain()) || + (!reachable && !this.is_reachable.is_impossible())) { debug("Host %s became %s", - this.endpoint.to_string(), reachable ? "reachable" : "unreachable"); - this.is_reachable = reachable; + this.address.to_string(), reachable ? "reachable" : "unreachable"); + this.is_reachable = reachable ? Trillian.TRUE : Trillian.FALSE; } + + // We only work out if the name is valid (or becomes valid + // again) if the address becomes reachable. + if (reachable && this.is_valid.is_uncertain()) { + this.is_valid = Trillian.TRUE; + } + } + private inline void set_invalid() { + // Coalesce changes to is_reachable, since Vala <= 0.34 always + // fires notify signals on set, even if the value doesn't + // change. 0.36 fixes that, so pull this method out when we can + // depend on that as a minimum. + if (this.is_valid != Trillian.FALSE) { + this.is_valid = Trillian.FALSE; + } + } + + private inline string to_address_string() { + // Unlikely to be the case, but if IPv6 format it nicely + return (this.address.hostname.index_of(":") == -1) + ? "%s:%u".printf(this.address.hostname, this.address.port) + : "[%s]:%u".printf(this.address.hostname, this.address.port); + } + } diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index 05a74e74..4513df64 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -30,6 +30,7 @@ set(RESOURCE_LIST STRIPBLANKS "main-toolbar.ui" STRIPBLANKS "main-toolbar-menus.ui" STRIPBLANKS "main-window.ui" + STRIPBLANKS "main-window-info-bar.ui" STRIPBLANKS "password-dialog.glade" STRIPBLANKS "preferences-dialog.ui" STRIPBLANKS "remove_confirm.glade" diff --git a/ui/geary.css b/ui/geary.css index cfeb72d3..d92ca396 100644 --- a/ui/geary.css +++ b/ui/geary.css @@ -6,6 +6,8 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ +/* MainWindow */ + .geary-folder-frame > border { border-left-width: 0; border-top-width: 0; @@ -39,6 +41,14 @@ border-top-left-radius: 0px; } +/* MainWindowInfoBarSet */ + +.geary-info-bar-frame > border { + border-top-width: 0; + border-left-width: 0; + border-right-width: 0; +} + /* FolderPopover */ row.geary-folder-popover-list-row { diff --git a/ui/main-window-info-bar.ui b/ui/main-window-info-bar.ui new file mode 100644 index 00000000..68179278 --- /dev/null +++ b/ui/main-window-info-bar.ui @@ -0,0 +1,153 @@ + + + + + + + True + False + 18 + 18 + 18 + 18 + 6 + 12 + + + True + True + start + baseline + 12 + True + If the problem is serious or persists, please copy and send these details to the <a href="https://wiki.gnome.org/Apps/Geary/Contact">mailing list</a> or file a <a href="https://wiki.gnome.org/Apps/Geary/ReportingABug">new bug report</a>. + True + True + 0 + + + 0 + 0 + + + + + True + False + start + baseline + Details: + False + + + 0 + 1 + + + + + 600 + 200 + True + True + True + True + in + + + True + True + True + True + False + word + 6 + 6 + 6 + 6 + False + True + + + + + 0 + 2 + + + + diff --git a/ui/main-window.ui b/ui/main-window.ui index 1ed5894b..e09e308a 100644 --- a/ui/main-window.ui +++ b/ui/main-window.ui @@ -1,69 +1,60 @@ + - +