ENOSUCHBLOG

Programming, philosophy, pedaling.


Introducing i3.cr

Jan 6, 2018     Tags: crystal, programming, workflow    

This post is at least a year old.

This is a writeup for my second Crystal library: i3.cr.

Background

i3.cr is a Crystal interface for i3, a popular tiling window manager. i3 provides an IPC server for interaction, allowing clients to read window manager’s container tree and workspaces, as well as issue commands for managing i3 itself.

The protocol used by i3’s IPC server is simple: it’s a magic field (currently “i3-ipc”) followed by a standard type-length-value protocol, with the value being a fixed-structure JSON object corresponding to the type.

Implementation

Messages are at the core of i3.cr. They’re not exposed directly to users, but are exchanged with the IPC server via an event loop and marshaled into custom Crystal classes (like I3::Message::Workspace) for the user to interact with.

The simplicity of i3’s IPC protocol makes it easy to manage in Crystal via a custom Message class by implementing Message.from_io and Message#to_io.

Excerpted:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Message
  def self.from_io(io, format = Fmt)
    magic = io.gets(MAGIC.size)

    raise Error.new("missing or malformed message magic") unless magic && magic == MAGIC

    length = io.read_bytes Int32, format
    typeno = io.read_bytes Int32, format
    payload = io.gets(length)

    type = if (typeno >> 31).zero?
             MessageType.new(typeno)
           else
             EventType.new(typeno & 0x7F)
           end

    raise Error.new("missing or malformed payload") unless payload && payload.bytesize == length

    new(type, payload)
  end

  def to_io(io, format = Fmt)
    io << MAGIC
    io.write_bytes length, format
    io.write_bytes type.to_i, format
    io << payload
  end
end

These two methods allow the IO class to serialize and deserialize Message instances via IO#read_bytes and IO#write_bytes:

1
2
3
4
5
# send a message over the wire
socket.write_bytes message

# ...and read a response
response = socket.read_bytes Message

Installation and usage

The easiest way to install i3.cr is via Crystal’s shards dependency manager. You can find the relevant steps in the repository README.

Like x_do.cr, i3.cr provides a convenient act method, which yields an I3::Connection:

1
2
3
I3.act do |con|
 # operate on the i3 connection
end

Alternatively, users can create and manage a connection explicitly:

1
2
3
con = I3::Connection.new
# operate on the i3 connection
con.close

Note that explicitly creating a connection requires you to close it when finished, while the block form handles resource cleanup for you.

In terms of actual operations, the entire i3 IPC protocol is supported. You can issue commands:

1
2
3
4
5
# move to workspace #2
con.command "workspace 2"

# move to workspace #1, and reload the config
con.command "workspace 1; reload"

As well as retrieve the current state of all workspaces, outputs, the container tree, marks, status bars, and so on.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# get all workspaces
con.workspaces

# get all outputs
con.outputs

# get the container tree
con.tree

# get the names of all set marks
con.marks

# get the first status bar's config
con.bar_config(con.bar_ids.first)

# get i3's runtime version
con.version

# get the names of all binding modes
con.binding_modes

# get i3's runtime configuration
con.config

Each of the above returns an appropriate Crystal object: Array(I3::Message::Workspace) for workspaces, I3::Message::Tree for tree, Array(String) for bar_ids, and so forth.

Event subscription also works, with all i3 events currently supported:

1
2
3
4
5
6
7
8
# subscribe to all workspace events
con.subscribe "workspace"

con.on_event do |event|
  # this is safe, since we've only subscribed to one event type
  workspace = event.as(I3::Message::Event::Workspace)
  puts workspace.change
end

Note that on_event blocks the main thread, as it reads from a Channel(Message) populated with events1. This means that it can be used to halt until an appropriate event occurs, and should be put inside of a fiber if it needs to be run asynchronously. Also note that an event backlog can occur: events are handled FIFO.

All event response objects can be found under the I3::Message::Event namespace.

Full documentation is available here. The I3::Message and I3::Message::Event namespaces contain objects that map directly to the JSON objects sent by i3, so the i3 reply documentation is also worth reading.

Summary

This was my second Crystal library in a week, which is a pretty fast pace (one that I’m unlikely to keep up).

Where writing x_do.cr taught me about C bindings, this taught me about Crystal’s concurrency, networking, and serialization/deserialization features. In particular, I got my hands dirty with JSON.mapping and IO serialization, which were a real pleasure to use compared to Ruby’s OpenStruct and pack/unpack, respectively. Fibers and channels were also interesting (and a breeze to communicate over), but I think I prefer a traditional thread-and-queue approach for a protocol like this.

- William


  1. Crystal doesn’t have mature threading support yet, which makes it difficult to create a non-blocking/callback-based event handler. When threading becomes stable, I’ll probably add support for it.