Skip to content

WIP: GObject/promise-based D-Bus convenience

Florian Müllner requested to merge fmuellner/gjs:wip/dbus-promise into master

First things first: This is the result of a night's hacking and in no way intended to be merged. It is a topic worth discussing though, and as I have some code, I decided on creating a MR over opening an issue.

Recently I have been working on a color picker D-Bus API in GNOME Shell. It's mainly intended for the Desktop Portal, which is what applications are supposed to talk to. Therefore I have based my testing on that usage, and it turned out to be a stark reminder of the pain points I see in the current D-Bus convenience layer:

  • copying an XML interface description into your sources and escaping all line endings is tedious
  • the convenience layer is pretty thin - you have to use separate methods to connect/disconnect D-Bus signals and use the g-properties-changed signal instead of notify to get notifications about property changes
  • it's all async, which feels increasingly clunky after learning about promises

I'm unsure what to do about the first item:

  • decide that it's not that bad and keep it as is - people who are bothered by it can stuff the XML into a GResource and load it from there or something
  • as above, but include some convenience API for loading an interface info in gjs/GIO
  • fetch the description dynamically using the org.freedesktop.DBus.Introspectable interface; sounds neat off-hand, but we'd have to call Introspect from every instance, and only after the object has been constructed, so signals and properties would still not be "real" object signals/properties
  • create a gdbus-codgen-like generator for javascript

But after a bit of playing around, I think that we can improve significantly on the two other pain points (if not fix then altogether) by embracing both GObject subclassing and promises.

The code in this MR provides an alternative D-Bus convenience layer that I cobbled together to do exactly that. Here is what my test script now looks like using it (note in particular the startup vfunc and the onButtonClicked handler:

A small sample application
imports.gi.versions.Gdk = '3.0';
imports.gi.versions.Gtk = '3.0';

const { Gdk, Gio, GLib, GObject, Gtk } = imports.gi;

const DESKTOP_PORTAL_NAME = 'org.freedesktop.portal.Desktop';
const DESKTOP_PORTAL_PATH = '/org/freedesktop/portal/desktop';

const REQUEST_IFACE = 'org.freedesktop.portal.Request';
const SCREENSHOT_IFACE = 'org.freedesktop.portal.Screenshot';

function lookupInterface(iface) {
    for (let dir of GLib.get_system_data_dirs()) {
        let pathElements = [dir, 'dbus-1', 'interfaces', `${iface}.xml`];
        let file = Gio.File.new_for_path(GLib.build_filenamev(pathElements));
        if (!file.query_exists(null))
            continue;

        let [, xml] = file.load_contents(null);
        return xml.toString();
    }
    return null;
}

var ScreenshotProxy = Gio.DBusProxy.createProxySubclass(
    lookupInterface(SCREENSHOT_IFACE)
);
var ResponseProxy = Gio.DBusProxy.createProxySubclass(
    lookupInterface(REQUEST_IFACE)
);

var Application = GObject.registerClass({
}, class Application extends Gtk.Application {
    _init() {
        super._init({ application_id: 'org.gnome.fmuellner.PickColor' });

        this._cssProvider = null;

        this._handleToken = `PickColor${(1000 * Math.random()).toFixed()}`;
        this._responseId = 0;
        this._portalProxy = null;
        this._responseProxy = null;
    }

    async vfunc_startup() {
        super.vfunc_startup();

        try {
            this._portalProxy = await ScreenshotProxy.new(
                Gio.DBus.session,
                Gio.DBusProxyFlags.NONE,
                DESKTOP_PORTAL_NAME,
                DESKTOP_PORTAL_PATH,
                null
            );
            this._responseProxy = await ResponseProxy.new(
                Gio.DBus.session,
                Gio.DBusProxyFlags.NONE,
                DESKTOP_PORTAL_NAME,
                this._handlePath,
                null
            );
            this._responseId = this._responseProxy.connect('response',
                this._onRequestResponse.bind(this)
            );
        } catch (e) {
            log('Failed to connect to Screenshot Portal');
        }
    }

    vfunc_shutdown() {
        if (this._responseId)
            this._responseProxy.disconnect(this._responseId);
        this._responseId = 0;

        super.vfunc_shutdown();
    }

    _onRequestResponse(proxy, response, result) {
        if (response == 1)
            return; // canceled

        if (response != 0) {
            log('Something went wrong');
            return;
        }

        let { color } = result.deep_unpack();
        let [red, green, blue] = color.deep_unpack();
        let col = new Gdk.RGBA({ red, green, blue, alpha: 1. });

        let styleContext = this.active_window.get_style_context();
        if (this._cssProvider)
            styleContext.remove_provider(this._cssProvider);
        else
            this._cssProvider = new Gtk.CssProvider();

        this._cssProvider.load_from_data(
            `window { background-color: ${col.to_string()}; }`
        );
        styleContext.add_provider(
            this._cssProvider,
            Gtk.STYLE_PROVIDER_PRIORITY_USER
        );
    }

    async _onButtonClicked() {
        let handle_token = new GLib.Variant('s', this._handleToken);
        try {
            let path = await this._portalProxy.pickColor({ handle_token });
            if (path != this._handlePath)
                throw new Error('Unexpected response from portal');
        } catch (e) {
            log(e.message);
        }
    }

    vfunc_activate() {
        let window = this.get_active_window();

        if (!window) {
            window = new Gtk.ApplicationWindow({
                application: this,
                defaultWidth: 600,
                defaultHeight: 400
            });

            let button = new Gtk.Button({
                label: 'Pick Color',
                visible: true,
                halign: Gtk.Align.CENTER,
                valign: Gtk.Align.CENTER
            });
            button.connect('clicked', this._onButtonClicked.bind(this));
            window.add(button);
        }

        window.present();
    }

    get _handlePath() {
        if (!this._portalProxy)
            return null;

        let senderName = this._portalProxy.gConnection.uniqueName;
        return GLib.build_filenamev([
            DESKTOP_PORTAL_PATH,
            'request',
            senderName.replace(/^:/, '').replace(/\./g, '_'),
            this._handleToken
        ]);
    }
});

GLib.set_prgname('Color Picker');
GLib.set_application_name('Color Picker');

new Application().run(null);

I now wish all my code could look like this 😄

But while a generic promisization of our introspected async APIs looks non-trivial and potentially risky, it looks like something worth pursuing and the D-Bus convenience is a good starting point IMHO: It's an override rather than baked into the GI bridge, its main entry point - makeProxyWrapper() - is gjs-specific rather than tweaking introspected API, and if we keep the existing convenience API and add an alternative, it's a non-disruptive change that people can adapt to at their own pacing and gradually.

Comments? Am I crazy?

Merge request reports