dbus

tape
import dbus from "dbus";
import io from "io";

var bus: dbus.Bus = dbus.session() or {
    io.eprintln("no session bus: {err.desc()}");
    return 1;
};
defer bus.close();
var m: dbus.Message = dbus.method_call("org.freedesktop.Notifications", "/org/freedesktop/Notifications",
    "org.freedesktop.Notifications", "Notify");
m.body.text("my-app");               // app_name
m.body.uint32(0);                    // replaces_id
m.body.text("");                     // app_icon
m.body.text("Build finished");       // summary
m.body.text("All tests passed");     // body
m.body.begin_array("s");             // actions
m.body.end_array();
m.body.begin_dict("sv");             // hints
m.body.entry_sv("urgency", &dbus.uint8(1));
m.body.end_array();
m.body.int32(-1);                    // expire_timeout
let reply: dbus.Message = bus.call(&m) or { io.eprintln("{err.desc()}"); return 1; };
var r: dbus.Reader = reply.reader();
let id: u32 = r.uint32() or { 0 };
io.println("notification {id}");

dbus talks to the services of the Linux desktop over D-Bus: notifications, the desktop portal (file dialogs, opening URLs), the Secret Service, status icons, owning a name. It is written in tape (no libdbus) and comes in three layers:

  • a Bus is a connection to the session or the system bus over net’s Unix-domain sockets. Calls either wait for their reply (call) or are answered later (start_call); signals go to handlers by match rule; the objects you export answer calls through your handlers. poll never waits, so a ui program handles the bus from its event loop;
  • a Conn is the connection on its own: authentication, Hello and framing over bytes you feed in and send out;
  • the wire format: Writer and Reader marshal values by type signature, Value is a value as a tree, encode and decode turn a Message into bytes and back.

Not covered: passing file descriptors (fds ride as indices in the message but are never sent), the tcp: and launchd: transports, authentication other than EXTERNAL, and being a bus. Abstract sockets (the unix:abstract= address dbus-launch gives) work on Linux, through net.unix_abstract.

Connecting

tape
var bus: dbus.Bus = dbus.session() or { return 1; };   // DBUS_SESSION_BUS_ADDRESS, then $XDG_RUNTIME_DIR/bus
var sys: dbus.Bus = dbus.system() or { return 1; };    // /run/dbus/system_bus_socket
io.println("we are {bus.unique_name()}");              // ":1.42"
bus.close();

connect(address, opts) connects to the first entry of an address list that answers ("unix:path=/run/user/1000/bus;unix:path=/tmp/other"), authenticates and waits for Hello’s reply; start_connect returns as soon as the socket is open, and ready(), wait_ready(timeout) or any call finishes the handshake (messages you send meanwhile go after Hello). BusOptions holds the timeout (timeout, 25 s by default: the connect, the handshake and each blocking call) and the uid to authenticate as (uid; by default this process’s).

tape
var opts: dbus.BusOptions = dbus.bus_options();
opts.timeout = time.seconds(5);
var b: dbus.Bus = dbus.connect("unix:path=/run/user/1000/bus", opts) or {
    io.eprintln("{err.desc()}");
    return 1;
};

session_address() and system_address() give the address session() and system() use. Errors: NoBus when there is no address, Io from the socket (NotFound for a missing socket file, ConnectionRefused for a server that has gone; Unsupported for another transport, or an abstract socket on a platform other than Linux), AuthRejected, Wire when the bus breaks the protocol.

A Bus is not copied: keep it in one place, a module variable when your handlers and ui callbacks use it.

Messages

A Message has its header’s fields (kind, flags, serial, reply_serial, path, interface, member, error_name, destination, sender, unix_fds) and a body: m.body is a Writer you append the arguments to, in order.

tape
var m: dbus.Message = dbus.method_call("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop",
    "org.freedesktop.portal.OpenURI", "OpenURI");
m.body.text("");                        // parent window
m.body.text("https://tapelang.org");
m.body.begin_dict("sv");                // options: a{sv}
m.body.entry_sv("handle_token", &dbus.text("tape1"));
m.body.end_array();
io.println(m.signature());              // ssa{sv}
let s: dbus.Message = dbus.signal("/org/example/Thing", "org.example.Thing", "Changed");

