diff options
| -rw-r--r-- | README.md | 48 | ||||
| -rwxr-xr-x | bin/er_xdg_pipe_menu_menu | 17 | ||||
| -rw-r--r-- | rebar.config | 2 | ||||
| -rw-r--r-- | rebar.lock | 9 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu.app.src | 5 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_cache.erl | 56 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_cli.erl | 41 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_socket.erl | 66 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_sup.erl | 10 | ||||
| -rw-r--r-- | src/er_xdg_pipe_menu_watcher.erl | 85 | ||||
| -rw-r--r-- | test/er_xdg_pipe_menu_cache_tests.erl | 130 | ||||
| -rw-r--r-- | test/er_xdg_pipe_menu_socket_tests.erl | 93 | ||||
| -rw-r--r-- | test/er_xdg_pipe_menu_watcher_tests.erl | 86 |
13 files changed, 641 insertions, 7 deletions
@@ -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"}, @@ -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. |
