aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/er_xdg_pipe_menu.app.src5
-rw-r--r--src/er_xdg_pipe_menu_cache.erl56
-rw-r--r--src/er_xdg_pipe_menu_cli.erl41
-rw-r--r--src/er_xdg_pipe_menu_socket.erl66
-rw-r--r--src/er_xdg_pipe_menu_sup.erl10
-rw-r--r--src/er_xdg_pipe_menu_watcher.erl85
6 files changed, 258 insertions, 5 deletions
diff --git a/src/er_xdg_pipe_menu.app.src b/src/er_xdg_pipe_menu.app.src
index 1649ec3..4ba8103 100644
--- a/src/er_xdg_pipe_menu.app.src
+++ b/src/er_xdg_pipe_menu.app.src
@@ -6,9 +6,10 @@
{applications, [
kernel,
stdlib,
- xmerl
+ xmerl,
+ fs
]},
- {env, []},
+ {env, [{debounce_ms, 500}]},
{modules, []},
{licenses, ["MIT"]},
{links, []}
diff --git a/src/er_xdg_pipe_menu_cache.erl b/src/er_xdg_pipe_menu_cache.erl
new file mode 100644
index 0000000..a71526e
--- /dev/null
+++ b/src/er_xdg_pipe_menu_cache.erl
@@ -0,0 +1,56 @@
+%%%-------------------------------------------------------------------
+%% @doc Caches the rendered Openbox pipe menu in memory.
+%%
+%% Holds the last-rendered menu binary so socket clients get an
+%% instant response. `invalidate/0' synchronously rescans the XDG
+%% application directories, reparses every `.desktop' file and
+%% re-renders the menu, replacing the cached binary.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(er_xdg_pipe_menu_cache).
+
+-behaviour(gen_server).
+
+-export([start_link/0, get_menu/0, invalidate/0, build/1]).
+-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
+
+-spec start_link() -> {ok, pid()}.
+start_link() ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+-spec get_menu() -> binary().
+get_menu() ->
+ gen_server:call(?MODULE, get_menu).
+
+-spec invalidate() -> ok.
+invalidate() ->
+ gen_server:cast(?MODULE, invalidate).
+
+%% Pure pipeline: explicit dirs in, rendered menu binary out. Kept
+%% side-effect-free (besides reading the filesystem) and exported so
+%% it can be unit tested directly and reused by the CLI client's
+%% no-daemon fallback, without going through the gen_server at all.
+-spec build([file:filename_all()]) -> binary().
+build(Dirs) ->
+ Files = er_xdg_pipe_menu_scanner:find_desktop_files(Dirs),
+ Entries = lists:filtermap(fun parse/1, Files),
+ er_xdg_pipe_menu_renderer:render(Entries).
+
+parse(File) ->
+ case er_xdg_pipe_menu_desktop_entry:parse_file(File) of
+ {ok, Entry} -> {true, Entry};
+ {error, _} -> false
+ end.
+
+init([]) ->
+ {ok, build(er_xdg_pipe_menu_scanner:data_dirs())}.
+
+handle_call(get_menu, _From, Menu) ->
+ {reply, Menu, Menu}.
+
+handle_cast(invalidate, _Menu) ->
+ {noreply, build(er_xdg_pipe_menu_scanner:data_dirs())}.
+
+handle_info(_Info, State) ->
+ {noreply, State}.
diff --git a/src/er_xdg_pipe_menu_cli.erl b/src/er_xdg_pipe_menu_cli.erl
new file mode 100644
index 0000000..92ac715
--- /dev/null
+++ b/src/er_xdg_pipe_menu_cli.erl
@@ -0,0 +1,41 @@
+%%%-------------------------------------------------------------------
+%% @doc Entry point invoked by Openbox as the pipe menu command.
+%%
+%% Tries the running daemon's Unix socket first for an instant,
+%% pre-rendered response; if the daemon isn't reachable, falls back to
+%% scanning, parsing and rendering inline so the menu still works.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(er_xdg_pipe_menu_cli).
+
+-export([main/1]).
+
+-define(CONNECT_TIMEOUT_MS, 200).
+-define(RECV_TIMEOUT_MS, 1000).
+
+main(_Args) ->
+ Menu =
+ case from_daemon() of
+ {ok, Bin} -> Bin;
+ error -> er_xdg_pipe_menu_cache:build(er_xdg_pipe_menu_scanner:data_dirs())
+ end,
+ io:put_chars(Menu).
+
+from_daemon() ->
+ Path = er_xdg_pipe_menu_socket:socket_path(),
+ case gen_tcp:connect({local, Path}, 0, [binary, {active, false}], ?CONNECT_TIMEOUT_MS) of
+ {ok, Socket} ->
+ Result = recv_all(Socket, []),
+ gen_tcp:close(Socket),
+ Result;
+ {error, _Reason} ->
+ error
+ end.
+
+recv_all(Socket, Acc) ->
+ case gen_tcp:recv(Socket, 0, ?RECV_TIMEOUT_MS) of
+ {ok, Data} -> recv_all(Socket, [Data | Acc]);
+ {error, closed} -> {ok, iolist_to_binary(lists:reverse(Acc))};
+ {error, _Reason} -> error
+ end.
diff --git a/src/er_xdg_pipe_menu_socket.erl b/src/er_xdg_pipe_menu_socket.erl
new file mode 100644
index 0000000..1bcca91
--- /dev/null
+++ b/src/er_xdg_pipe_menu_socket.erl
@@ -0,0 +1,66 @@
+%%%-------------------------------------------------------------------
+%% @doc Serves the cached menu over a Unix domain socket.
+%%
+%% Connecting to the socket *is* the request: each accepted connection
+%% is handed the current `er_xdg_pipe_menu_cache:get_menu/0' binary and
+%% closed. There is no request payload to parse.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(er_xdg_pipe_menu_socket).
+
+-export([start_link/0, socket_path/0]).
+-export([init/1]).
+
+-spec start_link() -> {ok, pid()}.
+start_link() ->
+ proc_lib:start_link(?MODULE, init, [self()]).
+
+-spec socket_path() -> file:filename_all().
+socket_path() ->
+ case application:get_env(er_xdg_pipe_menu, socket_path) of
+ {ok, Path} -> Path;
+ undefined -> default_socket_path()
+ end.
+
+default_socket_path() ->
+ case env("XDG_RUNTIME_DIR") of
+ undefined -> filename:join("/tmp", "er_xdg_pipe_menu-" ++ os:getenv("USER", "unknown") ++ ".sock");
+ Dir -> filename:join(Dir, "er_xdg_pipe_menu.sock")
+ end.
+
+env(Name) ->
+ case os:getenv(Name) of
+ false -> undefined;
+ "" -> undefined;
+ Value -> Value
+ end.
+
+init(Parent) ->
+ Path = socket_path(),
+ %% Clean up a stale socket file left behind by a previous run that
+ %% didn't shut down cleanly; gen_tcp:listen fails with eaddrinuse
+ %% otherwise.
+ _ = file:delete(Path),
+ {ok, Listen} = gen_tcp:listen(0, [
+ {ifaddr, {local, Path}},
+ binary,
+ {active, false},
+ {packet, raw},
+ {reuseaddr, true}
+ ]),
+ proc_lib:init_ack(Parent, {ok, self()}),
+ accept_loop(Listen).
+
+accept_loop(Listen) ->
+ case gen_tcp:accept(Listen) of
+ {ok, Socket} ->
+ spawn(fun() -> serve(Socket) end),
+ accept_loop(Listen);
+ {error, Reason} ->
+ exit(Reason)
+ end.
+
+serve(Socket) ->
+ gen_tcp:send(Socket, er_xdg_pipe_menu_cache:get_menu()),
+ gen_tcp:close(Socket).
diff --git a/src/er_xdg_pipe_menu_sup.erl b/src/er_xdg_pipe_menu_sup.erl
index 2e42081..1916c0b 100644
--- a/src/er_xdg_pipe_menu_sup.erl
+++ b/src/er_xdg_pipe_menu_sup.erl
@@ -28,10 +28,14 @@ start_link() ->
init([]) ->
SupFlags = #{
strategy => one_for_all,
- intensity => 0,
- period => 1
+ intensity => 5,
+ period => 10
},
- ChildSpecs = [],
+ ChildSpecs = [
+ #{id => er_xdg_pipe_menu_cache, start => {er_xdg_pipe_menu_cache, start_link, []}},
+ #{id => er_xdg_pipe_menu_watcher, start => {er_xdg_pipe_menu_watcher, start_link, []}},
+ #{id => er_xdg_pipe_menu_socket, start => {er_xdg_pipe_menu_socket, start_link, []}}
+ ],
{ok, {SupFlags, ChildSpecs}}.
%% internal functions
diff --git a/src/er_xdg_pipe_menu_watcher.erl b/src/er_xdg_pipe_menu_watcher.erl
new file mode 100644
index 0000000..d8c407d
--- /dev/null
+++ b/src/er_xdg_pipe_menu_watcher.erl
@@ -0,0 +1,85 @@
+%%%-------------------------------------------------------------------
+%% @doc Watches the XDG application directories for changes.
+%%
+%% Starts one `fs' (native inotify) watch per existing directory
+%% returned by `er_xdg_pipe_menu_scanner:data_dirs/0' and subscribes
+%% to its file events. On any event, a debounce timer is (re)started;
+%% once it fires with no further events in the window, the menu cache
+%% is invalidated so the next request gets a freshly rebuilt menu.
+%%
+%% `fs' watches recursively (it drives `inotifywait -r' on Linux), so
+%% changes in vendor subdirectories (e.g. `applications/kde4/*') are
+%% picked up without any extra handling here.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(er_xdg_pipe_menu_watcher).
+
+-behaviour(gen_server).
+
+-export([start_link/0]).
+-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
+
+-define(DEFAULT_DEBOUNCE_MS, 500).
+
+-spec start_link() -> {ok, pid()}.
+start_link() ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+init([]) ->
+ warn_if_inotifywait_missing(),
+ Dirs = [D || D <- er_xdg_pipe_menu_scanner:data_dirs(), filelib:is_dir(D)],
+ lists:foreach(fun watch/1, lists:zip(lists:seq(1, length(Dirs)), Dirs)),
+ {ok, #{timer => undefined}}.
+
+watch({Index, Dir}) ->
+ Name = list_to_atom("er_xdg_pipe_menu_fs_" ++ integer_to_list(Index)),
+ case fs:start_link(Name, Dir) of
+ {ok, _Pid} -> fs:subscribe(Name);
+ {error, Reason} -> log_watch_failure(Dir, Reason)
+ end.
+
+%% `fs' on Linux shells out to the system `inotifywait' binary; if
+%% it's missing, `fs:start_link/2' still returns `{ok, _}' but simply
+%% never delivers events, which is otherwise silent.
+warn_if_inotifywait_missing() ->
+ case os:find_executable("inotifywait") of
+ false ->
+ error_logger:warning_msg(
+ "er_xdg_pipe_menu_watcher: 'inotifywait' not found on PATH "
+ "(install inotify-tools) -- desktop file changes won't be "
+ "picked up until the daemon is restarted~n",
+ []
+ );
+ _Path ->
+ ok
+ end.
+
+log_watch_failure(Dir, Reason) ->
+ error_logger:warning_msg(
+ "er_xdg_pipe_menu_watcher: failed to watch ~p: ~p~n", [Dir, Reason]
+ ).
+
+handle_call(_Request, _From, State) ->
+ {reply, ok, State}.
+
+handle_cast(_Msg, State) ->
+ {noreply, State}.
+
+handle_info({_Pid, {fs, file_event}, {_Path, _Events}}, State) ->
+ {noreply, reset_debounce(State)};
+handle_info(debounce_fire, State) ->
+ er_xdg_pipe_menu_cache:invalidate(),
+ {noreply, State#{timer => undefined}};
+handle_info(_Info, State) ->
+ {noreply, State}.
+
+reset_debounce(#{timer := Timer} = State) ->
+ case Timer of
+ undefined -> ok;
+ Ref -> erlang:cancel_timer(Ref)
+ end,
+ State#{timer => erlang:send_after(debounce_ms(), self(), debounce_fire)}.
+
+debounce_ms() ->
+ application:get_env(er_xdg_pipe_menu, debounce_ms, ?DEFAULT_DEBOUNCE_MS).