diff options
Diffstat (limited to 'src')
| -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 |
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). |
