Jan 6, 2018 Tags: crystal, programming, workflow
This is a writeup for my second Crystal library: i3.cr.
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.
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
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.
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
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. ↩