From 5ff1fbe175af17fbb808c5ea942f465a932da3af Mon Sep 17 00:00:00 2001 From: "Christopher R. Nelson" Date: Tue, 21 Jul 2026 11:44:41 -0400 Subject: Rename app from er_xdg_pipe_menu to xdg_pipe_menu Drop the "er-" prefix throughout: module/atom names, filenames, socket paths, and docs. It was an implementation detail, not part of the app's identity. Co-Authored-By: Claude Sonnet 5 --- src/er_xdg_pipe_menu.app.src | 16 ---- src/er_xdg_pipe_menu_app.erl | 18 ----- src/er_xdg_pipe_menu_cache.erl | 56 ------------- src/er_xdg_pipe_menu_cli.erl | 41 ---------- src/er_xdg_pipe_menu_desktop_entry.erl | 141 --------------------------------- src/er_xdg_pipe_menu_renderer.erl | 78 ------------------ src/er_xdg_pipe_menu_scanner.erl | 62 --------------- src/er_xdg_pipe_menu_socket.erl | 66 --------------- src/er_xdg_pipe_menu_sup.erl | 41 ---------- src/er_xdg_pipe_menu_watcher.erl | 85 -------------------- src/xdg_pipe_menu.app.src | 16 ++++ src/xdg_pipe_menu_app.erl | 18 +++++ src/xdg_pipe_menu_cache.erl | 56 +++++++++++++ src/xdg_pipe_menu_cli.erl | 41 ++++++++++ src/xdg_pipe_menu_desktop_entry.erl | 141 +++++++++++++++++++++++++++++++++ src/xdg_pipe_menu_renderer.erl | 78 ++++++++++++++++++ src/xdg_pipe_menu_scanner.erl | 62 +++++++++++++++ src/xdg_pipe_menu_socket.erl | 66 +++++++++++++++ src/xdg_pipe_menu_sup.erl | 41 ++++++++++ src/xdg_pipe_menu_watcher.erl | 85 ++++++++++++++++++++ 20 files changed, 604 insertions(+), 604 deletions(-) delete mode 100644 src/er_xdg_pipe_menu.app.src delete mode 100644 src/er_xdg_pipe_menu_app.erl delete mode 100644 src/er_xdg_pipe_menu_cache.erl delete mode 100644 src/er_xdg_pipe_menu_cli.erl delete mode 100644 src/er_xdg_pipe_menu_desktop_entry.erl delete mode 100644 src/er_xdg_pipe_menu_renderer.erl delete mode 100644 src/er_xdg_pipe_menu_scanner.erl delete mode 100644 src/er_xdg_pipe_menu_socket.erl delete mode 100644 src/er_xdg_pipe_menu_sup.erl delete mode 100644 src/er_xdg_pipe_menu_watcher.erl create mode 100644 src/xdg_pipe_menu.app.src create mode 100644 src/xdg_pipe_menu_app.erl create mode 100644 src/xdg_pipe_menu_cache.erl create mode 100644 src/xdg_pipe_menu_cli.erl create mode 100644 src/xdg_pipe_menu_desktop_entry.erl create mode 100644 src/xdg_pipe_menu_renderer.erl create mode 100644 src/xdg_pipe_menu_scanner.erl create mode 100644 src/xdg_pipe_menu_socket.erl create mode 100644 src/xdg_pipe_menu_sup.erl create mode 100644 src/xdg_pipe_menu_watcher.erl (limited to 'src') diff --git a/src/er_xdg_pipe_menu.app.src b/src/er_xdg_pipe_menu.app.src deleted file mode 100644 index 4ba8103..0000000 --- a/src/er_xdg_pipe_menu.app.src +++ /dev/null @@ -1,16 +0,0 @@ -{application, er_xdg_pipe_menu, [ - {description, "Openbox pipemenu generator from XDG .desktop files"}, - {vsn, "0.1.0"}, - {registered, []}, - {mod, {er_xdg_pipe_menu_app, []}}, - {applications, [ - kernel, - stdlib, - xmerl, - fs - ]}, - {env, [{debounce_ms, 500}]}, - {modules, []}, - {licenses, ["MIT"]}, - {links, []} - ]}. diff --git a/src/er_xdg_pipe_menu_app.erl b/src/er_xdg_pipe_menu_app.erl deleted file mode 100644 index 1880de8..0000000 --- a/src/er_xdg_pipe_menu_app.erl +++ /dev/null @@ -1,18 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc er_xdg_pipe_menu public API -%% @end -%%%------------------------------------------------------------------- - --module(er_xdg_pipe_menu_app). - --behaviour(application). - --export([start/2, stop/1]). - -start(_StartType, _StartArgs) -> - er_xdg_pipe_menu_sup:start_link(). - -stop(_State) -> - ok. - -%% internal functions diff --git a/src/er_xdg_pipe_menu_cache.erl b/src/er_xdg_pipe_menu_cache.erl deleted file mode 100644 index a71526e..0000000 --- a/src/er_xdg_pipe_menu_cache.erl +++ /dev/null @@ -1,56 +0,0 @@ -%%%------------------------------------------------------------------- -%% @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 deleted file mode 100644 index 92ac715..0000000 --- a/src/er_xdg_pipe_menu_cli.erl +++ /dev/null @@ -1,41 +0,0 @@ -%%%------------------------------------------------------------------- -%% @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_desktop_entry.erl b/src/er_xdg_pipe_menu_desktop_entry.erl deleted file mode 100644 index 9a71013..0000000 --- a/src/er_xdg_pipe_menu_desktop_entry.erl +++ /dev/null @@ -1,141 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc Parser for XDG .desktop files. -%% -%% Reads the `[Desktop Entry]' group of a desktop entry file and -%% extracts the fields needed to build a menu item: `Name', `Exec' -%% and (optionally) `Icon' and `Categories'. Other groups (e.g. -%% `[Desktop Action ...]') and localized keys (e.g. `Name[fr]') are -%% ignored. -%% @end -%%%------------------------------------------------------------------- - --module(er_xdg_pipe_menu_desktop_entry). - --include("er_xdg_pipe_menu.hrl"). - --export([parse_file/1, parse/1]). - --define(GROUP, <<"Desktop Entry">>). --define(WANTED_KEYS, [ - <<"Name">>, <<"Exec">>, <<"Icon">>, <<"Categories">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">> -]). --define(FIELD_CODES, "fFuUdDnNickvm"). - --spec parse_file(file:filename_all()) -> {ok, #desktop_entry{}} | {error, term()}. -parse_file(Path) -> - case file:read_file(Path) of - {ok, Bin} -> parse(Bin); - {error, Reason} -> {error, Reason} - end. - --spec parse(binary()) -> {ok, #desktop_entry{}} | {error, missing_name | missing_exec}. -parse(Bin) -> - to_entry(scan(Bin)). - -%% Fold every line into a map of the fields we care about, tracking -%% which `[Group]' we're currently inside. -scan(Bin) -> - Lines = binary:split(Bin, [<<"\r\n">>, <<"\n">>], [global]), - {_Group, Fields} = lists:foldl(fun scan_line/2, {undefined, #{}}, Lines), - Fields. - -scan_line(Line, {Group, Fields}) -> - case classify(string:trim(Line)) of - skip -> - {Group, Fields}; - {section, Name} -> - {Name, Fields}; - {kv, Key, Value} -> - case Group =:= ?GROUP andalso lists:member(Key, ?WANTED_KEYS) of - true -> {Group, maps:put(Key, unescape(Value), Fields)}; - false -> {Group, Fields} - end - end. - -classify(<<>>) -> - skip; -classify(<<"#", _/binary>>) -> - skip; -classify(<<"[", Rest/binary>>) -> - Size = byte_size(Rest) - 1, - case Rest of - <> -> {section, string:trim(Name)}; - _ -> skip - end; -classify(Line) -> - case binary:split(Line, <<"=">>) of - [Key, Value] -> {kv, string:trim(Key), string:trim(Value)}; - [_] -> skip - end. - -%% Desktop entry escape sequences: \\ \s \n \t \r -unescape(Bin) -> - unescape(Bin, <<>>). - -unescape(<<>>, Acc) -> - Acc; -unescape(<<$\\, $\\, Rest/binary>>, Acc) -> - unescape(Rest, <>); -unescape(<<$\\, $s, Rest/binary>>, Acc) -> - unescape(Rest, <>); -unescape(<<$\\, $n, Rest/binary>>, Acc) -> - unescape(Rest, <>); -unescape(<<$\\, $t, Rest/binary>>, Acc) -> - unescape(Rest, <>); -unescape(<<$\\, $r, Rest/binary>>, Acc) -> - unescape(Rest, <>); -unescape(<>, Acc) -> - unescape(Rest, <>). - -to_entry(Fields) -> - case maps:find(<<"Name">>, Fields) of - error -> - {error, missing_name}; - {ok, Name} -> - case maps:find(<<"Exec">>, Fields) of - error -> - {error, missing_exec}; - {ok, Exec} -> - {ok, #desktop_entry{ - name = Name, - exec = strip_field_codes(Exec), - icon = maps:get(<<"Icon">>, Fields, undefined), - categories = parse_categories(maps:get(<<"Categories">>, Fields, undefined)), - hidden = is_hidden(Fields) - }} - end - end. - -%% `Categories' is a `;'-separated list, conventionally with a -%% trailing separator (e.g. `Utility;Development;'); empty segments -%% from that trailing separator (or repeated ones) are dropped. -parse_categories(undefined) -> - []; -parse_categories(Bin) -> - [C || C <- binary:split(Bin, <<";">>, [global]), C =/= <<>>]. - -%% Niche variations (e.g. a Type-less override dropped into -%% ~/.local/share/applications) are left to be special-cased there -%% rather than accounted for here. -is_hidden(Fields) -> - maps:get(<<"NoDisplay">>, Fields, <<"false">>) =:= <<"true">> orelse - maps:get(<<"Hidden">>, Fields, <<"false">>) =:= <<"true">> orelse - case maps:find(<<"Type">>, Fields) of - {ok, Type} -> Type =/= <<"Application">>; - error -> false - end. - -%% Field codes (%f, %u, %i, ...) are expanded by launchers that pass -%% files/URIs on the command line; a pipemenu has none to pass, so -%% each code is dropped along with the whitespace around it. `%%' is -%% then unescaped to a literal `%'. -strip_field_codes(Exec) -> - Tokens = binary:split(Exec, <<" ">>, [global]), - Kept = [T || T <- Tokens, T =/= <<>>, not is_field_code(T)], - Joined = iolist_to_binary(lists:join(<<" ">>, Kept)), - binary:replace(Joined, <<"%%">>, <<"%">>, [global]). - -is_field_code(<<"%", C>>) -> - lists:member(C, ?FIELD_CODES); -is_field_code(_) -> - false. diff --git a/src/er_xdg_pipe_menu_renderer.erl b/src/er_xdg_pipe_menu_renderer.erl deleted file mode 100644 index f89d868..0000000 --- a/src/er_xdg_pipe_menu_renderer.erl +++ /dev/null @@ -1,78 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc Renders parsed desktop entries as an Openbox pipe menu. -%% -%% Given a list of `#desktop_entry{}' records, produces the XML that -%% Openbox expects on stdout from a pipe menu: one `' submenu -%% per category, each containing an `' per visible entry in -%% that category (entries with `hidden = true' are dropped). An entry -%% belonging to several categories appears in each of their submenus; -%% an entry with no categories is placed in a catch-all `Other' -%% submenu. Categories are sorted case-insensitively, and entries -%% within a submenu are sorted case-insensitively by name. -%% @end -%%%------------------------------------------------------------------- - --module(er_xdg_pipe_menu_renderer). - --include("er_xdg_pipe_menu.hrl"). - --export([render/1]). - --define(OTHER_CATEGORY, <<"Other">>). - --spec render([#desktop_entry{}]) -> binary(). -render(Entries) -> - Visible = [E || E <- Entries, not E#desktop_entry.hidden], - Grouped = group_by_category(Visible), - Categories = lists:sort(fun category_lt/2, maps:keys(Grouped)), - Menu = {openbox_pipe_menu, [], submenus_content(Categories, Grouped)}, - Xml = xmerl:export_simple( - [Menu], - xmerl_xml, - [{prolog, ["\n"]}] - ), - iolist_to_binary([Xml, "\n"]). - -group_by_category(Entries) -> - lists:foldl(fun add_entry_to_categories/2, #{}, Entries). - -add_entry_to_categories(#desktop_entry{categories = []} = E, Acc) -> - add_entry_to_category(?OTHER_CATEGORY, E, Acc); -add_entry_to_categories(#desktop_entry{categories = Cats} = E, Acc) -> - lists:foldl(fun(Cat, Acc0) -> add_entry_to_category(Cat, E, Acc0) end, Acc, Cats). - -add_entry_to_category(Cat, E, Acc) -> - maps:update_with(Cat, fun(Es) -> [E | Es] end, [E], Acc). - -category_lt(A, B) -> string:lowercase(A) =< string:lowercase(B). - -by_name(#desktop_entry{name = A}, #desktop_entry{name = B}) -> - string:lowercase(A) =< string:lowercase(B). - -submenus_content(Categories, Grouped) -> - lists:foldr( - fun(Cat, Acc) -> ["\n ", submenu_element(Cat, Grouped) | Acc] end, ["\n"], Categories - ). - -submenu_element(Cat, Grouped) -> - Entries = lists:sort(fun by_name/2, maps:get(Cat, Grouped)), - {menu, [{id, Cat}, {label, Cat}], items_content(Entries)}. - -items_content(Entries) -> - lists:foldr(fun(E, Acc) -> ["\n ", item_element(E) | Acc] end, ["\n "], Entries). - -item_element(#desktop_entry{name = Name, exec = Exec, icon = Icon}) -> - {item, item_attrs(Name, Icon), [ - "\n ", - {action, [{name, <<"Execute">>}], [ - "\n ", - {command, [], [text(Exec)]}, - "\n " - ]}, - "\n " - ]}. - -item_attrs(Name, undefined) -> [{label, Name}]; -item_attrs(Name, Icon) -> [{label, Name}, {icon, Icon}]. - -text(Bin) -> unicode:characters_to_list(Bin). diff --git a/src/er_xdg_pipe_menu_scanner.erl b/src/er_xdg_pipe_menu_scanner.erl deleted file mode 100644 index 131b4f6..0000000 --- a/src/er_xdg_pipe_menu_scanner.erl +++ /dev/null @@ -1,62 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc Locates .desktop files under the XDG application directories. -%% -%% Directory precedence follows the XDG Base Directory Specification: -%% the user's data home (most specific, so its entries should win over -%% system ones downstream) is searched first, then each directory in -%% `XDG_DATA_DIRS'. Each of `XDG_DATA_HOME' and `XDG_DATA_DIRS' falls -%% back to its spec-defined default independently when unset or empty -%% -- so a Flatpak-aware `XDG_DATA_DIRS' (which typically adds -%% `~/.local/share/flatpak/exports/share') is honored in place of the -%% default rather than merged with it. -%% @end -%%%------------------------------------------------------------------- - --module(er_xdg_pipe_menu_scanner). - --export([data_dirs/0, find_desktop_files/0, find_desktop_files/1]). - --define(DEFAULT_DATA_DIRS, ["/usr/local/share", "/usr/share"]). - --spec data_dirs() -> [file:filename_all()]. -data_dirs() -> - [filename:join(Dir, "applications") || Dir <- data_home() ++ data_dirs_list()]. - --spec find_desktop_files() -> [file:filename_all()]. -find_desktop_files() -> - find_desktop_files(data_dirs()). - --spec find_desktop_files([file:filename_all()]) -> [file:filename_all()]. -find_desktop_files(Dirs) -> - lists:flatmap(fun find_in_dir/1, Dirs). - -find_in_dir(Dir) -> - case filelib:is_dir(Dir) of - true -> filelib:wildcard(filename:join(Dir, "**/*.desktop")); - false -> [] - end. - -data_home() -> - case env("XDG_DATA_HOME") of - undefined -> - case env("HOME") of - undefined -> []; - Home -> [filename:join([Home, ".local", "share"])] - end; - Dir -> - [Dir] - end. - -data_dirs_list() -> - case env("XDG_DATA_DIRS") of - undefined -> ?DEFAULT_DATA_DIRS; - Value -> [D || D <- string:split(Value, ":", all), D =/= ""] - end. - -%% The spec treats an unset and an empty variable the same way. -env(Name) -> - case os:getenv(Name) of - false -> undefined; - "" -> undefined; - Value -> Value - end. diff --git a/src/er_xdg_pipe_menu_socket.erl b/src/er_xdg_pipe_menu_socket.erl deleted file mode 100644 index 1bcca91..0000000 --- a/src/er_xdg_pipe_menu_socket.erl +++ /dev/null @@ -1,66 +0,0 @@ -%%%------------------------------------------------------------------- -%% @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 deleted file mode 100644 index 1916c0b..0000000 --- a/src/er_xdg_pipe_menu_sup.erl +++ /dev/null @@ -1,41 +0,0 @@ -%%%------------------------------------------------------------------- -%% @doc er_xdg_pipe_menu top level supervisor. -%% @end -%%%------------------------------------------------------------------- - --module(er_xdg_pipe_menu_sup). - --behaviour(supervisor). - --export([start_link/0]). - --export([init/1]). - --define(SERVER, ?MODULE). - -start_link() -> - supervisor:start_link({local, ?SERVER}, ?MODULE, []). - -%% sup_flags() = #{strategy => strategy(), % optional -%% intensity => non_neg_integer(), % optional -%% period => pos_integer()} % optional -%% child_spec() = #{id => child_id(), % mandatory -%% start => mfargs(), % mandatory -%% restart => restart(), % optional -%% shutdown => shutdown(), % optional -%% type => worker(), % optional -%% modules => modules()} % optional -init([]) -> - SupFlags = #{ - strategy => one_for_all, - intensity => 5, - period => 10 - }, - 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 deleted file mode 100644 index d8c407d..0000000 --- a/src/er_xdg_pipe_menu_watcher.erl +++ /dev/null @@ -1,85 +0,0 @@ -%%%------------------------------------------------------------------- -%% @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/src/xdg_pipe_menu.app.src b/src/xdg_pipe_menu.app.src new file mode 100644 index 0000000..0d0d7d6 --- /dev/null +++ b/src/xdg_pipe_menu.app.src @@ -0,0 +1,16 @@ +{application, xdg_pipe_menu, [ + {description, "Openbox pipemenu generator from XDG .desktop files"}, + {vsn, "0.1.0"}, + {registered, []}, + {mod, {xdg_pipe_menu_app, []}}, + {applications, [ + kernel, + stdlib, + xmerl, + fs + ]}, + {env, [{debounce_ms, 500}]}, + {modules, []}, + {licenses, ["MIT"]}, + {links, []} + ]}. diff --git a/src/xdg_pipe_menu_app.erl b/src/xdg_pipe_menu_app.erl new file mode 100644 index 0000000..7401a03 --- /dev/null +++ b/src/xdg_pipe_menu_app.erl @@ -0,0 +1,18 @@ +%%%------------------------------------------------------------------- +%% @doc xdg_pipe_menu public API +%% @end +%%%------------------------------------------------------------------- + +-module(xdg_pipe_menu_app). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + xdg_pipe_menu_sup:start_link(). + +stop(_State) -> + ok. + +%% internal functions diff --git a/src/xdg_pipe_menu_cache.erl b/src/xdg_pipe_menu_cache.erl new file mode 100644 index 0000000..0e10fea --- /dev/null +++ b/src/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(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 = xdg_pipe_menu_scanner:find_desktop_files(Dirs), + Entries = lists:filtermap(fun parse/1, Files), + xdg_pipe_menu_renderer:render(Entries). + +parse(File) -> + case xdg_pipe_menu_desktop_entry:parse_file(File) of + {ok, Entry} -> {true, Entry}; + {error, _} -> false + end. + +init([]) -> + {ok, build(xdg_pipe_menu_scanner:data_dirs())}. + +handle_call(get_menu, _From, Menu) -> + {reply, Menu, Menu}. + +handle_cast(invalidate, _Menu) -> + {noreply, build(xdg_pipe_menu_scanner:data_dirs())}. + +handle_info(_Info, State) -> + {noreply, State}. diff --git a/src/xdg_pipe_menu_cli.erl b/src/xdg_pipe_menu_cli.erl new file mode 100644 index 0000000..aa2f98a --- /dev/null +++ b/src/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(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 -> xdg_pipe_menu_cache:build(xdg_pipe_menu_scanner:data_dirs()) + end, + io:put_chars(Menu). + +from_daemon() -> + Path = 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/xdg_pipe_menu_desktop_entry.erl b/src/xdg_pipe_menu_desktop_entry.erl new file mode 100644 index 0000000..62adaf1 --- /dev/null +++ b/src/xdg_pipe_menu_desktop_entry.erl @@ -0,0 +1,141 @@ +%%%------------------------------------------------------------------- +%% @doc Parser for XDG .desktop files. +%% +%% Reads the `[Desktop Entry]' group of a desktop entry file and +%% extracts the fields needed to build a menu item: `Name', `Exec' +%% and (optionally) `Icon' and `Categories'. Other groups (e.g. +%% `[Desktop Action ...]') and localized keys (e.g. `Name[fr]') are +%% ignored. +%% @end +%%%------------------------------------------------------------------- + +-module(xdg_pipe_menu_desktop_entry). + +-include("xdg_pipe_menu.hrl"). + +-export([parse_file/1, parse/1]). + +-define(GROUP, <<"Desktop Entry">>). +-define(WANTED_KEYS, [ + <<"Name">>, <<"Exec">>, <<"Icon">>, <<"Categories">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">> +]). +-define(FIELD_CODES, "fFuUdDnNickvm"). + +-spec parse_file(file:filename_all()) -> {ok, #desktop_entry{}} | {error, term()}. +parse_file(Path) -> + case file:read_file(Path) of + {ok, Bin} -> parse(Bin); + {error, Reason} -> {error, Reason} + end. + +-spec parse(binary()) -> {ok, #desktop_entry{}} | {error, missing_name | missing_exec}. +parse(Bin) -> + to_entry(scan(Bin)). + +%% Fold every line into a map of the fields we care about, tracking +%% which `[Group]' we're currently inside. +scan(Bin) -> + Lines = binary:split(Bin, [<<"\r\n">>, <<"\n">>], [global]), + {_Group, Fields} = lists:foldl(fun scan_line/2, {undefined, #{}}, Lines), + Fields. + +scan_line(Line, {Group, Fields}) -> + case classify(string:trim(Line)) of + skip -> + {Group, Fields}; + {section, Name} -> + {Name, Fields}; + {kv, Key, Value} -> + case Group =:= ?GROUP andalso lists:member(Key, ?WANTED_KEYS) of + true -> {Group, maps:put(Key, unescape(Value), Fields)}; + false -> {Group, Fields} + end + end. + +classify(<<>>) -> + skip; +classify(<<"#", _/binary>>) -> + skip; +classify(<<"[", Rest/binary>>) -> + Size = byte_size(Rest) - 1, + case Rest of + <> -> {section, string:trim(Name)}; + _ -> skip + end; +classify(Line) -> + case binary:split(Line, <<"=">>) of + [Key, Value] -> {kv, string:trim(Key), string:trim(Value)}; + [_] -> skip + end. + +%% Desktop entry escape sequences: \\ \s \n \t \r +unescape(Bin) -> + unescape(Bin, <<>>). + +unescape(<<>>, Acc) -> + Acc; +unescape(<<$\\, $\\, Rest/binary>>, Acc) -> + unescape(Rest, <>); +unescape(<<$\\, $s, Rest/binary>>, Acc) -> + unescape(Rest, <>); +unescape(<<$\\, $n, Rest/binary>>, Acc) -> + unescape(Rest, <>); +unescape(<<$\\, $t, Rest/binary>>, Acc) -> + unescape(Rest, <>); +unescape(<<$\\, $r, Rest/binary>>, Acc) -> + unescape(Rest, <>); +unescape(<>, Acc) -> + unescape(Rest, <>). + +to_entry(Fields) -> + case maps:find(<<"Name">>, Fields) of + error -> + {error, missing_name}; + {ok, Name} -> + case maps:find(<<"Exec">>, Fields) of + error -> + {error, missing_exec}; + {ok, Exec} -> + {ok, #desktop_entry{ + name = Name, + exec = strip_field_codes(Exec), + icon = maps:get(<<"Icon">>, Fields, undefined), + categories = parse_categories(maps:get(<<"Categories">>, Fields, undefined)), + hidden = is_hidden(Fields) + }} + end + end. + +%% `Categories' is a `;'-separated list, conventionally with a +%% trailing separator (e.g. `Utility;Development;'); empty segments +%% from that trailing separator (or repeated ones) are dropped. +parse_categories(undefined) -> + []; +parse_categories(Bin) -> + [C || C <- binary:split(Bin, <<";">>, [global]), C =/= <<>>]. + +%% Niche variations (e.g. a Type-less override dropped into +%% ~/.local/share/applications) are left to be special-cased there +%% rather than accounted for here. +is_hidden(Fields) -> + maps:get(<<"NoDisplay">>, Fields, <<"false">>) =:= <<"true">> orelse + maps:get(<<"Hidden">>, Fields, <<"false">>) =:= <<"true">> orelse + case maps:find(<<"Type">>, Fields) of + {ok, Type} -> Type =/= <<"Application">>; + error -> false + end. + +%% Field codes (%f, %u, %i, ...) are expanded by launchers that pass +%% files/URIs on the command line; a pipemenu has none to pass, so +%% each code is dropped along with the whitespace around it. `%%' is +%% then unescaped to a literal `%'. +strip_field_codes(Exec) -> + Tokens = binary:split(Exec, <<" ">>, [global]), + Kept = [T || T <- Tokens, T =/= <<>>, not is_field_code(T)], + Joined = iolist_to_binary(lists:join(<<" ">>, Kept)), + binary:replace(Joined, <<"%%">>, <<"%">>, [global]). + +is_field_code(<<"%", C>>) -> + lists:member(C, ?FIELD_CODES); +is_field_code(_) -> + false. diff --git a/src/xdg_pipe_menu_renderer.erl b/src/xdg_pipe_menu_renderer.erl new file mode 100644 index 0000000..c94fe83 --- /dev/null +++ b/src/xdg_pipe_menu_renderer.erl @@ -0,0 +1,78 @@ +%%%------------------------------------------------------------------- +%% @doc Renders parsed desktop entries as an Openbox pipe menu. +%% +%% Given a list of `#desktop_entry{}' records, produces the XML that +%% Openbox expects on stdout from a pipe menu: one `' submenu +%% per category, each containing an `' per visible entry in +%% that category (entries with `hidden = true' are dropped). An entry +%% belonging to several categories appears in each of their submenus; +%% an entry with no categories is placed in a catch-all `Other' +%% submenu. Categories are sorted case-insensitively, and entries +%% within a submenu are sorted case-insensitively by name. +%% @end +%%%------------------------------------------------------------------- + +-module(xdg_pipe_menu_renderer). + +-include("xdg_pipe_menu.hrl"). + +-export([render/1]). + +-define(OTHER_CATEGORY, <<"Other">>). + +-spec render([#desktop_entry{}]) -> binary(). +render(Entries) -> + Visible = [E || E <- Entries, not E#desktop_entry.hidden], + Grouped = group_by_category(Visible), + Categories = lists:sort(fun category_lt/2, maps:keys(Grouped)), + Menu = {openbox_pipe_menu, [], submenus_content(Categories, Grouped)}, + Xml = xmerl:export_simple( + [Menu], + xmerl_xml, + [{prolog, ["\n"]}] + ), + iolist_to_binary([Xml, "\n"]). + +group_by_category(Entries) -> + lists:foldl(fun add_entry_to_categories/2, #{}, Entries). + +add_entry_to_categories(#desktop_entry{categories = []} = E, Acc) -> + add_entry_to_category(?OTHER_CATEGORY, E, Acc); +add_entry_to_categories(#desktop_entry{categories = Cats} = E, Acc) -> + lists:foldl(fun(Cat, Acc0) -> add_entry_to_category(Cat, E, Acc0) end, Acc, Cats). + +add_entry_to_category(Cat, E, Acc) -> + maps:update_with(Cat, fun(Es) -> [E | Es] end, [E], Acc). + +category_lt(A, B) -> string:lowercase(A) =< string:lowercase(B). + +by_name(#desktop_entry{name = A}, #desktop_entry{name = B}) -> + string:lowercase(A) =< string:lowercase(B). + +submenus_content(Categories, Grouped) -> + lists:foldr( + fun(Cat, Acc) -> ["\n ", submenu_element(Cat, Grouped) | Acc] end, ["\n"], Categories + ). + +submenu_element(Cat, Grouped) -> + Entries = lists:sort(fun by_name/2, maps:get(Cat, Grouped)), + {menu, [{id, Cat}, {label, Cat}], items_content(Entries)}. + +items_content(Entries) -> + lists:foldr(fun(E, Acc) -> ["\n ", item_element(E) | Acc] end, ["\n "], Entries). + +item_element(#desktop_entry{name = Name, exec = Exec, icon = Icon}) -> + {item, item_attrs(Name, Icon), [ + "\n ", + {action, [{name, <<"Execute">>}], [ + "\n ", + {command, [], [text(Exec)]}, + "\n " + ]}, + "\n " + ]}. + +item_attrs(Name, undefined) -> [{label, Name}]; +item_attrs(Name, Icon) -> [{label, Name}, {icon, Icon}]. + +text(Bin) -> unicode:characters_to_list(Bin). diff --git a/src/xdg_pipe_menu_scanner.erl b/src/xdg_pipe_menu_scanner.erl new file mode 100644 index 0000000..44c0a59 --- /dev/null +++ b/src/xdg_pipe_menu_scanner.erl @@ -0,0 +1,62 @@ +%%%------------------------------------------------------------------- +%% @doc Locates .desktop files under the XDG application directories. +%% +%% Directory precedence follows the XDG Base Directory Specification: +%% the user's data home (most specific, so its entries should win over +%% system ones downstream) is searched first, then each directory in +%% `XDG_DATA_DIRS'. Each of `XDG_DATA_HOME' and `XDG_DATA_DIRS' falls +%% back to its spec-defined default independently when unset or empty +%% -- so a Flatpak-aware `XDG_DATA_DIRS' (which typically adds +%% `~/.local/share/flatpak/exports/share') is honored in place of the +%% default rather than merged with it. +%% @end +%%%------------------------------------------------------------------- + +-module(xdg_pipe_menu_scanner). + +-export([data_dirs/0, find_desktop_files/0, find_desktop_files/1]). + +-define(DEFAULT_DATA_DIRS, ["/usr/local/share", "/usr/share"]). + +-spec data_dirs() -> [file:filename_all()]. +data_dirs() -> + [filename:join(Dir, "applications") || Dir <- data_home() ++ data_dirs_list()]. + +-spec find_desktop_files() -> [file:filename_all()]. +find_desktop_files() -> + find_desktop_files(data_dirs()). + +-spec find_desktop_files([file:filename_all()]) -> [file:filename_all()]. +find_desktop_files(Dirs) -> + lists:flatmap(fun find_in_dir/1, Dirs). + +find_in_dir(Dir) -> + case filelib:is_dir(Dir) of + true -> filelib:wildcard(filename:join(Dir, "**/*.desktop")); + false -> [] + end. + +data_home() -> + case env("XDG_DATA_HOME") of + undefined -> + case env("HOME") of + undefined -> []; + Home -> [filename:join([Home, ".local", "share"])] + end; + Dir -> + [Dir] + end. + +data_dirs_list() -> + case env("XDG_DATA_DIRS") of + undefined -> ?DEFAULT_DATA_DIRS; + Value -> [D || D <- string:split(Value, ":", all), D =/= ""] + end. + +%% The spec treats an unset and an empty variable the same way. +env(Name) -> + case os:getenv(Name) of + false -> undefined; + "" -> undefined; + Value -> Value + end. diff --git a/src/xdg_pipe_menu_socket.erl b/src/xdg_pipe_menu_socket.erl new file mode 100644 index 0000000..1bfde52 --- /dev/null +++ b/src/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 `xdg_pipe_menu_cache:get_menu/0' binary and +%% closed. There is no request payload to parse. +%% @end +%%%------------------------------------------------------------------- + +-module(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(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", "xdg_pipe_menu-" ++ os:getenv("USER", "unknown") ++ ".sock"); + Dir -> filename:join(Dir, "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, xdg_pipe_menu_cache:get_menu()), + gen_tcp:close(Socket). diff --git a/src/xdg_pipe_menu_sup.erl b/src/xdg_pipe_menu_sup.erl new file mode 100644 index 0000000..a4d2828 --- /dev/null +++ b/src/xdg_pipe_menu_sup.erl @@ -0,0 +1,41 @@ +%%%------------------------------------------------------------------- +%% @doc xdg_pipe_menu top level supervisor. +%% @end +%%%------------------------------------------------------------------- + +-module(xdg_pipe_menu_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +-define(SERVER, ?MODULE). + +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +%% sup_flags() = #{strategy => strategy(), % optional +%% intensity => non_neg_integer(), % optional +%% period => pos_integer()} % optional +%% child_spec() = #{id => child_id(), % mandatory +%% start => mfargs(), % mandatory +%% restart => restart(), % optional +%% shutdown => shutdown(), % optional +%% type => worker(), % optional +%% modules => modules()} % optional +init([]) -> + SupFlags = #{ + strategy => one_for_all, + intensity => 5, + period => 10 + }, + ChildSpecs = [ + #{id => xdg_pipe_menu_cache, start => {xdg_pipe_menu_cache, start_link, []}}, + #{id => xdg_pipe_menu_watcher, start => {xdg_pipe_menu_watcher, start_link, []}}, + #{id => xdg_pipe_menu_socket, start => {xdg_pipe_menu_socket, start_link, []}} + ], + {ok, {SupFlags, ChildSpecs}}. + +%% internal functions diff --git a/src/xdg_pipe_menu_watcher.erl b/src/xdg_pipe_menu_watcher.erl new file mode 100644 index 0000000..64fb2c6 --- /dev/null +++ b/src/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 `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(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 <- 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("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( + "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( + "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) -> + 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(xdg_pipe_menu, debounce_ms, ?DEFAULT_DEBOUNCE_MS). -- cgit v1.2.3