From ed6b2bb786ffda3e420bbd2244ffb7397f2440f4 Mon Sep 17 00:00:00 2001 From: Evan Paterakis Date: Sun, 9 Feb 2025 02:47:21 +0200 Subject: [PATCH] feat: in-app browser (#1323) --- Makefile | 2 +- README.md | 1 + data/dev.geopjr.Tuba.gschema.xml | 3 + data/gresource.xml | 2 + .../tuba-channel-insecure-symbolic.svg | 5 + .../actions/tuba-view-refresh-symbolic.svg | 4 + data/ui/dialogs/main.ui | 4 +- meson.build | 13 +- meson_options.txt | 1 + src/API/Status/PreviewCard.vala | 19 +- src/Dialogs/MainWindow.vala | 23 + src/Services/Settings.vala | 4 +- src/Views/Browser.vala | 434 ++++++++++++++++++ src/Views/Profile.vala | 5 +- src/Views/meson.build | 4 + src/Widgets/RichLabel.vala | 31 +- 16 files changed, 532 insertions(+), 23 deletions(-) create mode 100644 data/icons/scalable/actions/tuba-channel-insecure-symbolic.svg create mode 100644 data/icons/scalable/actions/tuba-view-refresh-symbolic.svg create mode 100644 src/Views/Browser.vala diff --git a/Makefile b/Makefile index eb8fccbae..ff774a2ab 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: all install uninstall build test potfiles PREFIX ?= /usr -clapper ?= +clapper ?= 1 # Remove the devel headerbar style: # make release=1 release ?= diff --git a/README.md b/README.md index 7d67da538..c6dc7376e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ icu | ✅ libspelling | ❌ gstreamer + gst-plugins-good | ❌ clapper | ❌ +webkitgtk | ❌ diff --git a/data/dev.geopjr.Tuba.gschema.xml b/data/dev.geopjr.Tuba.gschema.xml index 53322da29..4afa72a9b 100644 --- a/data/dev.geopjr.Tuba.gschema.xml +++ b/data/dev.geopjr.Tuba.gschema.xml @@ -130,6 +130,9 @@ 3 + + true + 600 diff --git a/data/gresource.xml b/data/gresource.xml index 0cdafa414..5823065ed 100644 --- a/data/gresource.xml +++ b/data/gresource.xml @@ -78,6 +78,8 @@ icons/scalable/actions/tuba-quotation-symbolic.svg icons/scalable/actions/tuba-fish-symbolic.svg icons/scalable/actions/tuba-dock-right-symbolic.svg + icons/scalable/actions/tuba-channel-insecure-symbolic.svg + icons/scalable/actions/tuba-view-refresh-symbolic.svg gtk/help-overlay.ui diff --git a/data/icons/scalable/actions/tuba-channel-insecure-symbolic.svg b/data/icons/scalable/actions/tuba-channel-insecure-symbolic.svg new file mode 100644 index 000000000..774767668 --- /dev/null +++ b/data/icons/scalable/actions/tuba-channel-insecure-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/data/icons/scalable/actions/tuba-view-refresh-symbolic.svg b/data/icons/scalable/actions/tuba-view-refresh-symbolic.svg new file mode 100644 index 000000000..3248fe727 --- /dev/null +++ b/data/icons/scalable/actions/tuba-view-refresh-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/ui/dialogs/main.ui b/data/ui/dialogs/main.ui index 7bcc8ea12..c82730f4d 100644 --- a/data/ui/dialogs/main.ui +++ b/data/ui/dialogs/main.ui @@ -12,7 +12,7 @@ - + 0 @@ -35,4 +35,4 @@ - \ No newline at end of file + diff --git a/meson.build b/meson.build index 7ffa8fca5..9b77125ae 100644 --- a/meson.build +++ b/meson.build @@ -18,7 +18,6 @@ endif devel = get_option('devel') distro = get_option('distro') -clapper_support = get_option('clapper') # Setup configuration file config = configuration_data() @@ -78,6 +77,7 @@ asresources = gnome.compile_resources( ) gstreamer = false +webkit = false gtk_dep = dependency('gtk4', version: '>=4.13.4', required: true) libadwaita_dep = dependency('libadwaita-1', version: '>=1.5', required: true) gtksourceview_dep = dependency('gtksourceview-5', required: true, version: '>=5.6.0') @@ -86,6 +86,7 @@ libspelling = dependency('libspelling-1', required: false) clapper_dep = dependency('clapper-0.0', required: false, version: '>=0.8.0') clapper_gtk_dep = dependency('clapper-gtk-0.0', required: false) gstreamer_dep = dependency('gstreamer-1.0', required: false) +webkit_dep = dependency('webkitgtk-6.0', required: false) if not libwebp_dep.found () warning('WebP support might be missing, please install webp-pixbuf-loader.') @@ -104,13 +105,18 @@ if gstreamer_dep.found () gstreamer = true endif -if clapper_support and clapper_dep.found () and clapper_dep.version().version_compare('>=0.6.0') and clapper_gtk_dep.found () +if get_option('clapper') and clapper_dep.found () and clapper_dep.version().version_compare('>=0.6.0') and clapper_gtk_dep.found () add_project_arguments(['--define=CLAPPER'], language: 'vala') if (clapper_dep.get_variable('features').split().contains('mpris')) add_project_arguments(['--define=CLAPPER_MPRIS'], language: 'vala') endif endif +if get_option('in-app-browser') and webkit_dep.found () + add_project_arguments(['--define=WEBKIT'], language: 'vala') + webkit = true +endif + sources = files() subdir('src') @@ -129,7 +135,8 @@ final_deps = [ meson.get_compiler('c').find_library('m', required: false), clapper_dep, clapper_gtk_dep, - gstreamer_dep + gstreamer_dep, + webkit_dep ] executable( diff --git a/meson_options.txt b/meson_options.txt index 37b599b9d..13e1c6da9 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -1,3 +1,4 @@ option('devel', type: 'boolean', value: false) option('distro', type: 'boolean', value: false) option('clapper', type: 'boolean', value: true) +option('in-app-browser', type: 'boolean', value: true) diff --git a/src/API/Status/PreviewCard.vala b/src/API/Status/PreviewCard.vala index 14479ba63..bbc7f9432 100644 --- a/src/API/Status/PreviewCard.vala +++ b/src/API/Status/PreviewCard.vala @@ -166,7 +166,7 @@ public class Tuba.API.PreviewCard : Entity, Widgetizable { public void open_special_card () { if (this.tuba_uri == null) { - Host.open_url.begin (this.url); + open_url (this.url); return; } @@ -206,7 +206,7 @@ public class Tuba.API.PreviewCard : Entity, Widgetizable { return; #endif default: - Host.open_url.begin (this.url); + open_url (this.url); return; } @@ -232,13 +232,24 @@ public class Tuba.API.PreviewCard : Entity, Widgetizable { } break; default: - Host.open_url.begin (this.url); + open_url (this.url); break; } }) .on_error (() => { - Host.open_url.begin (this.url); + open_url (this.url); }) .exec (); } + + private void open_url (string url) { + #if WEBKIT + if (settings.use_in_app_browser_if_available && Views.Browser.can_handle_url (url)) { + app.main_window.open_in_app_browser_for_url (url); + return; + } + #endif + + Host.open_url.begin (url); + } } diff --git a/src/Dialogs/MainWindow.vala b/src/Dialogs/MainWindow.vala index ed8d3d35d..d5ed5df87 100644 --- a/src/Dialogs/MainWindow.vala +++ b/src/Dialogs/MainWindow.vala @@ -8,6 +8,10 @@ public class Tuba.Dialogs.MainWindow: Adw.ApplicationWindow, Saveable { [GtkChild] unowned Adw.Breakpoint breakpoint; [GtkChild] unowned Adw.ToastOverlay toast_overlay; + #if WEBKIT + [GtkChild] unowned Gtk.Overlay main_overlay; + #endif + public void set_sidebar_selected_item (int pos) { sidebar.set_sidebar_selected_item (pos); } @@ -165,6 +169,25 @@ public class Tuba.Dialogs.MainWindow: Adw.ApplicationWindow, Saveable { } } + #if WEBKIT + Gtk.Widget? browser_last_focused_widget = null; + public void open_in_app_browser_for_url (string url) { + browser_last_focused_widget = app.main_window.get_focus (); + var browser = new Views.Browser (); + browser.exit.connect (on_browser_exit); + browser.load_url (url); + main_overlay.add_overlay (browser); + browser.reveal_child = true; + } + + private void on_browser_exit (Views.Browser browser) { + main_overlay.remove_overlay (browser); + + browser_last_focused_widget.grab_focus (); + browser_last_focused_widget = null; + } + #endif + public Views.Base open_view (Views.Base view) { if ( ( diff --git a/src/Services/Settings.vala b/src/Services/Settings.vala index b86863d12..1995b7941 100644 --- a/src/Services/Settings.vala +++ b/src/Services/Settings.vala @@ -159,6 +159,7 @@ public class Tuba.Settings : GLib.Settings { public string last_contributors_update { get; set; } public string[] contributors { get; set; default = {}; } public int status_aria_verbosity { get; set; default = 3; } + public bool use_in_app_browser_if_available { get; set; } private static string[] keys_to_init = { "active-account", @@ -189,7 +190,8 @@ public class Tuba.Settings : GLib.Settings { "dim-trivial-notifications", "analytics", "update-contributors", - "status-aria-verbosity" + "status-aria-verbosity", + "use-in-app-browser-if-available" }; public Settings () { diff --git a/src/Views/Browser.vala b/src/Views/Browser.vala new file mode 100644 index 000000000..2b3d72fd3 --- /dev/null +++ b/src/Views/Browser.vala @@ -0,0 +1,434 @@ +public class Tuba.Views.Browser : Adw.Bin { + private class HeaderBar : Adw.Bin { + ~HeaderBar () { + debug ("Destroying Browser HeaderBar"); + } + + Gdk.RGBA color; + Adw.WindowTitle window_title; + Gtk.Image ssl_icon; + SimpleAction go_back_action; + SimpleAction go_forward_action; + + private const GLib.ActionEntry[] ACTION_ENTRIES = { + {"copy-url", on_copy_url}, + {"open-in-browser", on_open_in_browser}, + {"refresh", on_refresh} + }; + + public signal void refresh (); + public signal void go_back (); + public signal void go_forward (); + public signal void exit (); + + public enum Security { + SECURE, + INSECURE, + UNKNOWN; + } + + private Security _security = Security.UNKNOWN; + public Security security { + get { return _security; } + set { + if (value != _security) { + _security = value; + switch (value) { + case Security.UNKNOWN: + ssl_icon.visible = false; + return; + case Security.SECURE: + ssl_icon.icon_name = "tuba-padlock2-symbolic"; + ssl_icon.tooltip_text = _("Secure"); + break; + default: + ssl_icon.icon_name = "tuba-channel-insecure-symbolic"; + ssl_icon.tooltip_text = _("Insecure"); + break; + } + + ssl_icon.visible = true; + } + } + } + + public string title { + get { return window_title.title; } + set { window_title.title = value; } + } + + public string subtitle { + get { return window_title.subtitle; } + set { window_title.subtitle = value; } + } + + private double _progress = 0; + public double progress { + get { + return _progress; + } + + set { + _progress = value; + if (value == 1) _progress = 0; + this.queue_draw (); + } + } + + public bool can_go_back { + set { + go_back_action.set_enabled (value); + } + } + + public bool can_go_forward { + set { + go_forward_action.set_enabled (value); + } + } + + public override void snapshot (Gtk.Snapshot snapshot) { + snapshot.append_color ( + color, + Graphene.Rect () { + origin = Graphene.Point () { + x = 0, + y = 0 + }, + size = Graphene.Size () { + height = this.get_height (), + width = (float) (this.get_width () * this.progress) + } + } + ); + + base.snapshot (snapshot); + } + + private void update_accent_color () { + color = Adw.StyleManager.get_default ().get_accent_color_rgba (); + color.alpha = 0.5f; + if (progress != 0) this.queue_draw (); + } + + construct { + var default_sm = Adw.StyleManager.get_default (); + if (default_sm.system_supports_accent_colors) { + default_sm.notify["accent-color-rgba"].connect (update_accent_color); + update_accent_color (); + } else { + color = { + 120 / 255.0f, + 174 / 255.0f, + 237 / 255.0f, + 0.5f + }; + } + + window_title = new Adw.WindowTitle ("", ""); + var headerbar = new Adw.HeaderBar () { + show_start_title_buttons = false, + show_end_title_buttons = false, + title_widget = window_title + }; + + var back_btn = new Gtk.Button.from_icon_name (is_rtl ? "tuba-right-large-symbolic" : "tuba-left-large-symbolic") { + tooltip_text = _("Back") + }; + back_btn.clicked.connect (on_exit); + headerbar.pack_start (back_btn); + + ssl_icon = new Gtk.Image () { + visible = false + }; + headerbar.pack_start (ssl_icon); + + var actions = new GLib.SimpleActionGroup (); + actions.add_action_entries (ACTION_ENTRIES, this); + + go_back_action = new SimpleAction ("go-back", null); + go_back_action.activate.connect (on_go_back); + go_back_action.set_enabled (false); + actions.add_action (go_back_action); + + go_forward_action = new SimpleAction ("go-forward", null); + go_forward_action.activate.connect (on_go_forward); + go_forward_action.set_enabled (false); + actions.add_action (go_forward_action); + + this.insert_action_group ("browser", actions); + + var sub_menu_model = new GLib.Menu (); + var back_item = new GLib.MenuItem (_("Back"), "browser.go-back"); + back_item.set_attribute_value ("verb-icon", "tuba-left-large-symbolic"); + sub_menu_model.append_item (back_item); + + var refresh_item = new GLib.MenuItem (_("Refresh"), "browser.refresh"); + refresh_item.set_attribute_value ("verb-icon", "tuba-view-refresh-symbolic"); + sub_menu_model.append_item (refresh_item); + + var forward_item = new GLib.MenuItem (_("Forward"), "browser.go-forward"); + forward_item.set_attribute_value ("verb-icon", "tuba-right-large-symbolic"); + sub_menu_model.append_item (forward_item); + + var menu_section = new GLib.MenuItem.section (null, sub_menu_model); + menu_section.set_attribute_value ("display-hint", "horizontal-buttons"); + + var others_model = new GLib.Menu (); + others_model.append (_("Open in Browser"), "browser.open-in-browser"); + others_model.append (_("Copy URL"), "browser.copy-url"); + + var menu_model = new GLib.Menu (); + menu_model.append_item (menu_section); + menu_model.append_section (null, others_model); + + var menu_button = new Gtk.MenuButton () { + icon_name = "view-more-symbolic", + primary = true, + menu_model = menu_model, + tooltip_text = _("Menu") + }; + headerbar.pack_end (menu_button); + + this.child = headerbar; + } + + private void on_open_in_browser () { + Host.open_url.begin (this.subtitle); + } + + private void on_copy_url () { + Host.copy (this.subtitle); + app.toast (_("Copied url to clipboard")); + } + + private void on_refresh () { + refresh (); + } + + private void on_go_back () { + go_back (); + } + + private void on_go_forward () { + go_forward (); + } + + private void on_exit () { + exit (); + } + } + + const uint ANIMATION_DURATION = 250; + public override void snapshot (Gtk.Snapshot snapshot) { + var progress = this.animation.value; + if (progress == 1.0) { + base.snapshot (snapshot); + return; + } + + float width = (float) this.get_width (); + snapshot.translate (Graphene.Point () { + x = width - width * (float) progress, + y = 0 + }); + base.snapshot (snapshot); + } + + private void animation_target_cb (double value) { + this.queue_draw (); + } + + private void on_animation_end () { + if (reveal_child) { + this.grab_focus (); + } else { + exit (); + animation = null; // leaks without + } + } + + private bool _reveal_child = false; + public bool reveal_child { + get { + return _reveal_child; + } + + set { + if (_reveal_child == value) return; + animation.value_from = animation.value; + animation.value_to = value ? 1.0 : 0.0; + + _reveal_child = value; + animation.play (); + this.notify_property ("reveal-child"); + } + } + + ~Browser () { + debug ("Destroying Browser"); + } + + WebKit.WebView webview; + HeaderBar headerbar; + Adw.TimedAnimation animation; + + public new bool grab_focus () { + return this.webview.grab_focus (); + } + + public signal void exit (); + construct { + var target = new Adw.CallbackAnimationTarget (animation_target_cb); + animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, target) { + easing = Adw.Easing.EASE_IN_OUT_QUART + }; + animation.done.connect (on_animation_end); + + this.webview = new WebKit.WebView () { + vexpand = true, + hexpand = true + }; + + WebKit.Settings webkit_settings = new WebKit.Settings () { + default_font_family = Gtk.Settings.get_default ().gtk_font_name, + allow_file_access_from_file_urls = false, + allow_modal_dialogs = false, + allow_universal_access_from_file_urls = false, + auto_load_images = true, + disable_web_security = true, + javascript_can_open_windows_automatically = false, + enable_developer_extras = false, + enable_back_forward_navigation_gestures = true, + enable_dns_prefetching = false, + enable_fullscreen = true, + enable_media = true, + enable_media_capabilities = true, + enable_mediasource = true, + enable_site_specific_quirks = true, + enable_webaudio = true, + enable_webgl = true, + enable_webrtc = false, + enable_write_console_messages_to_stdout = false, + javascript_can_access_clipboard = false, + javascript_can_open_windows_automatically = false, + enable_html5_database = true, + enable_html5_local_storage = true, + enable_smooth_scrolling = true, + hardware_acceleration_policy = WebKit.HardwareAccelerationPolicy.NEVER + }; + + webkit_settings.set_user_agent_with_application_details (Build.NAME, Build.VERSION); + webview.settings = webkit_settings; + + Gtk.GestureClick back_click_gesture = new Gtk.GestureClick () { + button = 8 + }; + back_click_gesture.pressed.connect (on_go_back); + webview.add_controller (back_click_gesture); + + Gtk.GestureClick forward_click_gesture = new Gtk.GestureClick () { + button = 9 + }; + forward_click_gesture.pressed.connect (on_go_forward); + webview.add_controller (forward_click_gesture); + + headerbar = new HeaderBar (); + headerbar.go_back.connect (on_go_back); + headerbar.refresh.connect (on_refresh); + headerbar.go_forward.connect (on_go_forward); + headerbar.exit.connect (on_exit); + + var toolbar_view = new Adw.ToolbarView () { + css_classes = { "background" } + }; + toolbar_view.add_top_bar (headerbar); + + this.webview.bind_property ("title", headerbar, "title", BindingFlags.SYNC_CREATE); + this.webview.bind_property ("uri", headerbar, "subtitle", BindingFlags.SYNC_CREATE); + this.webview.web_context.set_cache_model (WebKit.CacheModel.DOCUMENT_BROWSER); + this.webview.bind_property ("estimated-load-progress", headerbar, "progress", BindingFlags.SYNC_CREATE); + this.webview.load_changed.connect (on_load_changed); + this.webview.network_session.download_started.connect (download_in_browser); + this.webview.decide_policy.connect (open_new_tab_in_browser); + + toolbar_view.content = this.webview; + this.child = toolbar_view; + } + + protected virtual void on_load_changed (WebKit.LoadEvent load_event) { + this.headerbar.can_go_forward = this.webview.can_go_forward (); + this.headerbar.can_go_back = this.webview.can_go_back (); + + switch (load_event) { + case WebKit.LoadEvent.FINISHED: + GLib.TlsCertificateFlags tls_errors; + bool secure = this.webview.get_tls_info (null, out tls_errors); + headerbar.security = secure && tls_errors == NO_FLAGS + ? HeaderBar.Security.SECURE + : HeaderBar.Security.INSECURE; + break; + default: + headerbar.security = HeaderBar.Security.UNKNOWN; + break; + } + } + + public static bool can_handle_uri (GLib.Uri uri) { + return uri.get_scheme ().has_prefix ("http"); + } + + public static bool can_handle_url (string url) { + return url.down ().has_prefix ("http"); + } + + public void load_url (string url) { + this.webview.load_uri (url); + } + + protected void download_in_browser (WebKit.Download download) { + download.cancel (); + Host.open_url.begin (download.get_request ().uri); + } + + protected bool open_new_tab_in_browser (WebKit.PolicyDecision decision, WebKit.PolicyDecisionType type) { + switch (type) { + case WebKit.PolicyDecisionType.NEW_WINDOW_ACTION: + WebKit.NavigationPolicyDecision navigation_decision = decision as WebKit.NavigationPolicyDecision; + if (navigation_decision == null) return false; + + load_url (navigation_decision.navigation_action.get_request ().uri); + navigation_decision.ignore (); + return true; + case WebKit.PolicyDecisionType.RESPONSE: + WebKit.ResponsePolicyDecision response_decision = decision as WebKit.ResponsePolicyDecision; + if ( + response_decision == null + || response_decision.is_mime_type_supported () + || response_decision.response.mime_type.has_prefix ("text/") + ) return false; + + Host.open_url.begin (response_decision.request.uri); + response_decision.ignore (); + return true; + default: + return false; + } + } + + private void on_go_back () { + this.webview.go_back (); + } + + private void on_refresh () { + this.webview.reload (); + } + + private void on_go_forward () { + this.webview.go_forward (); + } + + private void on_exit () { + this.reveal_child = false; + } +} diff --git a/src/Views/Profile.vala b/src/Views/Profile.vala index 5c93aa6a9..de6099e38 100644 --- a/src/Views/Profile.vala +++ b/src/Views/Profile.vala @@ -217,12 +217,13 @@ public class Tuba.Views.Profile : Views.Accounts { protected override void build_header () { base.build_header (); - menu_button = new Gtk.MenuButton (); + menu_button = new Gtk.MenuButton () { + icon_name = "view-more-symbolic" + }; var menu_builder = new Gtk.Builder.from_resource (@"$(Build.RESOURCES)ui/menus.ui"); var menu = "profile-menu"; menu_button.menu_model = menu_builder.get_object (menu) as MenuModel; menu_button.popover.width_request = 250; - menu_button.icon_name = "view-more-symbolic"; header.pack_end (menu_button); if (profile.account.is_self ()) { diff --git a/src/Views/meson.build b/src/Views/meson.build index 4872191f5..02e7cbada 100644 --- a/src/Views/meson.build +++ b/src/Views/meson.build @@ -35,4 +35,8 @@ sources += files( 'Timeline.vala', ) +if webkit + sources += files ('Browser.vala') +endif + subdir('Admin') diff --git a/src/Widgets/RichLabel.vala b/src/Widgets/RichLabel.vala index 2d3b99419..f7aa779ae 100644 --- a/src/Widgets/RichLabel.vala +++ b/src/Widgets/RichLabel.vala @@ -163,24 +163,35 @@ public class Tuba.Widgets.RichLabel : Adw.Bin { } catch (Error e) { warning (@"Failed to resolve URL \"$url\":"); warning (e.message); - if (uri == null) { - Host.open_url.begin (url); - } else { - Host.open_uri.begin (uri); - } + open_in_browser (url, uri); } }); } else { - if (uri == null) { - Host.open_url.begin (url); - } else { - Host.open_uri.begin (uri); - } + open_in_browser (url, uri); } return true; } + private void open_in_browser (string url, GLib.Uri? uri = null) { + #if WEBKIT + if (settings.use_in_app_browser_if_available) { + if ( + (uri != null && Views.Browser.can_handle_uri (uri)) + || Views.Browser.can_handle_url (url) + ) { + app.main_window.open_in_app_browser_for_url (url); + return; + } + } + #endif + if (uri == null) { + Host.open_url.begin (url); + } else { + Host.open_uri.begin (uri); + } + } + public static bool should_resolve_url (string url) { return settings.aggressive_resolving || url.index_of_char ('@') != -1