The Writer has one method per type: uint8 (y), boolean (b), int16 (n), uint16 (q), int32 (i), uint32 (u), int64 (x), uint64 (t), double (d), text (s), object_path (o), signature_value (g), unix_fd (h), and containers: begin_array(elem) … end_array(), begin_dict("sv") for an a{sv} (its entries with begin_entry() … end_entry(), or entry_sv(key, &v) for a key and a variant), begin_struct() … end_struct(), begin_variant(sig) … end_variant(), and variant(&v) and value(&v) for a whole Value. At the top level the signature is built as you write; inside an array or a variant each value must be the declared type, and a value of another type traps (a bug in your code). Text that is not UTF-8, or a bad object path, is remembered instead (w.error()) and makes the message fail to send. In a string literal a { starts an interpolation, so a dict’s signature is written "a\{sv}": begin_dict("sv") saves you the braces.

method_return(&call) and error_reply(&call, name, text) answer a call; m.describe() is a line for logs as dbus-monitor prints it; m.expects_reply() says whether the sender waits for one.

m.encode() gives the bytes (after serial is set; Bus and Conn set it), dbus.decode(bytes) reads a message back and checks all of it, and dbus.message_size(bytes) says how long the message the bytes start with is (null before 16 bytes have come):

tape
m.serial = 7;
let bytes = m.encode() or { return 1; };
let back: dbus.Message = dbus.decode(bytes.slice()) or { return 1; };
io.println(back.describe());

Reading a message

m.reader() reads the body in order, each read naming the type it expects: another type there is an error (TypeMismatch), never a trap.

tape
var r: dbus.Reader = reply.reader();
let uris: dbus.Value = r.value() or { return 1; };   // a whole value as a tree
while (r.peek() != 0) { r.skip() or { return 1; }; }

text, object_path and signature_value return views into the message (valid while it is). Arrays: begin_array(), then read elements while more(), then end_array() (which skips what you did not read). Structs and dict entries: begin_struct() / begin_entry() … end_struct() / end_entry(). Variants: begin_variant() returns the value’s signature. peek() is the next value’s type code (0 at the end of a container), peek_signature() its whole type, at_end() whether nothing is left; values() reads every value left as trees, and m.values() the whole body. dbus.reader(data, sig, big) reads any bytes you have.

Values

A Value is any D-Bus value as a tree: Byte, Bool, Int16 … Uint64, Double, Str, ObjectPath, Signature, UnixFd, Array (with its element signature), Struct, Entry (a dict entry) and Variant.

tape
var hints: dbus.Value = dbus.dict("sv");            // a{sv}
hints.put("urgency", dbus.uint8(2));                // wrapped in a variant
hints.put("category", dbus.text("im.received"));
io.println(hints.signature());                      // a{sv}
let u: i64 = hints.lookup("urgency")!.as_i64()!;    // 2: lookup unwraps the variant
var pt: dbus.Value = dbus.structure();
pt.push(dbus.int32(3));
pt.push(dbus.int32(4));                             // (ii)
var list: dbus.Value = dbus.array("(ii)");
list.push(pt);
m.body.value(&list);

Constructors: uint8, boolean, int16, uint16, int32, uint32, int64, uint64, double, text, object_path, signature, unix_fd, array(elem), dict(kv), structure(), entry(k, v), variant(v). Reading one: as_text() (s, o, g), as_i64() (any integer that fits), as_u64(), as_bool(), as_f64(), items() (an array’s items, a struct’s fields, an entry’s key and value), inner() (a variant’s value), lookup(key) (in an a{s?}), signature(), and equals(&other).

Calling

tape
let reply: dbus.Message = bus.call(&m) or {
    io.eprintln("{err.name()}: {err.desc()}");   // an error reply is Remote: its name and message
    return 1;
};
let quick: dbus.Message = bus.call(&m, time.millis(500)) or { return 1; };   // TimedOut after it

call sends and waits for the reply (the bus’s timeout, or yours). Messages that arrive while it waits are kept for poll, where their handlers run.

start_call returns at once with the call’s serial; the reply goes to your handler when poll sees it (or a org.freedesktop.DBus.Error.NoReply error after the timeout):

tape
fn on_reply(ctx: *u8, bus: *dbus.Bus, reply: *const dbus.Message) {
    if (reply.kind == dbus.MessageType.Error) {
        io.println("failed: {reply.error_name}");
        return;
    }
    var r: dbus.Reader = reply.reader();
    let id: u32 = r.uint32() or { 0 };
    io.println("id {id}");
}

bus.start_call(&m, on_reply, null) or { return 1; };

With a null handler the reply comes out of poll instead (match it by reply_serial); cancel(serial) forgets a started call. send sends without waiting for anything (a signal, a reply, a call whose answer you don’t need); reply_error(&call, name, text) answers a call with an error.

Signals

tape
fn on_closed(ctx: *u8, bus: *dbus.Bus, m: *const dbus.Message) {
    var r: dbus.Reader = m.reader();
    let id: u32 = r.uint32() or { 0 };
    io.println("notification {id} closed");
}

let id: i64 = bus.subscribe(dbus.signal_rule("org.freedesktop.Notifications", "NotificationClosed"),
    on_closed, null) or { return 1; };
bus.unsubscribe(id) or {};

A Rule says which messages you want: kind (signals by default), sender, interface, member, path, path_namespace (a path and everything under it), destination, arg0 (the first argument, a string). subscribe asks the bus for them (AddMatch) and calls your handler for each one poll sees; add_match and remove_match only ask the bus, and the messages come out of poll. rule.text() is the rule as the bus reads it, rule.matches(&m) checks a message.

The desktop portal

A portal call returns a Request object’s path at once, and its answer comes later as that object’s Response signal. The path is known before the call (from your unique name and the handle_token you pass), so subscribe first:

tape
let path: string = dbus.portal_request_path(bus.unique_name(), "tape1");
var rule: dbus.Rule = dbus.signal_rule("org.freedesktop.portal.Request", "Response");
rule.path = path;
bus.subscribe(rule, on_response, null) or { return 1; };   // on_response reads "ua{sv}": 0 is success
// then the call, with options a{sv} holding handle_token: "tape1"

portal_call does all of it and waits for the answer:

tape
let token: string = bus.portal_token();
var m: dbus.Message = dbus.method_call(dbus.PORTAL_NAME, dbus.PORTAL_PATH,
    "org.freedesktop.portal.FileChooser", "OpenFile");
m.body.text("");                                   // the parent window
m.body.text("Attach");
m.body.begin_dict("sv");
m.body.entry_sv("handle_token", &dbus.text(token));
m.body.end_array();
let r: dbus.PortalResponse = bus.portal_call(&m, token, null) or {
    if (err.service_missing()) { io.println("no portal"); }
    return 1;
};
if (r.response == 0) { io.println(r.results.lookup("uris")!.items()[0].as_text()!); }

For any other signal that answers a call (a Secret Service prompt’s Completed), catch(rule) before the call and wait_caught(id, timeout) after it. err.service_missing() says the service is not there at all (no bus, or no one owns its name).

The notify, dialog, secrets, tray and launch modules use dbus on Linux; a program calls them rather than these services itself.

In a ui program

poll reads what has come and runs the handlers without waiting, so a ui program watches the bus’s socket and dispatches when it is readable:

tape
var g_bus: dbus.Bus;

fn on_bus(h: i64) {
    g_bus.dispatch() or { ui.unwatch_socket(h); };
}

g_bus = dbus.session() or { return 1; };
ui.watch_socket(g_bus.handle(), net.READABLE, on_bus) or { return 1; };

A component puts ui.Watch { handle: g_bus.handle(); want: net.READABLE; on_ready: on_bus_ready; } in its view instead. dispatch() polls until nothing is left and drops what no handler took; poll() returns those messages to you one by one, and wait(timeout) waits for one. After a blocking call, the messages it kept may not wake the loop (the socket has already been read): buffered() is then true, so call dispatch() after a call.

The shared session connection

The desktop modules (notify, dialog, secrets, tray, launch) share one connection to the session bus, and your program can use it too rather than opening its own:

tape
fn bus_watch(h: i64) { ui.watch(h, dbus.shared_ready) or {}; }
fn bus_unwatch(h: i64) { ui.unwatch(h); }

let bus: *dbus.Bus = dbus.shared_session() or { return 1; };
bus.subscribe(dbus.signal_rule("org.example.Player", "Changed"), on_changed, null) or { return 1; };
let hold: i64 = dbus.shared_hold(bus_watch, bus_unwatch);   // dispatched in the ui loop from now on
// ...
dbus.shared_release(hold);
FunctionDoes
shared_session() -> *BusThe connection, made the first time (and again after it failed or closed)
shared_generation() -> i64How many connections it has made: a new one has none of the old one’s subscriptions or exports, so make them again when this changes
shared_open() -> boolIt is there and alive (nothing connects)
shared_hold(watch, unwatch) -> i64Have it dispatched in the ui loop: your watch(h) registers ui.watch(h, dbus.shared_ready) (ui.watch_socket on Windows), unwatch(h) removes it. However many hold it, it is watched once; the last shared_release removes the watch
shared_ready(h)The watch’s callback: dispatches it (every user’s handlers run) and makes it again if it has gone
shared_wake(), shared_wake_handle()Make shared_ready run at the loop’s next turn; the handle that does it
shared_close()End it (the next shared_session makes another)

The rules that let several users share it:

  • Use handlers. Whoever dispatches it runs everybody’s handlers and drops what no handler takes, so subscribe, export, catch or start calls with an on_reply; don’t wait for poll to hand you a message.
  • Export at paths of your own: two exports at one path and interface are one.
  • A handler doesn’t block. It may send (a reply, a signal), but a call or wait_caught inside a handler would dispatch the connection inside its own dispatch. Queue the work and do it from the ui loop.
  • Never close it. bus.close() traps on it; it belongs to dbus.
  • Blocking calls lose nothing. A file dialog or a keyring prompt waits on it for as long as the user takes, dispatching as it waits: a tray menu still answers, a notification’s click is queued. What a call keeps while it waits is dispatched at the loop’s next turn.
  • The main thread’s. It is a module variable of dbus, which a thread fn can’t reach.

Exporting objects

An Interface at a path answers the calls made to it through your handlers. Introspect, Peer.Ping and the Properties interface are answered for you:

tape
var g_tip: string = "3 unread";

fn item_call(ctx: *u8, bus: *dbus.Bus, call: *const dbus.Message) -> bool {
    if (call.member != "Activate") { return false; }   // UnknownMethod is sent
    var r: dbus.Message = dbus.method_return(call);
    bus.send(&r) or {};
    return true;
}

fn item_get(ctx: *u8, name: []const u8, w: *dbus.Writer) -> bool {
    if (name == "Title") { w.variant(&dbus.text("My app")); return true; }
    if (name == "Status") { w.variant(&dbus.text("Active")); return true; }
    return false;                                       // UnknownProperty
}

let props: [2][]const u8 = ["Title", "Status"];
bus.export(dbus.Interface {
    path: "/StatusNotifierItem"; name: "org.kde.StatusNotifierItem";
    xml: " <interface name=\"org.kde.StatusNotifierItem\">...</interface>\n";
    properties: props[0..2]; on_call: item_call; on_get: item_get; .. });
bus.request_name("org.kde.StatusNotifierItem-4242-1", 0) or { return 1; };

on_call replies (send of a method_return, or reply_error; now or later) and returns true, or returns false for a method it does not have. on_get writes a property as one variant; on_set(ctx, name, &value) sets one (without it every property is read-only); properties are the names GetAll reports, xml the interface’s part of the introspection data, ctx your pointer for the handlers. properties_changed(path, name, names) emits PropertiesChanged with the values on_get gives now. unexport(path, name) removes an interface. request_name(name, flags) and release_name(name) own a well-known name (NAME_PRIMARY_OWNER, NAME_IN_QUEUE, NAME_EXISTS, NAME_ALREADY_OWNER; flags NAME_ALLOW_REPLACEMENT, NAME_REPLACE_EXISTING, NAME_DO_NOT_QUEUE).

Addresses

tape
let list = dbus.parse_address("unix:abstract=/tmp/dbus-1,guid=7f;unix:path=/run/user/1000/bus") or { return 1; };
io.println(list.get_ref(1).get("path")!);                 // /run/user/1000/bus
let ep: net.Endpoint = list.get_ref(1).endpoint() or { return 1; };

An Address is a transport and its keys (get(key), values unescaped); endpoint() is where a unix:path= (or unix:runtime=yes) address connects.

The connection engine

Conn is the connection without a socket: it authenticates (AUTH EXTERNAL with your uid in hex), says Hello, numbers and frames messages. You send output(), call advance_output(n) for what was sent, feed what arrives, and take messages from next():

tape
var c: dbus.Conn = dbus.conn(dbus.ConnOptions { uid: 1000; .. });
// write c.output() to your transport: "\0AUTH EXTERNAL 31303030\r\n"
c.advance_output(c.output().len);
c.feed(received) or { return 1; };          // "OK <guid>\r\n": BEGIN and Hello are queued
let serial: u32 = c.send(&m) or { return 1; };
let next: ?dbus.Message = c.next() or { return 1; };

phase() is Auth, Hello, Open or Closed; unique_name() the name Hello gave, guid() the server’s. ConnOptions has uid (-1 sends no identity), unix_fds (negotiate fd passing; unix_fds() says whether it was agreed), hello (false for a peer-to-peer connection) and max_message. end_of_input() says the transport ended; has_message() says a whole message waits. auth_identity(uid) is the hex the authentication sends.

Errors

The wire’s problems are WireError values (Truncated, BadEndian, BadVersion, BadType, UnknownType, TooLarge, BadPadding, BadBool, BadString, BadObjectPath, BadSignature, BadName, MissingField, BadField, BadArray, TypeMismatch, TooDeep, BadSerial, BodyMismatch, BadAuth, BadAddress), each with a description. A Conn or Bus fails with a DbusError: Io { e }, Wire { e }, AuthRejected, Remote { name, message } (an error reply; remote_error(&m) makes one from a message), Disconnected, NoBus; desc() reads it and name() is a Remote’s error name. Once the connection fails, every later call returns the same error.

valid_signature, single_type, single_element, valid_object_path, valid_interface, valid_member and valid_bus_name check text by the specification’s rules. The limits are MAX_MESSAGE (128 MiB), MAX_ARRAY (64 MiB), MAX_SIGNATURE (255) and MAX_DEPTH (64); the header flags NO_REPLY_EXPECTED, NO_AUTO_START and ALLOW_INTERACTIVE_AUTHORIZATION; BUS_NAME, BUS_PATH and BUS_INTERFACE are the bus’s own.

One thread uses a Bus or a Conn at a time.

Last modified: