Current section

Files

Jump to
i3_client src i3_client.erl
Raw

src/i3_client.erl

-module(i3_client).
-moduledoc """
> #### Warning {: .warning }
>
> The documentation, especially on successful return values, is
> intentionally vague. This library doesn't do any meaningful
> processing of message responses or events before handing them over
> to the user. The main principle is that responses are maps but the
> events are kept as binaries. Events can potentially be big and
> shouldn't be copied carelessly during fan out.
>
> The official i3 IPC interface
> [documentation](https://i3wm.org/docs/ipc.html) should be your
> primary guide regarding what's included in successful responses and
> events but also make sure to consult your variant server's
> documentation because there can be differences.
""".
-export([
init/1,
publish/3,
handle_connect/1,
handle_disconnect/1,
handle_call/3,
handle_info/2,
handle_result/2
]).
-export([
child_spec/0, child_spec/1,
start/1, start/2,
start_link/1, start_link/2,
start_monitor/1, start_monitor/2,
run_command/2, run_command/3,
get_workspaces/1, get_workspaces/2,
get_outputs/1, get_outputs/2,
get_tree/1, get_tree/2,
get_marks/1, get_marks/2,
get_bar_config/1, get_bar_config/2, get_bar_config/3,
get_version/1, get_version/2,
get_binding_modes/1, get_binding_modes/2,
get_config/1, get_config/2,
send_tick/2, send_tick/3,
sync/2, sync/3,
get_binding_state/1, get_binding_state/2,
get_inputs/1, get_inputs/2,
get_seats/1, get_seats/2,
subscribe/2, subscribe/3
]).
-include("i3_client_codec.hrl").
-define(timeout, 5000).
-define(client_opts, [auto_reconnect]).
-type start_opt() ::
{path, binary()}
| {auto_reconnect, boolean()}
| {reconnect_backoff, boolean()}
| {sync_connect, boolean()}
| gen_statem:start_opt().
-type start_ret() :: gen_statem:start_ret().
-type server_ref() :: gen_statem:server_ref().
-type server_name() :: gen_statem:server_name().
-type message_opt() :: {timeout, timeout()}.
-type message_ret() :: message_ret(map()).
-type message_ret(Ret) :: {ok, Ret} | {error, enotsup | {badmsg, map()}}.
-behaviour(i3_client_connection).
-doc """
Equivalent to [`child_spec([])`](`child_spec/1`).
""".
child_spec() -> child_spec([]).
-doc """
Returns a child specification for i3 connection process.
The process name can be provided as a `name` option to keep it
consistent with Elixir's convention.
""".
child_spec(Opts0) ->
Start =
case lists:keytake(name, 1, Opts0) of
{value, {name, Name}, Opts1} ->
{i3_client, start_link, [Name, Opts1]};
false ->
{i3_client, start_link, [Opts0]}
end,
#{
id => i3_client_connection,
start => Start
}.
-doc """
Start an i3 client connection, neither linked nor registered.
""".
-spec start([start_opt()]) -> start_ret().
start(Opts) ->
{Args, _} = i3_client_common:keystake(?client_opts, Opts),
i3_client_connection:start(?MODULE, Args, Opts).
-doc """
Start an i3 client connection, registered but not linked.
""".
-spec start(server_name(), [start_opt()]) -> start_ret().
start(ServerName, Opts) ->
{Args, _} = i3_client_common:keystake(?client_opts, Opts),
i3_client_connection:start_link(ServerName, ?MODULE, Args, Opts).
-doc """
Start an i3 client connection, linked but not registered.
""".
-spec start_link([start_opt()]) -> start_ret().
start_link(Opts) ->
{Args, _} = i3_client_common:keystake(?client_opts, Opts),
i3_client_connection:start_link(?MODULE, Args, Opts).
-doc """
Start an i3 client connection, linked and registered.
""".
-spec start_link(server_name(), [start_opt()]) -> start_ret().
start_link(ServerName, Opts) ->
{Args, _} = i3_client_common:keystake(?client_opts, Opts),
i3_client_connection:start_link(ServerName, ?MODULE, Args, Opts).
-doc """
Start an i3 client connection, monitored but not registered.
""".
-spec start_monitor([start_opt()]) -> start_ret().
start_monitor(Opts) ->
{Args, _} = i3_client_common:keystake(?client_opts, Opts),
i3_client_connection:start_monitor(?MODULE, Args, Opts).
-doc """
Start an i3 client connection, monitored and registered.
""".
-spec start_monitor(server_name(), [start_opt()]) -> start_ret().
start_monitor(ServerName, Opts) ->
{Args, _} = i3_client_common:keystake(?client_opts, Opts),
i3_client_connection:start_monitor(ServerName, ?MODULE, Args, Opts).
-doc """
Equivalent to [`run_command(Server, Command, [])`](`run_command/3`).
""".
-spec run_command(server_ref(), binary()) -> message_ret([map()]).
run_command(Server, Command) ->
run_command(Server, Command, []).
-doc """
Runs the command as an i3 command.
""".
-spec run_command(server_ref(), binary(), [message_opt()]) -> message_ret([map()]).
run_command(Server, Command, Opts) ->
i3_client_connection:call(
Server,
{?run_command, Command},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_workspaces(Server, [])`](`get_workspaces/2`).
""".
-spec get_workspaces(server_ref()) -> message_ret([map()]).
get_workspaces(Server) ->
get_workspaces(Server, []).
-doc """
Gets the list of current workspaces.
""".
-spec get_workspaces(server_ref(), [message_opt()]) -> message_ret([map()]).
get_workspaces(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_workspaces, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_outputs(Server, [])`](`get_outputs/2`).
""".
-spec get_outputs(server_ref()) -> message_ret([map()]).
get_outputs(Server) ->
get_outputs(Server, []).
-doc """
Gets the list of current outputs.
""".
-spec get_outputs(server_ref(), [message_opt()]) -> message_ret([map()]).
get_outputs(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_outputs, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_tree(Server, [])`](`get_tree/2`).
""".
-spec get_tree(server_ref()) -> message_ret().
get_tree(Server) ->
get_tree(Server, []).
-doc """
Gets the window manager's layout tree.
""".
-spec get_tree(server_ref(), [message_opt()]) -> message_ret().
get_tree(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_tree, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_marks(Server, [])`](`get_marks/2`).
""".
-spec get_marks(server_ref()) -> message_ret([binary()]).
get_marks(Server) ->
get_marks(Server, []).
-doc """
Gets the names of all currently set marks.
""".
-spec get_marks(server_ref(), [message_opt()]) -> message_ret([binary()]).
get_marks(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_marks, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_bar_config(Server, [])`](`get_bar_config/2`).
""".
-spec get_bar_config(server_ref()) -> message_ret([binary()]).
get_bar_config(Server) ->
get_bar_config(Server, []).
-doc """
Equivalent to [`get_bar_config(Server, ~"",
Opts)`](`get_bar_config/3`) or [`get_bar_config(Server, BarId,
[])`](`get_bar_config/3`).
""".
-spec get_bar_config(server_ref(), binary() | [message_opt()]) -> message_ret([binary()] | map()).
get_bar_config(Server, Opts) when is_list(Opts) ->
get_bar_config(Server, ~"", Opts);
get_bar_config(Server, BarId) when is_binary(BarId) ->
get_bar_config(Server, BarId, []).
-doc """
Gets a particular bar configuration or the names of all bar
configurations.
""".
-spec get_bar_config(server_ref(), binary(), [message_opt()]) -> message_ret([binary()] | map()).
get_bar_config(Server, BarId, Opts) when is_binary(BarId) and is_list(Opts) ->
i3_client_connection:call(
Server,
{?get_bar_config, BarId},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_version(Server, [])`](`get_version/2`).
""".
-spec get_version(server_ref()) -> message_ret().
get_version(Server) ->
get_version(Server, []).
-doc """
Gets the i3 IPC server's version.
""".
-spec get_version(server_ref(), [message_opt()]) -> message_ret().
get_version(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_version, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_binding_modes(Server,
[])`](`get_binding_modes/2`).
""".
-spec get_binding_modes(server_ref()) -> message_ret([binary()]).
get_binding_modes(Server) ->
get_binding_modes(Server, []).
-doc """
Gets the names of all currently configured binding modes.
""".
-spec get_binding_modes(server_ref(), [message_opt()]) -> message_ret([binary()]).
get_binding_modes(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_binding_modes, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_config(Server, [])`](`get_config/2`).
""".
-spec get_config(server_ref()) -> message_ret().
get_config(Server) ->
get_config(Server, []).
-doc """
Gets the i3 IPC server's last loaded config.
""".
-spec get_config(server_ref(), [message_opt()]) -> message_ret().
get_config(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_config, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`send_tick(Server, Payload, [])`](`send_tick/3`).
""".
-spec send_tick(server_ref(), binary()) -> term().
send_tick(Server, Payload) when is_binary(Payload) ->
send_tick(Server, Payload, []).
-doc """
Sends a tick event with the specified payload.
""".
-spec send_tick(server_ref(), binary(), [message_opt()]) -> term().
send_tick(Server, Payload, Opts) ->
i3_client_connection:call(
Server,
{?send_tick, Payload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`sync(Server, Payload, [])`](`sync/3`).
""".
-spec sync(server_ref(), #{rnd => non_neg_integer(), window => non_neg_integer()}) -> message_ret().
sync(Server, #{rnd := Rnd, window := Win} = Payload) when is_integer(Rnd) and is_integer(Win) ->
sync(Server, Payload, []).
-doc """
Sends an i3 sync event with the specified random value to the
specified window.
> #### Info {: .info }
> On Sway, this command will just return a failure map since it does
> not make sense to implement due to the X11 nature of the command.
""".
-spec sync(server_ref(), #{rnd => non_neg_integer(), window => non_neg_integer()}, [message_opt()]) ->
message_ret().
sync(Server, #{rnd := Rnd, window := Win} = Payload, Opts) when
is_integer(Rnd) and is_integer(Win)
->
i3_client_connection:call(
Server,
{?sync, Payload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_binding_state(Server,
[])`](`get_binding_state/2`).
""".
-spec get_binding_state(server_ref()) -> message_ret().
get_binding_state(Server) ->
get_binding_state(Server, []).
-doc """
Gets the current binding state, i.e. the currently active binding mode
name.
""".
-spec get_binding_state(server_ref(), [message_opt()]) -> message_ret().
get_binding_state(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_binding_state, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_inputs(Server, [])`](`get_inputs/2`).
""".
-spec get_inputs(server_ref()) -> message_ret([map()]).
get_inputs(Server) ->
get_inputs(Server, []).
-doc """
Gets a list of the input devices currently available.
> #### Warning {: .warning }
> This message type is supported only in Sway.
""".
-spec get_inputs(server_ref(), [message_opt()]) -> message_ret([map()]).
get_inputs(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_inputs, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`get_seats(Server, [])`](`get_seats/2`).
""".
-spec get_seats(server_ref()) -> message_ret([map()]).
get_seats(Server) ->
get_seats(Server, []).
-doc """
Gets a list of the currently configured seats.
> #### Warning {: .warning }
> This message type is supported only in Sway.
""".
-spec get_seats(server_ref(), [message_opt()]) -> message_ret([map()]).
get_seats(Server, Opts) ->
i3_client_connection:call(
Server,
{?get_seats, ?nopayload},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc """
Equivalent to [`subscribe(Server, Events, [])`](`subscribe/3`).
""".
-spec subscribe(server_ref(), [binary()]) -> message_ret().
subscribe(Server, [_ | _] = Events) ->
subscribe(Server, Events, []).
-doc """
Subscribes this client connection to a list of asynchronous events.
A message `{i3_event, Conn :: pid(), Ref :: reference(), Type ::
binary(), Payload :: binary()}` will be sent to the calling process
when an event is received.
Returns `{ok, Ref :: reference()}` when the subscription is successful
or `{eventually, Ref :: reference()}` if the client process is not
currently connected. The latter might be the case if the process was
started with `sync_connect` set to false or `auto_reconnect` set to
true. The function might also return `{error, {badmsg, map()}}` if the
subscription is rejected by the server.
## Options
* `timeout` - Call timeout (default: 5000)
""".
-spec subscribe(server_ref(), [binary()], [message_opt()]) -> message_ret(reference()).
subscribe(Server, [_ | _] = Events, Opts) when is_list(Events) ->
i3_client_connection:call(
Server,
{?subscribe, Events},
proplists:get_value(timeout, Opts, ?timeout)
).
-doc false.
init(_Args) ->
{ok, #{
from => undefined,
ref => undefined,
connected => false,
auto_reconnect => false,
subscriber_events => #{},
first_tickers => queue:new()
}}.
-doc false.
publish(
~"tick" = Type,
Payload,
#{subscriber_events := SubscriberEvents0, first_tickers := FirstTickers} = State
) ->
case {json:decode(Payload), queue:out(FirstTickers)} of
% We have received an "acknowledgement" tick message but no
% one is expecting it. The connection must've been
% reestablished and we resubscribed to all the events.
{#{~"first" := true}, {empty, _}} ->
State;
{#{~"first" := true}, {{value, {Ref, Pid}}, Rest}} ->
Pid ! {i3_event, self(), Ref, Type, Payload},
SubscriberEvents1 = maps:update_with(
~"tick",
fun(M) -> M#{Ref => Pid} end,
#{Ref => Pid},
SubscriberEvents0
),
State#{subscriber_events := SubscriberEvents1, first_tickers := Rest};
_ ->
do_publish(Type, Payload, State)
end;
publish(Type, Payload, State) ->
do_publish(Type, Payload, State).
do_publish(Type, Payload, State) ->
Subscribers = maps:get(Type, maps:get(subscriber_events, State), #{}),
maps:foreach(
fun(Ref, Pid) -> Pid ! {i3_event, self(), Ref, Type, Payload} end,
Subscribers
),
State.
-doc false.
handle_connect(#{subscriber_events := SubscriberEvents, first_tickers := FirstTickers} = State0) ->
State1 = State0#{connected := true},
case {maps:keys(SubscriberEvents), queue:peek(FirstTickers)} of
{[], empty} ->
{noreply, State1};
{Events, empty} ->
{send, ?subscribe, Events, State1};
{Events, {value, _}} ->
{send, ?subscribe, lists:uniq([~"tick" | Events]), State1}
end.
-doc false.
handle_disconnect(State0) ->
State1 = State0#{connected := false},
State2 =
case State1 of
#{auto_reconnect := true, from := {_, _} = From, ref := Ref} when is_reference(Ref) ->
i3_client_connection:reply(From, {eventually, Ref}),
State1#{from := undefined, ref := undefined};
_ ->
State1
end,
{noreply, State2}.
-doc false.
handle_call({?subscribe, Events}, {Pid, _} = From, State0) ->
{Ref, NewEventsLazy, State1} = handle_subscribe(State0, Pid, Events),
case State1 of
#{connected := false} ->
i3_client_connection:reply(From, {eventually, Ref}),
{noreply, State1};
_ ->
case NewEventsLazy() of
[] ->
i3_client_connection:reply(From, {ok, Ref}),
{noreply, State1};
Events ->
{send, ?subscribe, Events, State1#{from := From, ref := Ref}}
end
end;
handle_call({Type, Payload}, From, State) ->
{send, Type, Payload, State#{from := From}}.
-doc false.
handle_info({'DOWN', Ref, process, _, _}, State) ->
{noreply, handle_unsubscribe(State, Ref)}.
-doc false.
handle_result(_, #{from := undefined, ref := undefined} = State) ->
{noreply, State};
handle_result({_, Response}, #{from := From, ref := undefined} = State) ->
i3_client_connection:reply(From, Response),
{noreply, State#{from := undefined}};
% If there's a ref in the state it means we're expecting a result from
% a subscription request.
handle_result({?subscribe, {ok, _}}, #{from := From, ref := Ref} = State) ->
i3_client_connection:reply(From, {ok, Ref}),
{noreply, State#{from := undefined, ref := undefined}};
handle_result({?subscribe, {error, _} = Error}, #{from := From, ref := Ref} = State0) ->
i3_client_connection:reply(From, Error),
State1 = handle_unsubscribe(State0, Ref),
{noreply, State1#{from := undefined, ref := undefined}}.
handle_subscribe(State, Pid, Events) ->
#{subscriber_events := SubscriberEvents0, first_tickers := FirstTickers0} = State,
Ref = erlang:monitor(process, Pid),
Fun = fun(M) -> maps:put(Ref, Pid, M) end,
{SubscriberEvents1, FirstTickers1} =
lists:foldl(
fun
(~"tick", {SubscriberEvents, FirstTickers}) ->
{SubscriberEvents, queue:in({Ref, Pid}, FirstTickers)};
(Event, {SubscriberEvents, FirstTickers}) ->
{maps:update_with(Event, Fun, #{Ref => Pid}, SubscriberEvents), FirstTickers}
end,
{SubscriberEvents0, FirstTickers0},
Events
),
% It's not cheap and it actually might not be needed so it makes
% sense to be lazy.
NewEventsThunk =
fun() ->
% If after removing the current subscriber reference there
% are no other subscribers the the current one is the
% first and the connection must subscribe to the event.
SubscriberEvents2 = maps:fold(
fun(Event, RefPidMap, Acc) ->
case maps:remove(Ref, RefPidMap) of
Map when map_size(Map) =:= 0 ->
[Event | Acc];
_ ->
Acc
end
end,
[],
SubscriberEvents1
),
case FirstTickers1 of
FirstTickers0 ->
SubscriberEvents2;
_ ->
lists:uniq([~"tick" | SubscriberEvents2])
end
end,
{Ref, NewEventsThunk, State#{
subscriber_events := SubscriberEvents1, first_tickers := FirstTickers1
}}.
handle_unsubscribe(
#{subscriber_events := SubscriberEvents0, first_tickers := FirstTickers0} = State, Ref
) ->
erlang:demonitor(Ref, [flush]),
Fun = fun(_, RefPidMap) ->
case maps:remove(Ref, RefPidMap) of
Map when map_size(Map) =:= 0 ->
false;
Map ->
{true, Map}
end
end,
SubscriberEvents1 = maps:filtermap(Fun, SubscriberEvents0),
FirstTickers1 = queue:filter(
fun
({FirstTickerRef, _}) when FirstTickerRef =:= Ref -> false;
(_) -> true
end,
FirstTickers0
),
State#{
subscriber_events := SubscriberEvents1,
first_tickers := FirstTickers1
}.