Building a GTK based mobile app
I ordered a pinephone and while waiting for it I wanted to see how hard it'd be to write basic mobile apps for myself.
I picked HN as it's a very simple site/api.
Features:
- Code blocks
- Embedded browser
- Reader mode
- Ad blocker
Lessons learned:
- Use a resource bundle for images / styles / etc.
- Use ui files instead of programmatically adding all widgets
- Connect the signals from the ui file (see example)
- Use resources from the bundle directly on the ui file (see example)
- Using grids for content that is not homogeneous is a bad idea, it is better to use boxes-of-boxes.
- Do not use
GtkImage
along withGtkEventBox
, use aGtkButton
(probably withflat
class). - Use libhandy for mobile-friendly widgets (or libadwaita if you are from the future and it's stable).
- GTK Inspector is your friend.
- There's no general/defined way to connect events between widgets that are not direct parent-children. I went for a
global bus on which any widget can
emit
andlisten
for events.
Here's a very minimal example app that takes all of these into account, this is what I'd have liked to see as a "starting point" on the tutorials I've read. You can find the source code here.
The resources.xml
file has to be compiled with glib-compile-resources resources.xml --target resources
import gi
gi.require_version("Handy", "1")
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, Gio, Handy
Handy.init() # Must call this otherwise the Template() calls don't know how to resolve any Hdy* widgets
# You definitely want to read this from `pkg_resources`
glib_data = GLib.Bytes.new(open("resources", "rb").read())
resource = Gio.Resource.new_from_data(glib_data)
resource._register()
@Gtk.Template(resource_path='/example/MainWindow.ui')
class AppWindow(Handy.ApplicationWindow):
__gtype_name__ = 'AppWindow'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.show_all()
self.setup_styles()
def setup_styles(self):
css_provider = Gtk.CssProvider()
context = Gtk.StyleContext()
screen = Gdk.Screen.get_default()
css_provider.load_from_resource('/example/example.css')
context.add_provider_for_screen(screen, css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
class Application(Gtk.Application):
def __init__(self, *args, **kwargs):
super().__init__(*args, application_id="example.app", **kwargs)
def do_activate(self):
self.window = AppWindow(application=self, title="An Example App")
self.window.present()
app = Application()
app.run()
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.2 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<requires lib="libhandy" version="1.0"/>
<template class="AppWindow" parent="HdyApplicationWindow">
<property name="can_focus">False</property>
<property name="default_width">360</property>
<property name="default_height">720</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="vexpand">true</property>
<child>
<object class="GtkButton">
<property name="label">Hello world</property>
</object>
</child>
<child>
<object class="GtkButton">
<property name="label">Another button</property>
</object>
</child>
</object>
</child>
</template>
</interface>
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/example">
<file>example.css</file>
<file>MainWindow.ui</file>
</gresource>
</gresources>
This is what it looks like:
Adding a reader-mode button to the embedded browser
A feature I frequently miss when using embedded browsers is Firefox' reader mode button.
Apparently this is just some bits of javascript so it should not be too hard to get an embedded browser to execute
them on demand.
In the webkit docs we can see that, while a bit clunky, it is reasonable to use the callback-based APIs to execute some javascript:
Call run_javascript_from_gresource(resource, None, callback, None)
on the WebView instance and get called back at
callback
with a resource
from which you can extract a result (via run_javascript_from_gresource_finish
), a
complete example, showing how to get results from js functions:
def on_readerable_result(resource, _result, user_data):
result = www.run_javascript_finish(result)
if result.get_js_value().to_boolean():
print("It was reader-able")
def fn(resource, _result, user_data):
result = resource.run_javascript_from_gresource_finish(result)
js = 'isProbablyReaderable(document);'
www.run_javascript(js, None, on_readerable_result, None)
www.run_javascript_from_gresource('/hn/js/Readability.js', None, fn, None)
This works great1
Adding an ad blocker to the embedded browser
Once you are using an embedded browser, you realize how much you miss Firefox' ad-blocking extensions, so I set out to try and implement something similar (although, quite basic).
WebKit2 does not expose a direct way to block requests, see here. You need to build a WebExtension shared object, which webkit can be instructed to load at runtime and that WebExtension can process / reject requests.
A WebExtension is exposed via a fairly simple api which allows us to connect to the few signals we are interested in:
- The
WebExtension
is initialized - A
WebPage
object is created - An
UriRequest
is about to be sent
The most basic possible example is available here, as a small C program.
As I do not feel like I can write any amount of C code, I set out to build the extension in Rust, which offers a relatively easy way to interop with C via bindgen.
Generating bindings
Bog standard bindgen use, following the tutorial:
File headers.h
#include <gtk/gtk.h>
#include <webkit2/webkit-web-extension.h>
Whitelist what I wanted in build.rs
let bindings = bindgen::Builder::default()
// The input header we would like to generate
// bindings for.
.whitelist_function("g_signal_connect_object")
.whitelist_function("webkit_uri_request_get_uri")
.whitelist_function("webkit_web_page_get_id")
.whitelist_function("webkit_web_page_get_uri")
.blacklist_type("GObject")
.whitelist_type("GCallback")
.whitelist_type("WebKitWebPage")
.whitelist_type("WebKitURIRequest")
.whitelist_type("WebKitURIResponse")
.whitelist_type("gpointer")
.whitelist_type("WebKitWebExtension")
Add search paths
let gtk = pkg_config::probe_library("gtk+-3.0").unwrap();
let gtk_pathed = gtk
.include_paths
.iter()
.map(|x| format!("-I{}", x.to_string_lossy()));
bindings.clang_args(webkit_pathed);
Connecting signals
With the bindings generated we only need to connect the 3 required signals to our rust code, here's one as an example2:
#[no_mangle]
extern "C" fn webkit_web_extension_initialize(extension: *mut WebKitWebExtension) {
unsafe {
g_signal_connect(
extension as *mut c_void,
CStr::from_bytes_with_nul_unchecked(b"page-created\0").as_ptr(),
Some(mem::transmute(web_page_created_callback as *const ())),
0 as *mut c_void,
);
};
wk_adblock::init_ad_list();
}
Implementing the ad-blocker
We now have WebKit calling init_ad_list
once, when initializing the web-extension (this is our actual entry point to the
extension logic) and is_ad(uri)
before every request.
The ad-blocking logic is quite straight forward, requests should be blocked if
- The domain in the request considered 'bad'
- Any of the 'bad' URL fragments are present in the URL
Luckily a lot of people compile lists for both of these criteria. I've used the pgl lists.
Benchmarking implementations
I spent a while3 getting a benchmarking solution, Criterion, to work with my crate. When it finally did, I compared the performance of a few algorithms:
For domain matching:
- A trie with reversed domains, as bytes (
b'google.com'
->b'moc.elgoog'
) - A trie with reversed domains, as string arrays (
['google', 'com']
->['com', 'google']
) - The Aho-Corasick algorithm for substring matching
For url-fragment matching:
- The Twoway algorithm for substring matching (both on bytes and on &str)
- The Aho-Corasick algorithm for substring matching
- Rust's
contains
(on &str) - A very naive
window match
on bytes (compare every n-sized window of bytes with target)
The results really, really surprised me. The input files are ~19k lines for the subdomain bench and ~5k lines for the fragment bench.
URL fragment benches
All methods are relatively similar at ~450us, except Aho-Corasick at 180ns (!!), clear winner.
Subdomain benches
I'd expected the trie implementation to be fast (and I was quite happy when I saw the ~30us).. but the Aho-Corasick algorithm is again at 140ns which is mind-blowing.
These timings are on my desktop pc, running on an Odroid C2 they are ~5x slower (subdomain benches clock at 850ns, 165us, 685us)4.
The result
-
Although it is a tad slow on a test device (2013 Nexus 5). I might evaluate later the performance of calling a rust implementation instead, and whether that's worth it or not. ↩
-
This is probably wrong on many levels, but I don't know any better ↩
-
Went insane before finding that you can't use a cdylib crate for integration tests. ↩
-
And I expect the pinephone to be another 2x slower - but it is still incredibly fast ↩