Mumbling about computers

Building a GTK based mobile app

2021-04-17 [ gtk python rust ]

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 with GtkEventBox, use a GtkButton (probably with flat 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 and listen 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.

blabla

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


  1. 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. 

  2. This is probably wrong on many levels, but I don't know any better 

  3. Went insane before finding that you can't use a cdylib crate for integration tests. 

  4. And I expect the pinephone to be another 2x slower - but it is still incredibly fast