aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md48
-rwxr-xr-xbin/er_xdg_pipe_menu_menu17
-rw-r--r--rebar.config2
-rw-r--r--rebar.lock9
-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
-rw-r--r--test/er_xdg_pipe_menu_cache_tests.erl130
-rw-r--r--test/er_xdg_pipe_menu_socket_tests.erl93
-rw-r--r--test/er_xdg_pipe_menu_watcher_tests.erl86
13 files changed, 641 insertions, 7 deletions
diff --git a/README.md b/README.md
index 5a7da3b..fbf610c 100644
--- a/README.md
+++ b/README.md
@@ -7,3 +7,51 @@ Build
-----
$ rebar3 compile
+
+Live-reload (rebuilding the menu when `.desktop` files change) uses
+the `fs` library, which on Linux shells out to `inotifywait`. Install
+the `inotify-tools` package for this to work; without it the daemon
+still serves the menu it built at startup, it just won't notice
+later changes until restarted.
+
+Running locally
+----------------
+
+The app is a long-running daemon: it scans your XDG application
+directories once at startup, caches the rendered menu in memory, and
+serves it over a Unix domain socket at
+`$XDG_RUNTIME_DIR/er_xdg_pipe_menu.sock` (falling back to
+`/tmp/er_xdg_pipe_menu-$USER.sock` if `XDG_RUNTIME_DIR` isn't set),
+rebuilding the cache whenever a watched `.desktop` file changes.
+
+For a quick manual run (e.g. while developing):
+
+ $ rebar3 shell --eval "application:ensure_all_started(er_xdg_pipe_menu)."
+
+To have it start automatically with your session, add a systemd
+user unit, e.g. `~/.config/systemd/user/er-xdg-pipe-menu.service`:
+
+ [Unit]
+ Description=XDG pipe menu daemon
+
+ [Service]
+ WorkingDirectory=/path/to/er-xdg-pipe-menu
+ ExecStart=/usr/bin/rebar3 shell --eval "application:ensure_all_started(er_xdg_pipe_menu)."
+ Restart=on-failure
+
+ [Install]
+ WantedBy=default.target
+
+then enable it with `systemctl --user enable --now er-xdg-pipe-menu`.
+
+Openbox itself doesn't talk to the socket directly -- it invokes
+`bin/er_xdg_pipe_menu_menu` as the pipe menu command, which connects
+to the daemon's socket and prints whatever comes back (or, if the
+daemon isn't running, falls back to scanning/rendering inline so the
+menu still works). Point a pipe menu at it in `~/.config/openbox/menu.xml`:
+
+ <menu id="apps" label="Applications" execute="/path/to/er-xdg-pipe-menu/bin/er_xdg_pipe_menu_menu" />
+
+The script resolves its own location to find the compiled code, so
+it works from an absolute path as long as the repo's `_build`
+directory (from `rebar3 compile`) is alongside it.
diff --git a/bin/er_xdg_pipe_menu_menu b/bin/er_xdg_pipe_menu_menu
new file mode 100755
index 0000000..32a9bd9
--- /dev/null
+++ b/bin/er_xdg_pipe_menu_menu
@@ -0,0 +1,17 @@
+#!/usr/bin/env escript
+%%! -noshell
+
+%% Openbox pipe menu entry point. Tries the running daemon's Unix
+%% socket first; falls back to an inline scan+parse+render if it
+%% isn't reachable. See src/er_xdg_pipe_menu_cli.erl for the logic --
+%% this script only wires up the code path to the compiled app.
+
+main(Args) ->
+ add_code_paths(),
+ er_xdg_pipe_menu_cli:main(Args).
+
+add_code_paths() ->
+ ScriptDir = filename:dirname(filename:absname(escript:script_name())),
+ RepoRoot = filename:dirname(ScriptDir),
+ Pattern = filename:join([RepoRoot, "_build", "default", "lib", "*", "ebin"]),
+ code:add_pathsz(filelib:wildcard(Pattern)).
diff --git a/rebar.config b/rebar.config
index 8053a64..25a5a33 100644
--- a/rebar.config
+++ b/rebar.config
@@ -1,5 +1,5 @@
{erl_opts, [debug_info]}.
-{deps, []}.
+{deps, [{fs, "11.4.1"}]}.
{shell, [
%% {config, "config/sys.config"},
diff --git a/rebar.lock b/rebar.lock
index 57afcca..0e7e1fe 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -1 +1,8 @@
-[].
+{"1.2.0",
+[{<<"fs">>,{pkg,<<"fs">>,<<"11.4.1">>},0}]}.
+[
+{pkg_hash,[
+ {<<"fs">>, <<"11FB3153BB2E1DE851B8263BB5698D526894853C73A525EBEB5E69108B2D25CD">>}]},
+{pkg_hash_ext,[
+ {<<"fs">>, <<"DD00A61D89EAC01D16D3FC51D5B0EB5F0722EF8E3C1A3A547CD086957F3260A9">>}]}
+].
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).
diff --git a/test/er_xdg_pipe_menu_cache_tests.erl b/test/er_xdg_pipe_menu_cache_tests.erl
new file mode 100644
index 0000000..a1d1440
--- /dev/null
+++ b/test/er_xdg_pipe_menu_cache_tests.erl
@@ -0,0 +1,130 @@
+-module(er_xdg_pipe_menu_cache_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+build_renders_entries_from_dir_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "foo.desktop", "Foo", "foo"),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"Foo">>) =/= nomatch).
+
+build_groups_by_category_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "foo.desktop", "Foo", "foo", "Utility"),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"label=\"Utility\"">>) =/= nomatch).
+
+build_skips_hidden_entries_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "visible.desktop", "Visible", "visible"),
+ ok = file:write_file(
+ filename:join(Dir, "hidden.desktop"),
+ <<"[Desktop Entry]\nName=Hidden\nExec=hidden\nNoDisplay=true\n">>
+ ),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"Visible">>) =/= nomatch),
+ ?assertEqual(nomatch, binary:match(Menu, <<"Hidden">>)).
+
+build_skips_unparseable_files_without_failing_test() ->
+ Dir = mk_tmp_dir(),
+ write_desktop(Dir, "good.desktop", "Good", "good"),
+ ok = file:write_file(filename:join(Dir, "bad.desktop"), <<"[Desktop Entry]\nExec=bad\n">>),
+ Menu = er_xdg_pipe_menu_cache:build([Dir]),
+ ok = rm_rf(Dir),
+ ?assert(binary:match(Menu, <<"Good">>) =/= nomatch).
+
+build_skips_missing_dirs_test() ->
+ ?assertEqual(
+ <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<openbox_pipe_menu>\n"
+ "</openbox_pipe_menu>\n">>,
+ er_xdg_pipe_menu_cache:build(["/does-not-exist"])
+ ).
+
+gen_server_serves_and_invalidates_test() ->
+ Root = mk_tmp_dir(),
+ AppDir = filename:join(Root, "applications"),
+ ok = file:make_dir(AppDir),
+ write_desktop(AppDir, "foo.desktop", "Foo", "foo"),
+ with_env(
+ [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}],
+ fun() ->
+ {ok, Pid} = er_xdg_pipe_menu_cache:start_link(),
+ Menu1 = er_xdg_pipe_menu_cache:get_menu(),
+ ?assert(binary:match(Menu1, <<"Foo">>) =/= nomatch),
+
+ write_desktop(AppDir, "bar.desktop", "Bar", "bar"),
+ ok = er_xdg_pipe_menu_cache:invalidate(),
+ Menu2 = er_xdg_pipe_menu_cache:get_menu(),
+ stop(Pid),
+ ?assert(binary:match(Menu2, <<"Bar">>) =/= nomatch)
+ end
+ ),
+ ok = rm_rf(Root).
+
+%% -- helpers ----------------------------------------------------------------
+
+%% start_link/0 links the started process to us (the test process);
+%% unlink before killing it so the kill signal doesn't propagate back
+%% and take the test process down with it.
+stop(Pid) ->
+ unlink(Pid),
+ true = erlang:exit(Pid, kill).
+
+with_env(Vars, Fun) ->
+ Saved = [{Name, os:getenv(Name)} || {Name, _} <- Vars],
+ try
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Vars),
+ Fun()
+ after
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Saved)
+ end.
+
+set_env(Name, false) -> os:unsetenv(Name);
+set_env(Name, Value) -> os:putenv(Name, Value).
+
+write_desktop(Dir, FileName, Name, Exec) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary(["[Desktop Entry]\nName=", Name, "\nExec=", Exec, "\n"])
+ ).
+
+write_desktop(Dir, FileName, Name, Exec, Categories) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary([
+ "[Desktop Entry]\nName=",
+ Name,
+ "\nExec=",
+ Exec,
+ "\nCategories=",
+ Categories,
+ ";\n"
+ ])
+ ).
+
+mk_tmp_dir() ->
+ Path = filename:join(
+ test_tmp_dir(), "eunit-cache-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ ok = file:make_dir(Path),
+ Path.
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.
+
+rm_rf(Path) ->
+ case filelib:is_dir(Path) of
+ true ->
+ {ok, Entries} = file:list_dir(Path),
+ lists:foreach(fun(E) -> rm_rf(filename:join(Path, E)) end, Entries),
+ file:del_dir(Path);
+ false ->
+ file:delete(Path)
+ end.
diff --git a/test/er_xdg_pipe_menu_socket_tests.erl b/test/er_xdg_pipe_menu_socket_tests.erl
new file mode 100644
index 0000000..08a4b56
--- /dev/null
+++ b/test/er_xdg_pipe_menu_socket_tests.erl
@@ -0,0 +1,93 @@
+-module(er_xdg_pipe_menu_socket_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+serves_the_cached_menu_test() ->
+ Root = mk_tmp_dir(),
+ AppDir = filename:join(Root, "applications"),
+ ok = file:make_dir(AppDir),
+ write_desktop(AppDir, "foo.desktop", "Foo", "foo"),
+ SockPath = filename:join(
+ test_tmp_dir(), "eunit-socket-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ PrevSockPath = application:get_env(er_xdg_pipe_menu, socket_path),
+ application:set_env(er_xdg_pipe_menu, socket_path, SockPath),
+ with_env(
+ [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}],
+ fun() ->
+ {ok, CachePid} = er_xdg_pipe_menu_cache:start_link(),
+ {ok, SocketPid} = er_xdg_pipe_menu_socket:start_link(),
+
+ {ok, Conn} = gen_tcp:connect({local, SockPath}, 0, [binary, {active, false}], 1000),
+ Received = recv_all(Conn, []),
+ gen_tcp:close(Conn),
+
+ Expected = er_xdg_pipe_menu_cache:get_menu(),
+ stop(SocketPid),
+ stop(CachePid),
+ ?assertEqual(Expected, Received),
+ ?assert(binary:match(Received, <<"Foo">>) =/= nomatch)
+ end
+ ),
+ _ = file:delete(SockPath),
+ ok = rm_rf(Root),
+ restore_env(er_xdg_pipe_menu, socket_path, PrevSockPath).
+
+%% -- helpers ----------------------------------------------------------------
+
+%% start_link/0 links the started process to us (the test process);
+%% unlink before killing it so the kill signal doesn't propagate back
+%% and take the test process down with it.
+stop(Pid) ->
+ unlink(Pid),
+ true = erlang:exit(Pid, kill).
+
+recv_all(Socket, Acc) ->
+ case gen_tcp:recv(Socket, 0, 1000) of
+ {ok, Data} -> recv_all(Socket, [Data | Acc]);
+ {error, closed} -> iolist_to_binary(lists:reverse(Acc))
+ end.
+
+restore_env(App, Key, undefined) -> application:unset_env(App, Key);
+restore_env(App, Key, {ok, Value}) -> application:set_env(App, Key, Value).
+
+with_env(Vars, Fun) ->
+ Saved = [{Name, os:getenv(Name)} || {Name, _} <- Vars],
+ try
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Vars),
+ Fun()
+ after
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Saved)
+ end.
+
+set_env(Name, false) -> os:unsetenv(Name);
+set_env(Name, Value) -> os:putenv(Name, Value).
+
+write_desktop(Dir, FileName, Name, Exec) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary(["[Desktop Entry]\nName=", Name, "\nExec=", Exec, "\n"])
+ ).
+
+mk_tmp_dir() ->
+ Path = filename:join(
+ test_tmp_dir(), "eunit-socket-dir-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ ok = file:make_dir(Path),
+ Path.
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.
+
+rm_rf(Path) ->
+ case filelib:is_dir(Path) of
+ true ->
+ {ok, Entries} = file:list_dir(Path),
+ lists:foreach(fun(E) -> rm_rf(filename:join(Path, E)) end, Entries),
+ file:del_dir(Path);
+ false ->
+ file:delete(Path)
+ end.
diff --git a/test/er_xdg_pipe_menu_watcher_tests.erl b/test/er_xdg_pipe_menu_watcher_tests.erl
new file mode 100644
index 0000000..6754f24
--- /dev/null
+++ b/test/er_xdg_pipe_menu_watcher_tests.erl
@@ -0,0 +1,86 @@
+-module(er_xdg_pipe_menu_watcher_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+
+%% Exercises the real debounce-timer-reset-then-invalidate wiring by
+%% sending the watcher a synthetic message of the exact shape `fs'
+%% delivers, rather than waiting on a real inotify event -- `fs'
+%% itself is treated as out of scope for this test.
+debounced_event_invalidates_cache_test() ->
+ Root = mk_tmp_dir(),
+ AppDir = filename:join(Root, "applications"),
+ ok = file:make_dir(AppDir),
+ write_desktop(AppDir, "foo.desktop", "Foo", "foo"),
+ PrevDebounce = application:get_env(er_xdg_pipe_menu, debounce_ms),
+ application:set_env(er_xdg_pipe_menu, debounce_ms, 20),
+ with_env(
+ [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}],
+ fun() ->
+ {ok, CachePid} = er_xdg_pipe_menu_cache:start_link(),
+ {ok, WatcherPid} = er_xdg_pipe_menu_watcher:start_link(),
+
+ write_desktop(AppDir, "bar.desktop", "Bar", "bar"),
+ WatcherPid ! {self(), {fs, file_event}, {"dummy", [modified]}},
+ timer:sleep(100),
+
+ Menu = er_xdg_pipe_menu_cache:get_menu(),
+ stop(WatcherPid),
+ stop(CachePid),
+ ?assert(binary:match(Menu, <<"Bar">>) =/= nomatch)
+ end
+ ),
+ ok = rm_rf(Root),
+ restore_env(er_xdg_pipe_menu, debounce_ms, PrevDebounce).
+
+%% -- helpers ----------------------------------------------------------------
+
+%% start_link/0 links the started process to us (the test process);
+%% unlink before killing it so the kill signal doesn't propagate back
+%% and take the test process down with it.
+stop(Pid) ->
+ unlink(Pid),
+ true = erlang:exit(Pid, kill).
+
+restore_env(App, Key, undefined) -> application:unset_env(App, Key);
+restore_env(App, Key, {ok, Value}) -> application:set_env(App, Key, Value).
+
+with_env(Vars, Fun) ->
+ Saved = [{Name, os:getenv(Name)} || {Name, _} <- Vars],
+ try
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Vars),
+ Fun()
+ after
+ lists:foreach(fun({Name, Value}) -> set_env(Name, Value) end, Saved)
+ end.
+
+set_env(Name, false) -> os:unsetenv(Name);
+set_env(Name, Value) -> os:putenv(Name, Value).
+
+write_desktop(Dir, FileName, Name, Exec) ->
+ ok = file:write_file(
+ filename:join(Dir, FileName),
+ iolist_to_binary(["[Desktop Entry]\nName=", Name, "\nExec=", Exec, "\n"])
+ ).
+
+mk_tmp_dir() ->
+ Path = filename:join(
+ test_tmp_dir(), "eunit-watcher-" ++ integer_to_list(erlang:unique_integer([positive]))
+ ),
+ ok = file:make_dir(Path),
+ Path.
+
+test_tmp_dir() ->
+ case os:getenv("TMPDIR") of
+ false -> "/tmp";
+ Dir -> Dir
+ end.
+
+rm_rf(Path) ->
+ case filelib:is_dir(Path) of
+ true ->
+ {ok, Entries} = file:list_dir(Path),
+ lists:foreach(fun(E) -> rm_rf(filename:join(Path, E)) end, Entries),
+ file:del_dir(Path);
+ false ->
+ file:delete(Path)
+ end.