aboutsummaryrefslogtreecommitdiff
path: root/src
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 /src
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>
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).