diff --git a/README.md b/README.md index 340fb06..124adbc 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,18 @@ Simple [Mastodon](https://github.com/tootsuite/mastodon) client for Linux 1. Make sure you have these dependencies: - Package Name | Required Version - --- |:---: - meson | 0.50 - valac | 0.48 - libglib-2.0-dev | 2.30.0 - libjson-glib-dev | 1.4.4 - libxml2-dev | 2.9.10 - libgee-0.8-dev | 0.8.5 - libsoup2.4-dev | 2.64 - libgtk-3-dev | 3.22.0 - - The following packages are also required, but will be installed automatically if not present in your system: - - Package Name | Required Version - --- |:---: - libhandy-1.0-dev | 1.0.0 + Package Name | Required Version | Notes + --- |:---:| --- + meson | 0.50 | + valac | 0.48 | + libglib-2.0-dev | 2.30 | + libjson-glib-dev | 1.4.4 | + libxml2-dev | 2.9.10 | + libgee-0.8-dev | 0.8.5 | + libsoup2.4-dev | 2.64 | + libgtk-3-dev | 3.22 | + libhandy-1.0-dev | 1.0.0 | Will be attempted to install automatically if not present. + libsecret-1-dev | 0.20 | Optional. Used for storing accounts using Secret Service API. 2. Run `install.sh` in the project directory. The app will launch automatically on success. diff --git a/meson.build b/meson.build index b90bdfe..a07f92d 100644 --- a/meson.build +++ b/meson.build @@ -1,37 +1,11 @@ project('com.github.bleakgrey.tootle', 'vala', 'c', version: '1.0.0') -config = configuration_data() -config.set('EXEC_NAME', meson.project_name()) -config.set('GETTEXT_PACKAGE', meson.project_name()) -config.set('RESOURCES', '/' + '/'.join(meson.project_name().split('.')) + '/' ) -config.set('VERSION', meson.project_version()) -config.set('PREFIX', get_option('prefix')) -config.set('NAME', 'Tootle') -config.set('WEBSITE', 'https://github.com/bleakgrey/tootle') -config.set('SUPPORT_WEBSITE', 'https://github.com/bleakgrey/tootle/issues') -config.set('COPYRIGHT', '© 2018-2020 bleak_grey') - -gnome = import('gnome') -i18n = import('i18n') - add_global_arguments([ '-DGETTEXT_PACKAGE="@0@"'.format(meson.project_name()), ], language: 'c', ) -asresources = gnome.compile_resources( - 'as-resources', 'data/gresource.xml', - source_dir: 'data', - c_name: 'as' -) - -build_file = configure_file( - input: 'src/Build.vala', - output: 'Build.vala', - configuration: config -) - libhandy_dep = dependency('libhandy-1', version: '>= 1.0', required: false) if not libhandy_dep.found() libhandy = subproject( @@ -51,21 +25,41 @@ if not libhandy_dep.found() ) endif -executable( - meson.project_name(), - asresources, - build_file, +config = configuration_data() +config.set('EXEC_NAME', meson.project_name()) +config.set('GETTEXT_PACKAGE', meson.project_name()) +config.set('RESOURCES', '/' + '/'.join(meson.project_name().split('.')) + '/' ) +config.set('VERSION', meson.project_version()) +config.set('PREFIX', get_option('prefix')) +config.set('NAME', 'Tootle') +config.set('WEBSITE', 'https://github.com/bleakgrey/tootle') +config.set('SUPPORT_WEBSITE', 'https://github.com/bleakgrey/tootle/issues') +config.set('COPYRIGHT', '© 2018-2020 bleak_grey') +config.set('ACCOUNT_STORE', 'FileAccountStore') + +gnome = import('gnome') +i18n = import('i18n') + +asresources = gnome.compile_resources( + 'as-resources', 'data/gresource.xml', + source_dir: 'data', + c_name: 'as' +) + +sources = files( 'src/Application.vala', 'src/Desktop.vala', 'src/Drawing.vala', 'src/Html.vala', 'src/Request.vala', 'src/DateTime.vala', - 'src/InstanceAccount.vala', + 'src/Services/Accounts/InstanceAccount.vala', + 'src/Services/Accounts/AccountStore.vala', + 'src/Services/Accounts/FileAccountStore.vala', + 'src/Services/Accounts/IAccountListener.vala', + 'src/Services/Accounts/Mastodon/MastodonAccount.vala', 'src/Services/Streams.vala', 'src/Services/Settings.vala', - 'src/Services/Accounts.vala', - 'src/Services/IAccountListener.vala', 'src/Services/IStreamListener.vala', 'src/Services/Cache.vala', 'src/Services/Network.vala', @@ -122,6 +116,27 @@ executable( 'src/Views/Hashtag.vala', 'src/Views/Lists.vala', 'src/Views/List.vala', +) + +libsecret_dep = dependency('libsecret-1', required: false) +if not libsecret_dep.found() + warning('Keyring support disabled. Accounts will be stored in a text file.') +else + config.set('ACCOUNT_STORE','SecretAccountStore') + sources += 'src/Services/Accounts/'+config.get('ACCOUNT_STORE')+'.vala' +endif + +build_file = configure_file( + input: 'src/Build.vala.in', + output: 'Build.vala', + configuration: config +) + +executable( + meson.project_name(), + asresources, + build_file, + sources, dependencies: [ dependency('gtk+-3.0', version: '>=3.22.0'), dependency('glib-2.0', version: '>=2.30.0'), @@ -130,6 +145,7 @@ executable( dependency('json-glib-1.0', version: '>=1.4.4'), dependency('libxml-2.0'), libhandy_dep, + libsecret_dep, ], install: true, ) diff --git a/meson/post_install.py b/meson/post_install.py old mode 100644 new mode 100755 diff --git a/src/API/Notification.vala b/src/API/Notification.vala index 7a5cb62..392b77b 100644 --- a/src/API/Notification.vala +++ b/src/API/Notification.vala @@ -11,12 +11,6 @@ public class Tootle.API.Notification : Entity, Widgetizable { } public Soup.Message? dismiss () { - if (kind == NotificationType.WATCHLIST) { - if (accounts.active.cached_notifications.remove (this)) - accounts.save (); - return null; - } - if (kind == NotificationType.FOLLOW_REQUEST) return reject_follow_request (); diff --git a/src/API/NotificationType.vala b/src/API/NotificationType.vala index c3f4420..4b8cee8 100644 --- a/src/API/NotificationType.vala +++ b/src/API/NotificationType.vala @@ -4,8 +4,7 @@ public enum Tootle.API.NotificationType { REBLOG_REMOTE_USER, // Internal FAVOURITE, FOLLOW, - FOLLOW_REQUEST, // Internal - WATCHLIST; // Internal + FOLLOW_REQUEST; // Internal public string to_string () { switch (this) { @@ -21,8 +20,6 @@ public enum Tootle.API.NotificationType { return "follow"; case FOLLOW_REQUEST: return "follow_request"; - case WATCHLIST: - return "watchlist"; default: warning (@"Unknown notification type: $this"); return ""; @@ -43,8 +40,6 @@ public enum Tootle.API.NotificationType { return FOLLOW; case "follow_request": return FOLLOW_REQUEST; - case "watchlist": - return WATCHLIST; default: throw new Oopsie.INSTANCE (@"Unknown notification type: $str"); } @@ -64,8 +59,6 @@ public enum Tootle.API.NotificationType { return _("%s now follows you").printf (account.url, account.display_name); case FOLLOW_REQUEST: return _("%s wants to follow you").printf (account.url, account.display_name); - case WATCHLIST: - return _("%s posted a status").printf (account.url, account.display_name); default: warning (@"Unknown notification type: $this"); return ""; @@ -75,7 +68,6 @@ public enum Tootle.API.NotificationType { public string get_icon () { switch (this) { case MENTION: - case WATCHLIST: return "user-available-symbolic"; case REBLOG: case REBLOG_REMOTE_USER: diff --git a/src/Application.vala b/src/Application.vala index 3b2c9b9..2b74786 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -15,7 +15,7 @@ namespace Tootle { public static Window window_dummy; public static Settings settings; - public static Accounts accounts; + public static AccountStore accounts; public static Network network; public static Cache cache; public static Streams streams; @@ -26,14 +26,13 @@ namespace Tootle { // These are used for the GTK Inspector public Settings app_settings { get {return Tootle.settings; } } - public Accounts app_accounts { get {return Tootle.accounts; } } + public AccountStore app_accounts { get {return Tootle.accounts; } } public Network app_network { get {return Tootle.network; } } public Cache app_cache { get {return Tootle.cache; } } public Streams app_streams { get {return Tootle.streams; } } public signal void refresh (); public signal void toast (string title); - public signal void error (string title, string? text); public CssProvider css_provider = new CssProvider (); public CssProvider zoom_css_provider = new CssProvider (); @@ -84,33 +83,36 @@ namespace Tootle { protected override void startup () { base.startup (); - Build.print_info (); - Hdy.init (); + try { + Build.print_info (); + Hdy.init (); - settings = new Settings (); - streams = new Streams (); - accounts = new Accounts (); - network = new Network (); - cache = new Cache (); - accounts.init (); + settings = new Settings (); + streams = new Streams (); + network = new Network (); + cache = new Cache (); + accounts = Build.get_account_store (); + accounts.init (); - app.error.connect ((title, msg) => { - inform (Gtk.MessageType.ERROR, title, msg); - }); + css_provider.load_from_resource (@"$(Build.RESOURCES)app.css"); + StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), zoom_css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); - window_dummy = new Window (); - add_window (window_dummy); - - css_provider.load_from_resource (@"$(Build.RESOURCES)app.css"); - StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); - StyleContext.add_provider_for_screen (Gdk.Screen.get_default (), zoom_css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + window_dummy = new Window (); + add_window (window_dummy); + } + catch (Error e) { + var msg = _("Could not start application: %s").printf (e.message); + inform (Gtk.MessageType.ERROR, _("Error"), msg); + error (msg); + } set_accels_for_action ("app.about", ACCEL_ABOUT); set_accels_for_action ("app.compose", ACCEL_NEW_POST); set_accels_for_action ("app.back", ACCEL_BACK); set_accels_for_action ("app.refresh", ACCEL_REFRESH); set_accels_for_action ("app.search", ACCEL_SEARCH); - set_accels_for_action ("app.switch-timeline(0)", ACCEL_TIMELINE_0); + set_accels_for_action ("app.switch-timeline(0)", ACCEL_TIMELINE_0); //TODO: There's no action for handling these set_accels_for_action ("app.switch-timeline(1)", ACCEL_TIMELINE_1); set_accels_for_action ("app.switch-timeline(2)", ACCEL_TIMELINE_2); set_accels_for_action ("app.switch-timeline(3)", ACCEL_TIMELINE_3); @@ -138,10 +140,11 @@ namespace Tootle { } public void present_window () { - if (accounts.is_empty ()) { + if (accounts.saved.is_empty) { message ("Presenting NewAccount dialog"); if (new_account_window == null) new Dialogs.NewAccount (); + new_account_window.present (); } else { message ("Presenting MainWindow"); @@ -152,7 +155,7 @@ namespace Tootle { } public bool on_window_closed () { - if (!settings.work_in_background || accounts.is_empty ()) + if (!settings.work_in_background || accounts.saved.is_empty) app.remove_window (window_dummy); return false; } diff --git a/src/Build.vala b/src/Build.vala.in similarity index 92% rename from src/Build.vala rename to src/Build.vala.in index 37690aa..0a5cdfc 100644 --- a/src/Build.vala +++ b/src/Build.vala.in @@ -38,4 +38,8 @@ public class Build { return GLib.Environment.get_os_info (key) ?? "Unknown"; } + public static Tootle.AccountStore get_account_store () { + return new Tootle.@ACCOUNT_STORE@ (); + } + } diff --git a/src/Desktop.vala b/src/Desktop.vala index 81484ca..f7fb95a 100644 --- a/src/Desktop.vala +++ b/src/Desktop.vala @@ -21,7 +21,7 @@ public class Tootle.Desktop { } catch (Error e){ warning (@"xdg-open failed too: $(e.message)"); - app.error (_("Open this URL in your browser"), uri); + app.inform (Gtk.MessageType.WARNING, _("Open this URL in your browser"), uri); return false; } } diff --git a/src/Dialogs/NewAccount.vala b/src/Dialogs/NewAccount.vala index 9d6f270..1c60d40 100644 --- a/src/Dialogs/NewAccount.vala +++ b/src/Dialogs/NewAccount.vala @@ -93,16 +93,15 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window { async void step () throws Error { if (stack.visible_child == done_step) { - if (accounts.is_empty ()) - accounts.switch_account (0); - app.present_window (); destroy (); return; } - if (stack.visible_child == instance_step) + if (stack.visible_child == instance_step) { setup_instance (); + yield accounts.guess_backend (account); + } if (account.client_secret == null || account.client_id == null) { yield register_client (); @@ -113,7 +112,7 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window { } void setup_instance () throws Error { - message ("Checking instance URL"); + message ("Checking instance"); var str = instance_entry.text .replace ("/", "") @@ -156,7 +155,7 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window { } async void request_token () throws Error { - if (code_entry.text.char_count () <= 10) + if (code_entry.text.char_count () <= 1) throw new Oopsie.USER (_("Please enter a valid authorization code")); message ("Requesting access token"); @@ -175,20 +174,18 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window { if (account.access_token == null) throw new Oopsie.INSTANCE (_("Instance failed to authorize the access token")); - message ("Trying to get the user profile"); - var profile_req = new Request.GET ("/api/v1/accounts/verify_credentials") - .with_account (account); - yield profile_req.await (); + yield account.verify_credentials (); - var node = network.parse_node (profile_req); - var profile = API.Account.from (node); - account.patch (profile); + account = accounts.create_account (account.to_json ()); message ("Saving account"); accounts.add (account); hello_label.label = _("Hello, %s!").printf (account.handle); stack.visible_child = done_step; + + message ("Switching to account"); + accounts.activate (account); } public void redirect (string uri) { @@ -231,3 +228,4 @@ public class Tootle.Dialogs.NewAccount: Hdy.Window { } } + diff --git a/src/Services/Accounts.vala b/src/Services/Accounts.vala deleted file mode 100644 index 63d66b9..0000000 --- a/src/Services/Accounts.vala +++ /dev/null @@ -1,141 +0,0 @@ -using Gee; - -public class Tootle.Accounts : GLib.Object { - - private string dir_path; - private string file_path; - - public ArrayList saved { get; set; default = new ArrayList (); } - public InstanceAccount? active { get; set; } - - construct { - dir_path = @"$(GLib.Environment.get_user_config_dir ())/$(app.application_id)"; - file_path = @"$dir_path/accounts.json"; - } - - public void switch_account (int id) { - var acc = saved.@get (id); - message (@"Switching to $(acc.handle)..."); - new Request.GET ("/api/v1/accounts/verify_credentials") - .with_account (acc) - .then ((sess, mess) => { - var node = network.parse_node (mess); - var updated = API.Account.from (node); - acc.patch (updated); - message ("OK: Token is valid"); - active = acc; - settings.current_account = id; - }) - .on_error ((code, reason) => { - warning ("Token invalid!"); - app.error ( - _("Network Error"), - _("The instance has invalidated this session. Please sign in again.\n\n%s").printf (reason) - ); - }) - .exec (); - } - - public void add (InstanceAccount account) { - message (@"Adding new account: $(account.handle)"); - saved.add (account); - save (); - switch_account (saved.size - 1); - account.subscribe (); - } - - public void remove (InstanceAccount account) { - account.unsubscribe (); - saved.remove (account); - saved.notify_property ("size"); - - if (saved.size < 1) - active = null; - else { - var id = settings.current_account - 1; - if (id > saved.size - 1) - id = saved.size - 1; - else if (id < saved.size - 1) - id = 0; - switch_account (id); - } - save (); - - if (is_empty ()) - new Dialogs.NewAccount (); - } - - public bool is_empty () { - return saved.size == 0; - } - - public void init () { - save (false); - load (); - - if (!is_empty ()) - switch_account (settings.current_account); - } - - public void save (bool overwrite = true) { - try { - var dir = File.new_for_path (dir_path); - if (!dir.query_exists ()) - dir.make_directory (); - - var file = File.new_for_path (file_path); - if (file.query_exists () && !overwrite) - return; - - var builder = new Json.Builder (); - builder.begin_array (); - saved.foreach ((acc) => { - var node = acc.to_json (); - builder.add_value (node); - return true; - }); - builder.end_array (); - - var generator = new Json.Generator (); - generator.set_root (builder.get_root ()); - var data = generator.to_data (null); - - if (file.query_exists ()) - file.@delete (); - - FileOutputStream stream = file.create (FileCreateFlags.PRIVATE); - stream.write (data.data); - message ("Saved accounts"); - } - catch (Error e){ - warning (e.message); - } - } - - private void load () { - try { - uint8[] data; - string etag; - var file = File.new_for_path (file_path); - file.load_contents (null, out data, out etag); - var contents = (string) data; - - var parser = new Json.Parser (); - parser.load_from_data (contents, -1); - var array = parser.get_root ().get_array (); - - array.foreach_element ((_arr, _i, node) => { - var account = InstanceAccount.from (node); - if (account != null) { - saved.add (account); - account.subscribe (); - } - }); - message (@"Loaded $(saved.size) accounts"); - } - catch (Error e){ - warning (e.message); - } - } - -} diff --git a/src/Services/Accounts/AccountStore.vala b/src/Services/Accounts/AccountStore.vala new file mode 100644 index 0000000..f1b4759 --- /dev/null +++ b/src/Services/Accounts/AccountStore.vala @@ -0,0 +1,146 @@ +using Gee; + +public abstract class Tootle.AccountStore : GLib.Object { + + public ArrayList saved { get; set; default = new ArrayList (); } + public InstanceAccount? active { get; set; default = null; } + + // TODO: Make settings.current_account a string + public bool ensure_active_account () { + var has_active = false; + + if (!saved.is_empty) { + if (settings.current_account > saved.size || settings.current_account <= 0) + settings.current_account = 0; + + var last_account = saved[settings.current_account]; + if (active != last_account) { + activate (last_account); + has_active = true; + } + } + + if (!has_active) + app.present_window (); + + return has_active; + } + + public virtual void init () throws GLib.Error { + Mastodon.Account.register (this); + + load (); + ensure_active_account (); + } + + public abstract void load () throws GLib.Error; + public abstract void save () throws GLib.Error; + public void safe_save () { + try { + save (); + } + catch (GLib.Error e) { + warning (e.message); + app.inform (Gtk.MessageType.ERROR, _("Error"), e.message); + } + } + + public virtual void add (InstanceAccount account) throws GLib.Error { + message (@"Adding new account: $(account.handle)"); + saved.add (account); + save (); + account.subscribe (); + ensure_active_account (); + } + + public virtual void remove (InstanceAccount account) throws GLib.Error { + message (@"Removing account: $(account.handle)"); + account.unsubscribe (); + saved.remove (account); + saved.notify_property ("size"); + save (); + + var id = settings.current_account - 1; + if (saved.size < 1) + active = null; + else { + if (id > saved.size - 1) + id = saved.size - 1; + else if (id < saved.size - 1) + id = 0; + } + settings.current_account = id; + + ensure_active_account (); + } + + public void activate (InstanceAccount account) { + message (@"Activating $(account.handle)..."); + account.verify_credentials.begin ((obj, res) => { + try { + account.verify_credentials.end (res); + account.error = null; + } + catch (Error e) { + warning (@"Couldn't activate account $(account.handle):"); + warning (e.message); + account.error = e; + } + }); + + accounts.active = account; + settings.current_account = accounts.saved.index_of (account); + } + + [Signal (detailed = true)] + public signal InstanceAccount? create_for_backend (Json.Node node); + + public InstanceAccount create_account (Json.Node node) throws GLib.Error { + var obj = node.get_object (); + var backend = obj.get_string_member ("backend"); + var handle = obj.get_string_member ("handle"); + var account = create_for_backend[backend] (node); + if (account == null) + throw new Oopsie.INTERNAL (@"Account $handle has unknown backend: $backend"); + + return account; + } + + // This is a super overcomplicated way and I don't like this. + // I just want to store an array with functions that return + // a "string?" value and keep the first non-null one. + // + // I figured signals with GSignalAccumulator could be + // useful here, but Vala doesn't support that either. + // + // So here we go. Vala bad. No cookie. + public abstract class BackendTest : GLib.Object { + + public abstract string? get_backend (Json.Object obj); + + } + + public Gee.ArrayList backend_tests = new Gee.ArrayList (); + + public async void guess_backend (InstanceAccount account) throws GLib.Error { + var req = new Request.GET ("/api/v1/instance") + .with_account (account); + yield req.await (); + + var root = network.parse (req); + + string? backend = null; + backend_tests.foreach (test => { + backend = test.get_backend (root); + return true; + }); + + if (backend == null) + throw new Oopsie.INTERNAL ("This instance is unsupported."); + else { + account.backend = backend; + message (@"$(account.instance) is using $(account.backend)"); + } + } + +} diff --git a/src/Services/Accounts/FileAccountStore.vala b/src/Services/Accounts/FileAccountStore.vala new file mode 100644 index 0000000..aa2d73e --- /dev/null +++ b/src/Services/Accounts/FileAccountStore.vala @@ -0,0 +1,66 @@ +using Gee; + +public class Tootle.FileAccountStore : AccountStore { + + string dir_path; + string file_path; + + construct { + dir_path = @"$(GLib.Environment.get_user_config_dir ())/$(app.application_id)"; + file_path = @"$dir_path/accounts.json"; + } + + public override void load () throws GLib.Error { + uint8[] data; + string etag; + var file = File.new_for_path (file_path); + file.load_contents (null, out data, out etag); + var contents = (string) data; + + var parser = new Json.Parser (); + parser.load_from_data (contents, -1); + var array = parser.get_root ().get_array (); + + array.foreach_element ((arr, i, node) => { + try { + var account = accounts.create_account (node); + saved.add (account); + } + catch (Error e) { + warning (@"Couldn't load account $i: $(e.message)"); + } + }); + + message (@"Loaded $(saved.size) accounts"); + } + + public override void save () throws GLib.Error { + var dir = File.new_for_path (dir_path); + if (!dir.query_exists ()) + dir.make_directory_with_parents (); + + var file = File.new_for_path (file_path); + + var builder = new Json.Builder (); + builder.begin_array (); + saved.foreach ((acc) => { + var node = acc.to_json (); + builder.add_value (node); + return true; + }); + builder.end_array (); + + var generator = new Json.Generator (); + generator.set_root (builder.get_root ()); + var data = generator.to_data (null); + + if (file.query_exists ()) + file.@delete (); + + FileOutputStream stream = file.create (FileCreateFlags.PRIVATE); + stream.write (data.data); + + message (@"Saved $(saved.size) accounts"); + } + +} diff --git a/src/Services/IAccountListener.vala b/src/Services/Accounts/IAccountListener.vala similarity index 95% rename from src/Services/IAccountListener.vala rename to src/Services/Accounts/IAccountListener.vala index 8c22b8d..bab756c 100644 --- a/src/Services/IAccountListener.vala +++ b/src/Services/Accounts/IAccountListener.vala @@ -1,7 +1,6 @@ +[Deprecated] public interface Tootle.IAccountListener : GLib.Object { - //TODO: Refactor into AccountHolder - protected void account_listener_init () { accounts.notify["active"].connect (_on_active_acc_update); accounts.saved.notify["size"].connect (_on_saved_accs_update); diff --git a/src/InstanceAccount.vala b/src/Services/Accounts/InstanceAccount.vala similarity index 80% rename from src/InstanceAccount.vala rename to src/Services/Accounts/InstanceAccount.vala index b7d748b..4bf91cf 100644 --- a/src/InstanceAccount.vala +++ b/src/Services/Accounts/InstanceAccount.vala @@ -3,10 +3,12 @@ using Gee; public class Tootle.InstanceAccount : API.Account, IStreamListener { + public string? backend { set; get; } public string? instance { get; set; } public string? client_id { get; set; } public string? client_secret { get; set; } public string? access_token { get; set; } + public Error? error { get; set; } public int64 last_seen_notification { get; set; default = 0; } public bool has_unread_notifications { get; set; default = false; } @@ -18,30 +20,22 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener { owned get { return @"@$username@$domain"; } } - public new static InstanceAccount from (Json.Node node) throws Error { - return Entity.from_json (typeof (InstanceAccount), node) as InstanceAccount; - } - - public InstanceAccount () { + construct { on_notification.connect (show_notification); } - ~InstanceAccount () { - unsubscribe (); - } public InstanceAccount.empty (string instance){ Object (id: "", instance: instance); } - - public InstanceAccount.from_account (API.Account account) { - Object (id: account.id); - patch (account); + ~InstanceAccount () { + unsubscribe (); } public bool is_current () { return accounts.active.access_token == access_token; } + // TODO: This should be IStreamable public string get_stream_url () { return @"$instance/api/v1/streaming/?stream=user&access_token=$access_token"; } @@ -54,6 +48,17 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener { streams.unsubscribe (stream, this); } + public async void verify_credentials () throws Error { + var req = new Request.GET ("/api/v1/accounts/verify_credentials").with_account (this); + yield req.await (); + + var node = network.parse_node (req); + var updated = API.Account.from (node); + patch (updated); + + message (@"$handle: profile updated"); + } + public async Entity resolve (string url) throws Error { message (@"Resolving URL: \"$url\"..."); var results = yield API.SearchResults.request (url, this); @@ -62,6 +67,7 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener { return entity; } + // TODO: notification actions void show_notification (API.Notification obj) { var title = HtmlUtils.remove_tags (obj.kind.get_desc (obj.account)); var notification = new GLib.Notification (title); @@ -74,11 +80,6 @@ public class Tootle.InstanceAccount : API.Account, IStreamListener { } app.send_notification (app.application_id + ":" + obj.id.to_string (), notification); - - if (obj.kind == API.NotificationType.WATCHLIST) { - cached_notifications.add (obj); - accounts.save (); - } } } diff --git a/src/Services/Accounts/Mastodon/MastodonAccount.vala b/src/Services/Accounts/Mastodon/MastodonAccount.vala new file mode 100644 index 0000000..e1cb25f --- /dev/null +++ b/src/Services/Accounts/Mastodon/MastodonAccount.vala @@ -0,0 +1,22 @@ +public class Tootle.Mastodon.Account : InstanceAccount { + + public const string BACKEND = "Mastodon"; + + class Test : AccountStore.BackendTest { + + public override string? get_backend (Json.Object obj) { + return BACKEND; // Always treat instances as compatible with Mastodon + } + + } + + public static void register (AccountStore store) { + store.backend_tests.add (new Test ()); + store.create_for_backend[BACKEND].connect ((node) => { + var account = Entity.from_json (typeof (Account), node) as Account; + account.backend = BACKEND; + return account; + }); + } + +} diff --git a/src/Services/Accounts/SecretAccountStore.vala b/src/Services/Accounts/SecretAccountStore.vala new file mode 100644 index 0000000..d683639 --- /dev/null +++ b/src/Services/Accounts/SecretAccountStore.vala @@ -0,0 +1,110 @@ +using Secret; + +public class Tootle.SecretAccountStore : AccountStore { + + const string VERSION = "1"; + + Secret.Schema schema; + GLib.HashTable schema_attributes; + + public override void init () throws GLib.Error { + message (@"Using libsecret v$(Secret.MAJOR_VERSION).$(Secret.MINOR_VERSION).$(Secret.MICRO_VERSION)"); + + schema_attributes = new GLib.HashTable (str_hash, str_equal); + schema_attributes["login"] = SchemaAttributeType.STRING; + schema_attributes["version"] = SchemaAttributeType.STRING; + schema = new Secret.Schema.newv ( + Build.DOMAIN, + Secret.SchemaFlags.NONE, + schema_attributes + ); + + base.init (); + } + + public override void load () throws GLib.Error { + var attrs = new GLib.HashTable (str_hash, str_equal); + attrs["version"] = VERSION; + + var secrets = Secret.password_searchv_sync ( + schema, + attrs, + Secret.SearchFlags.ALL, + null + ); + + secrets.foreach (item => { + var account = secret_to_account (item); + if (account != null) + saved.add (account); + }); + + message (@"Loaded $(saved.size) accounts"); + } + + public override void save () throws GLib.Error { + saved.foreach (account => { + account_to_secret (account); + return true; + }); + message (@"Saved $(saved.size) accounts"); + } + + public override void remove (InstanceAccount account) throws GLib.Error { + base.remove (account); + + var attrs = new GLib.HashTable (str_hash, str_equal); + attrs["version"] = VERSION; + attrs["login"] = account.handle; + + Secret.password_clearv_sync ( + schema, + attrs, + null + ); + } + + void account_to_secret (InstanceAccount account) { + var attrs = new GLib.HashTable (str_hash, str_equal); + attrs["login"] = account.handle; + attrs["version"] = VERSION; + + var generator = new Json.Generator (); + generator.set_root (account.to_json ()); + var secret = generator.to_data (null); + var label = _("%s Account").printf (account.backend); + + try { + Secret.password_storev_sync ( + schema, + attrs, + Secret.COLLECTION_DEFAULT, + label, + secret, + null + ); + } + catch (GLib.Error e) { + warning (e.message); + app.inform (Gtk.MessageType.ERROR, _("Error"), e.message); + } + + message (@"Saved secret for $(account.handle)"); + } + + InstanceAccount? secret_to_account (Secret.Retrievable item) { + InstanceAccount? account = null; + try { + var secret = item.retrieve_secret_sync (); + var contents = secret.get_text (); + var parser = new Json.Parser (); + parser.load_from_data (contents, -1); + account = accounts.create_account (parser.get_root ()); + } + catch (GLib.Error e) { + warning (e.message); + } + return account; + } + +} diff --git a/src/Services/Network.vala b/src/Services/Network.vala index fa2839c..dee8620 100644 --- a/src/Services/Network.vala +++ b/src/Services/Network.vala @@ -20,7 +20,6 @@ public class Tootle.Network : GLib.Object { session = new Soup.Session (); session.ssl_strict = true; session.ssl_use_system_ca_file = true; - session.timeout = 15; session.request_unqueued.connect (msg => { requests_processing--; if (requests_processing <= 0) diff --git a/src/Services/Streams.vala b/src/Services/Streams.vala index cf6e767..355ad34 100644 --- a/src/Services/Streams.vala +++ b/src/Services/Streams.vala @@ -30,7 +30,7 @@ public class Tootle.Streams : Object { } public bool start () { - info (@"Opening stream: $name"); + message (@"Opening stream: $name"); network.session.websocket_connect_async.begin (msg, null, null, null, (obj, res) => { socket = network.session.websocket_connect_async.end (res); socket.error.connect (on_error); @@ -69,7 +69,7 @@ public class Tootle.Streams : Object { GLib.Timeout.add_seconds (timeout, start); timeout = int.min (timeout*2, 6); } - warning (@"Closing stream: $name"); + message (@"Closing stream: $name"); } void on_message (int i, Bytes bytes) { diff --git a/src/Views/Notifications.vala b/src/Views/Notifications.vala index 6c14eaa..c9cd1a9 100644 --- a/src/Views/Notifications.vala +++ b/src/Views/Notifications.vala @@ -25,7 +25,7 @@ public class Tootle.Views.Notifications : Views.Timeline, IAccountListener, IStr needs_attention = false; account.has_unread_notifications = false; account.last_seen_notification = last_id; - accounts.save (); + accounts.safe_save (); } } @@ -39,7 +39,7 @@ public class Tootle.Views.Notifications : Views.Timeline, IAccountListener, IStr needs_attention = has_unread () && !current; if (needs_attention) - accounts.save (); + accounts.safe_save (); } public override void on_account_changed (InstanceAccount? acc) { diff --git a/src/Widgets/AccountsButton.vala b/src/Widgets/AccountsButton.vala index 462fbca..3df784e 100644 --- a/src/Widgets/AccountsButton.vala +++ b/src/Widgets/AccountsButton.vala @@ -43,7 +43,13 @@ public class Tootle.Widgets.AccountsButton : Gtk.MenuButton, IAccountListener { ); if (forget) { button.active = false; - accounts.remove (account); + try { + accounts.remove (account); + } + catch (Error e) { + warning (e.message); + app.inform (Gtk.MessageType.ERROR, _("Error"), e.message); + } } } @@ -130,8 +136,9 @@ public class Tootle.Widgets.AccountsButton : Gtk.MenuButton, IAccountListener { var account = accounts.saved.@get (i); if (accounts.active == account) return; + else + accounts.activate (account); - accounts.switch_account (i); popover.popdown (); } diff --git a/src/Widgets/Attachment/Slot.vala b/src/Widgets/Attachment/Slot.vala index 3b12db5..64eea77 100644 --- a/src/Widgets/Attachment/Slot.vala +++ b/src/Widgets/Attachment/Slot.vala @@ -48,7 +48,7 @@ public class Tootle.Widgets.Attachment.Slot : FlowBoxChild { Desktop.open_uri (path); } catch (Error e) { - app.error (_("Error"), e.message); + app.inform (Gtk.MessageType.ERROR, _("Error"), e.message); } }); }