aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 11:18:56 -0400
committerChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 11:18:56 -0400
commit7e8472e1e2a0b39746ef86c041d56b92bab5a6b7 (patch)
tree4d37a36c945446b4bded6989a780abe4cd3f2f1e
parent46b72a375101b54819f8750750017bd71e7c3798 (diff)
Wire up cache, file watcher, and Unix socket daemon
Adds a gen_server cache holding the rendered menu, an fs/inotify-based watcher that debounces changes and invalidates it, a Unix domain socket serving the cached binary, and the escript client Openbox invokes (with an inline render fallback if the daemon isn't running). Also documents local deployment (systemd user unit, Openbox menu.xml wiring) in the README. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-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.