-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 }.