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 --- README.md | 20 +-- bin/er_xdg_pipe_menu_menu | 17 --- bin/xdg_pipe_menu_menu | 17 +++ include/er_xdg_pipe_menu.hrl | 7 - include/xdg_pipe_menu.hrl | 7 + rebar.config | 2 +- 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 ++++++++++++ test/er_xdg_pipe_menu_cache_tests.erl | 130 ------------------ test/er_xdg_pipe_menu_desktop_entry_tests.erl | 183 -------------------------- test/er_xdg_pipe_menu_renderer_tests.erl | 167 ----------------------- test/er_xdg_pipe_menu_scanner_tests.erl | 144 -------------------- test/er_xdg_pipe_menu_socket_tests.erl | 93 ------------- test/er_xdg_pipe_menu_watcher_tests.erl | 86 ------------ test/xdg_pipe_menu_cache_tests.erl | 130 ++++++++++++++++++ test/xdg_pipe_menu_desktop_entry_tests.erl | 183 ++++++++++++++++++++++++++ test/xdg_pipe_menu_renderer_tests.erl | 167 +++++++++++++++++++++++ test/xdg_pipe_menu_scanner_tests.erl | 144 ++++++++++++++++++++ test/xdg_pipe_menu_socket_tests.erl | 93 +++++++++++++ test/xdg_pipe_menu_watcher_tests.erl | 86 ++++++++++++ 38 files changed, 1442 insertions(+), 1442 deletions(-) delete mode 100755 bin/er_xdg_pipe_menu_menu create mode 100755 bin/xdg_pipe_menu_menu delete mode 100644 include/er_xdg_pipe_menu.hrl create mode 100644 include/xdg_pipe_menu.hrl 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 delete mode 100644 test/er_xdg_pipe_menu_cache_tests.erl delete mode 100644 test/er_xdg_pipe_menu_desktop_entry_tests.erl delete mode 100644 test/er_xdg_pipe_menu_renderer_tests.erl delete mode 100644 test/er_xdg_pipe_menu_scanner_tests.erl delete mode 100644 test/er_xdg_pipe_menu_socket_tests.erl delete mode 100644 test/er_xdg_pipe_menu_watcher_tests.erl create mode 100644 test/xdg_pipe_menu_cache_tests.erl create mode 100644 test/xdg_pipe_menu_desktop_entry_tests.erl create mode 100644 test/xdg_pipe_menu_renderer_tests.erl create mode 100644 test/xdg_pipe_menu_scanner_tests.erl create mode 100644 test/xdg_pipe_menu_socket_tests.erl create mode 100644 test/xdg_pipe_menu_watcher_tests.erl diff --git a/README.md b/README.md index fbf610c..c39385d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -er_xdg_pipe_menu +xdg_pipe_menu ===== Openbox pipemenu generator from XDG .desktop files @@ -20,37 +20,37 @@ 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), +`$XDG_RUNTIME_DIR/xdg_pipe_menu.sock` (falling back to +`/tmp/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)." + $ rebar3 shell --eval "application:ensure_all_started(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`: +user unit, e.g. `~/.config/systemd/user/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)." + WorkingDirectory=/path/to/xdg-pipe-menu + ExecStart=/usr/bin/rebar3 shell --eval "application:ensure_all_started(xdg_pipe_menu)." Restart=on-failure [Install] WantedBy=default.target -then enable it with `systemctl --user enable --now er-xdg-pipe-menu`. +then enable it with `systemctl --user enable --now 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 +`bin/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`: - + 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` diff --git a/bin/er_xdg_pipe_menu_menu b/bin/er_xdg_pipe_menu_menu deleted file mode 100755 index 32a9bd9..0000000 --- a/bin/er_xdg_pipe_menu_menu +++ /dev/null @@ -1,17 +0,0 @@ -#!/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/bin/xdg_pipe_menu_menu b/bin/xdg_pipe_menu_menu new file mode 100755 index 0000000..a120a9f --- /dev/null +++ b/bin/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/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(), + 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/include/er_xdg_pipe_menu.hrl b/include/er_xdg_pipe_menu.hrl deleted file mode 100644 index 6d50b8d..0000000 --- a/include/er_xdg_pipe_menu.hrl +++ /dev/null @@ -1,7 +0,0 @@ --record(desktop_entry, { - name :: binary(), - exec :: binary(), - icon :: binary() | undefined, - categories :: [binary()], - hidden :: boolean() -}). diff --git a/include/xdg_pipe_menu.hrl b/include/xdg_pipe_menu.hrl new file mode 100644 index 0000000..6d50b8d --- /dev/null +++ b/include/xdg_pipe_menu.hrl @@ -0,0 +1,7 @@ +-record(desktop_entry, { + name :: binary(), + exec :: binary(), + icon :: binary() | undefined, + categories :: [binary()], + hidden :: boolean() +}). diff --git a/rebar.config b/rebar.config index 25a5a33..9071343 100644 --- a/rebar.config +++ b/rebar.config @@ -3,5 +3,5 @@ {shell, [ %% {config, "config/sys.config"}, - {apps, [er_xdg_pipe_menu]} + {apps, [xdg_pipe_menu]} ]}. 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). diff --git a/test/er_xdg_pipe_menu_cache_tests.erl b/test/er_xdg_pipe_menu_cache_tests.erl deleted file mode 100644 index a1d1440..0000000 --- a/test/er_xdg_pipe_menu_cache_tests.erl +++ /dev/null @@ -1,130 +0,0 @@ --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( - <<"\n" - "\n" - "\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_desktop_entry_tests.erl b/test/er_xdg_pipe_menu_desktop_entry_tests.erl deleted file mode 100644 index bcea976..0000000 --- a/test/er_xdg_pipe_menu_desktop_entry_tests.erl +++ /dev/null @@ -1,183 +0,0 @@ --module(er_xdg_pipe_menu_desktop_entry_tests). - --include_lib("eunit/include/eunit.hrl"). --include("er_xdg_pipe_menu.hrl"). - -basic_entry_test() -> - Bin = << - "[Desktop Entry]\n" - "Type=Application\n" - "Name=Firefox\n" - "Exec=firefox %u\n" - "Icon=firefox\n" - >>, - ?assertEqual( - {ok, #desktop_entry{ - name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, categories = [], hidden = false - }}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -icon_is_optional_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo\n" - >>, - ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -categories_are_split_on_semicolon_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo\n" - "Categories=Utility;Development;\n" - >>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories). - -categories_is_empty_list_when_absent_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual([], Entry#desktop_entry.categories). - -repeated_category_separators_are_ignored_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo\n" - "Categories=Utility;;Development\n" - >>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories). - -missing_name_test() -> - Bin = <<"[Desktop Entry]\nExec=foo\n">>, - ?assertEqual({error, missing_name}, er_xdg_pipe_menu_desktop_entry:parse(Bin)). - -missing_exec_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\n">>, - ?assertEqual({error, missing_exec}, er_xdg_pipe_menu_desktop_entry:parse(Bin)). - -ignores_other_groups_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo\n" - "\n" - "[Desktop Action new-window]\n" - "Name=New Window\n" - "Exec=foo --new-window\n" - >>, - ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -ignores_comments_and_blank_lines_test() -> - Bin = << - "# a comment\n" - "\n" - "[Desktop Entry]\n" - "# another comment\n" - "Name=Foo\n" - "\n" - "Exec=foo\n" - >>, - ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -strips_multiple_field_codes_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo %f --flag %i %c\n" - >>, - ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, categories = [], hidden = false}}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -literal_percent_is_unescaped_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo --progress=%%\n" - >>, - ?assertEqual( - {ok, #desktop_entry{ - name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, categories = [], hidden = false - }}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -unescapes_value_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\\sBar\nExec=foo\n">>, - ?assertEqual( - {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, - er_xdg_pipe_menu_desktop_entry:parse(Bin) - ). - -no_display_is_hidden_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nNoDisplay=true\n">>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual(true, Entry#desktop_entry.hidden). - -hidden_key_is_hidden_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nHidden=true\n">>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual(true, Entry#desktop_entry.hidden). - -non_application_type_is_hidden_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nType=Link\n">>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual(true, Entry#desktop_entry.hidden). - -application_type_is_not_hidden_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nType=Application\n">>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual(false, Entry#desktop_entry.hidden). - -missing_type_is_not_hidden_test() -> - Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual(false, Entry#desktop_entry.hidden). - -false_values_are_not_hidden_test() -> - Bin = << - "[Desktop Entry]\n" - "Name=Foo\n" - "Exec=foo\n" - "NoDisplay=false\n" - "Hidden=false\n" - "Type=Application\n" - >>, - {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin), - ?assertEqual(false, Entry#desktop_entry.hidden). - -parse_file_test() -> - Path = filename:join(test_tmp_dir(), "eunit-desktop-entry-test.desktop"), - ok = file:write_file(Path, <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>), - Result = er_xdg_pipe_menu_desktop_entry:parse_file(Path), - ok = file:delete(Path), - ?assertEqual( - {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, - Result - ). - -parse_file_missing_test() -> - ?assertEqual( - {error, enoent}, - er_xdg_pipe_menu_desktop_entry:parse_file("/nonexistent/does-not-exist.desktop") - ). - -test_tmp_dir() -> - case os:getenv("TMPDIR") of - false -> "/tmp"; - Dir -> Dir - end. diff --git a/test/er_xdg_pipe_menu_renderer_tests.erl b/test/er_xdg_pipe_menu_renderer_tests.erl deleted file mode 100644 index 7273625..0000000 --- a/test/er_xdg_pipe_menu_renderer_tests.erl +++ /dev/null @@ -1,167 +0,0 @@ --module(er_xdg_pipe_menu_renderer_tests). - --include_lib("eunit/include/eunit.hrl"). --include_lib("xmerl/include/xmerl.hrl"). --include("er_xdg_pipe_menu.hrl"). - -entry(Name, Exec, Icon) -> - entry(Name, Exec, Icon, []). - -entry(Name, Exec, Icon, Categories) -> - entry(Name, Exec, Icon, Categories, false). - -entry(Name, Exec, Icon, Categories, Hidden) -> - #desktop_entry{ - name = Name, exec = Exec, icon = Icon, categories = Categories, hidden = Hidden - }. - -empty_list_renders_empty_menu_test() -> - ?assertEqual( - <<"\n" - "\n" - "\n">>, - er_xdg_pipe_menu_renderer:render([]) - ). - -single_entry_with_icon_test() -> - Rendered = er_xdg_pipe_menu_renderer:render([ - entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]) - ]), - ?assertEqual( - <<"\n" - "\n" - " \n" - " \n" - " \n" - " firefox\n" - " \n" - " \n" - " \n" - "\n">>, - Rendered - ). - -entry_without_icon_omits_icon_attr_test() -> - Rendered = er_xdg_pipe_menu_renderer:render([ - entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>]) - ]), - ?assertEqual( - <<"\n" - "\n" - " \n" - " \n" - " \n" - " foo\n" - " \n" - " \n" - " \n" - "\n">>, - Rendered - ). - -entries_without_categories_go_in_other_submenu_test() -> - Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Foo">>, <<"foo">>, undefined)]), - ?assertEqual( - <<"\n" - "\n" - " \n" - " \n" - " \n" - " foo\n" - " \n" - " \n" - " \n" - "\n">>, - Rendered - ). - -hidden_entries_are_dropped_test() -> - Entries = [ - entry(<<"Visible">>, <<"visible">>, undefined, [<<"Utility">>]), - entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true) - ], - Rendered = er_xdg_pipe_menu_renderer:render(Entries), - ?assertNot(binary:match(Rendered, <<"Invisible">>) =/= nomatch), - ?assert(binary:match(Rendered, <<"Visible">>) =/= nomatch). - -hidden_entry_leaves_no_empty_submenu_test() -> - Entries = [entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true)], - Rendered = er_xdg_pipe_menu_renderer:render(Entries), - ?assertNot(binary:match(Rendered, <<"Utility">>) =/= nomatch). - -entries_are_sorted_case_insensitively_within_a_submenu_test() -> - Entries = [ - entry(<<"zeta">>, <<"zeta">>, undefined, [<<"Utility">>]), - entry(<<"Alpha">>, <<"alpha">>, undefined, [<<"Utility">>]), - entry(<<"beta">>, <<"beta">>, undefined, [<<"Utility">>]) - ], - Rendered = er_xdg_pipe_menu_renderer:render(Entries), - Positions = [ - element(1, binary:match(Rendered, <<"label=\"Alpha\"">>)), - element(1, binary:match(Rendered, <<"label=\"beta\"">>)), - element(1, binary:match(Rendered, <<"label=\"zeta\"">>)) - ], - ?assertEqual(Positions, lists:sort(Positions)). - -submenus_are_sorted_case_insensitively_test() -> - Entries = [ - entry(<<"Zed App">>, <<"zed">>, undefined, [<<"zeta">>]), - entry(<<"Alpha App">>, <<"alpha">>, undefined, [<<"Alpha">>]), - entry(<<"Beta App">>, <<"beta">>, undefined, [<<"beta">>]) - ], - Rendered = er_xdg_pipe_menu_renderer:render(Entries), - Positions = [ - element(1, binary:match(Rendered, <<"label=\"Alpha\"">>)), - element(1, binary:match(Rendered, <<"label=\"beta\"">>)), - element(1, binary:match(Rendered, <<"label=\"zeta\"">>)) - ], - ?assertEqual(Positions, lists:sort(Positions)). - -entry_with_multiple_categories_appears_in_each_submenu_test() -> - Entries = [entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>, <<"Development">>])], - Rendered = er_xdg_pipe_menu_renderer:render(Entries), - {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)), - Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content], - ?assertEqual([<<"Development">>, <<"Utility">>], lists:sort([menu_id(M) || M <- Menus])), - Items = [I || M <- Menus, I = #xmlElement{name = item} <- M#xmlElement.content], - ?assertEqual(2, length(Items)). - -escapes_special_characters_test() -> - Entry = entry( - <<"Foo & \"Baz\"">>, - <<"foo --title=\"&y\"">>, - <<"icon\"&<>">>, - [<<"Foo & ">>] - ), - Rendered = er_xdg_pipe_menu_renderer:render([Entry]), - ?assertEqual( - <<"\n" - "\n" - " \" label=\"Foo & <Bar>\">\n" - " "Baz"\" icon=\"icon"&<>\">\n" - " \n" - " foo --title=\"<x>&y\"\n" - " \n" - " \n" - " \n" - "\n">>, - Rendered - ). - -output_is_well_formed_xml_test() -> - Entries = [ - entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]), - entry(<<"Foo & Bar">>, <<"foo">>, undefined, [<<"Network">>]) - ], - Rendered = er_xdg_pipe_menu_renderer:render(Entries), - {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)), - ?assertEqual(openbox_pipe_menu, Doc#xmlElement.name), - Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content], - ?assertEqual(1, length(Menus)), - [Menu] = Menus, - Items = [E || E = #xmlElement{name = item} <- Menu#xmlElement.content], - ?assertEqual(2, length(Items)). - -menu_id(#xmlElement{attributes = Attrs}) -> - [Value] = [V || #xmlAttribute{name = id, value = V} <- Attrs], - list_to_binary(Value). diff --git a/test/er_xdg_pipe_menu_scanner_tests.erl b/test/er_xdg_pipe_menu_scanner_tests.erl deleted file mode 100644 index 3f93496..0000000 --- a/test/er_xdg_pipe_menu_scanner_tests.erl +++ /dev/null @@ -1,144 +0,0 @@ --module(er_xdg_pipe_menu_scanner_tests). - --include_lib("eunit/include/eunit.hrl"). - -data_dirs_defaults_test() -> - with_env( - [{"XDG_DATA_HOME", false}, {"XDG_DATA_DIRS", false}, {"HOME", "/home/testuser"}], - fun() -> - ?assertEqual( - [ - "/home/testuser/.local/share/applications", - "/usr/local/share/applications", - "/usr/share/applications" - ], - er_xdg_pipe_menu_scanner:data_dirs() - ) - end - ). - -data_dirs_honors_xdg_data_home_test() -> - with_env( - [{"XDG_DATA_HOME", "/custom/data"}, {"XDG_DATA_DIRS", false}, {"HOME", "/home/testuser"}], - fun() -> - ?assertEqual( - [ - "/custom/data/applications", - "/usr/local/share/applications", - "/usr/share/applications" - ], - er_xdg_pipe_menu_scanner:data_dirs() - ) - end - ). - -data_dirs_honors_xdg_data_dirs_test() -> - with_env( - [ - {"XDG_DATA_HOME", false}, - {"XDG_DATA_DIRS", "/opt/flatpak/share:/var/lib/flatpak/exports/share"}, - {"HOME", "/home/testuser"} - ], - fun() -> - ?assertEqual( - [ - "/home/testuser/.local/share/applications", - "/opt/flatpak/share/applications", - "/var/lib/flatpak/exports/share/applications" - ], - er_xdg_pipe_menu_scanner:data_dirs() - ) - end - ). - -data_dirs_treats_empty_as_unset_test() -> - with_env( - [{"XDG_DATA_HOME", ""}, {"XDG_DATA_DIRS", ""}, {"HOME", "/home/testuser"}], - fun() -> - ?assertEqual( - [ - "/home/testuser/.local/share/applications", - "/usr/local/share/applications", - "/usr/share/applications" - ], - er_xdg_pipe_menu_scanner:data_dirs() - ) - end - ). - -data_dirs_ignores_empty_segments_in_xdg_data_dirs_test() -> - with_env( - [{"XDG_DATA_HOME", false}, {"XDG_DATA_DIRS", "/a::/b:"}, {"HOME", "/home/testuser"}], - fun() -> - ?assertEqual( - ["/home/testuser/.local/share/applications", "/a/applications", "/b/applications"], - er_xdg_pipe_menu_scanner:data_dirs() - ) - end - ). - -data_dirs_skips_data_home_without_xdg_data_home_or_home_test() -> - with_env( - [{"XDG_DATA_HOME", false}, {"XDG_DATA_DIRS", false}, {"HOME", false}], - fun() -> - ?assertEqual( - ["/usr/local/share/applications", "/usr/share/applications"], - er_xdg_pipe_menu_scanner:data_dirs() - ) - end - ). - -find_desktop_files_recurses_and_skips_missing_dirs_test() -> - Root = mk_tmp_dir(), - SubDir = filename:join(Root, "kde4"), - ok = file:make_dir(SubDir), - ok = file:write_file(filename:join(Root, "top.desktop"), <<"[Desktop Entry]\n">>), - ok = file:write_file(filename:join(SubDir, "nested.desktop"), <<"[Desktop Entry]\n">>), - ok = file:write_file(filename:join(Root, "not-a-desktop-file.txt"), <<"ignore me">>), - Missing = filename:join(Root, "does-not-exist"), - Found = er_xdg_pipe_menu_scanner:find_desktop_files([Root, Missing]), - ok = rm_rf(Root), - ?assertEqual( - lists:sort([filename:join(Root, "top.desktop"), filename:join(SubDir, "nested.desktop")]), - lists:sort(Found) - ). - -find_desktop_files_empty_for_no_dirs_test() -> - ?assertEqual([], er_xdg_pipe_menu_scanner:find_desktop_files([])). - -%% -- helpers ---------------------------------------------------------------- - -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). - -mk_tmp_dir() -> - Path = filename:join( - test_tmp_dir(), "eunit-scanner-" ++ 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 deleted file mode 100644 index 08a4b56..0000000 --- a/test/er_xdg_pipe_menu_socket_tests.erl +++ /dev/null @@ -1,93 +0,0 @@ --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 deleted file mode 100644 index 6754f24..0000000 --- a/test/er_xdg_pipe_menu_watcher_tests.erl +++ /dev/null @@ -1,86 +0,0 @@ --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. diff --git a/test/xdg_pipe_menu_cache_tests.erl b/test/xdg_pipe_menu_cache_tests.erl new file mode 100644 index 0000000..4b9dfbb --- /dev/null +++ b/test/xdg_pipe_menu_cache_tests.erl @@ -0,0 +1,130 @@ +-module(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 = 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 = 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 = 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 = xdg_pipe_menu_cache:build([Dir]), + ok = rm_rf(Dir), + ?assert(binary:match(Menu, <<"Good">>) =/= nomatch). + +build_skips_missing_dirs_test() -> + ?assertEqual( + <<"\n" + "\n" + "\n">>, + 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} = xdg_pipe_menu_cache:start_link(), + Menu1 = xdg_pipe_menu_cache:get_menu(), + ?assert(binary:match(Menu1, <<"Foo">>) =/= nomatch), + + write_desktop(AppDir, "bar.desktop", "Bar", "bar"), + ok = xdg_pipe_menu_cache:invalidate(), + Menu2 = 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/xdg_pipe_menu_desktop_entry_tests.erl b/test/xdg_pipe_menu_desktop_entry_tests.erl new file mode 100644 index 0000000..a55cd67 --- /dev/null +++ b/test/xdg_pipe_menu_desktop_entry_tests.erl @@ -0,0 +1,183 @@ +-module(xdg_pipe_menu_desktop_entry_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include("xdg_pipe_menu.hrl"). + +basic_entry_test() -> + Bin = << + "[Desktop Entry]\n" + "Type=Application\n" + "Name=Firefox\n" + "Exec=firefox %u\n" + "Icon=firefox\n" + >>, + ?assertEqual( + {ok, #desktop_entry{ + name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, categories = [], hidden = false + }}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +icon_is_optional_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + >>, + ?assertEqual( + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +categories_are_split_on_semicolon_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + "Categories=Utility;Development;\n" + >>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories). + +categories_is_empty_list_when_absent_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual([], Entry#desktop_entry.categories). + +repeated_category_separators_are_ignored_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + "Categories=Utility;;Development\n" + >>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories). + +missing_name_test() -> + Bin = <<"[Desktop Entry]\nExec=foo\n">>, + ?assertEqual({error, missing_name}, xdg_pipe_menu_desktop_entry:parse(Bin)). + +missing_exec_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\n">>, + ?assertEqual({error, missing_exec}, xdg_pipe_menu_desktop_entry:parse(Bin)). + +ignores_other_groups_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + "\n" + "[Desktop Action new-window]\n" + "Name=New Window\n" + "Exec=foo --new-window\n" + >>, + ?assertEqual( + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +ignores_comments_and_blank_lines_test() -> + Bin = << + "# a comment\n" + "\n" + "[Desktop Entry]\n" + "# another comment\n" + "Name=Foo\n" + "\n" + "Exec=foo\n" + >>, + ?assertEqual( + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +strips_multiple_field_codes_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo %f --flag %i %c\n" + >>, + ?assertEqual( + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, categories = [], hidden = false}}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +literal_percent_is_unescaped_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo --progress=%%\n" + >>, + ?assertEqual( + {ok, #desktop_entry{ + name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, categories = [], hidden = false + }}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +unescapes_value_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\\sBar\nExec=foo\n">>, + ?assertEqual( + {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, + xdg_pipe_menu_desktop_entry:parse(Bin) + ). + +no_display_is_hidden_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nNoDisplay=true\n">>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual(true, Entry#desktop_entry.hidden). + +hidden_key_is_hidden_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nHidden=true\n">>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual(true, Entry#desktop_entry.hidden). + +non_application_type_is_hidden_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nType=Link\n">>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual(true, Entry#desktop_entry.hidden). + +application_type_is_not_hidden_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\nType=Application\n">>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual(false, Entry#desktop_entry.hidden). + +missing_type_is_not_hidden_test() -> + Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual(false, Entry#desktop_entry.hidden). + +false_values_are_not_hidden_test() -> + Bin = << + "[Desktop Entry]\n" + "Name=Foo\n" + "Exec=foo\n" + "NoDisplay=false\n" + "Hidden=false\n" + "Type=Application\n" + >>, + {ok, Entry} = xdg_pipe_menu_desktop_entry:parse(Bin), + ?assertEqual(false, Entry#desktop_entry.hidden). + +parse_file_test() -> + Path = filename:join(test_tmp_dir(), "eunit-desktop-entry-test.desktop"), + ok = file:write_file(Path, <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>), + Result = xdg_pipe_menu_desktop_entry:parse_file(Path), + ok = file:delete(Path), + ?assertEqual( + {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}}, + Result + ). + +parse_file_missing_test() -> + ?assertEqual( + {error, enoent}, + xdg_pipe_menu_desktop_entry:parse_file("/nonexistent/does-not-exist.desktop") + ). + +test_tmp_dir() -> + case os:getenv("TMPDIR") of + false -> "/tmp"; + Dir -> Dir + end. diff --git a/test/xdg_pipe_menu_renderer_tests.erl b/test/xdg_pipe_menu_renderer_tests.erl new file mode 100644 index 0000000..6d632cf --- /dev/null +++ b/test/xdg_pipe_menu_renderer_tests.erl @@ -0,0 +1,167 @@ +-module(xdg_pipe_menu_renderer_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("xmerl/include/xmerl.hrl"). +-include("xdg_pipe_menu.hrl"). + +entry(Name, Exec, Icon) -> + entry(Name, Exec, Icon, []). + +entry(Name, Exec, Icon, Categories) -> + entry(Name, Exec, Icon, Categories, false). + +entry(Name, Exec, Icon, Categories, Hidden) -> + #desktop_entry{ + name = Name, exec = Exec, icon = Icon, categories = Categories, hidden = Hidden + }. + +empty_list_renders_empty_menu_test() -> + ?assertEqual( + <<"\n" + "\n" + "\n">>, + xdg_pipe_menu_renderer:render([]) + ). + +single_entry_with_icon_test() -> + Rendered = xdg_pipe_menu_renderer:render([ + entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]) + ]), + ?assertEqual( + <<"\n" + "\n" + " \n" + " \n" + " \n" + " firefox\n" + " \n" + " \n" + " \n" + "\n">>, + Rendered + ). + +entry_without_icon_omits_icon_attr_test() -> + Rendered = xdg_pipe_menu_renderer:render([ + entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>]) + ]), + ?assertEqual( + <<"\n" + "\n" + " \n" + " \n" + " \n" + " foo\n" + " \n" + " \n" + " \n" + "\n">>, + Rendered + ). + +entries_without_categories_go_in_other_submenu_test() -> + Rendered = xdg_pipe_menu_renderer:render([entry(<<"Foo">>, <<"foo">>, undefined)]), + ?assertEqual( + <<"\n" + "\n" + " \n" + " \n" + " \n" + " foo\n" + " \n" + " \n" + " \n" + "\n">>, + Rendered + ). + +hidden_entries_are_dropped_test() -> + Entries = [ + entry(<<"Visible">>, <<"visible">>, undefined, [<<"Utility">>]), + entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true) + ], + Rendered = xdg_pipe_menu_renderer:render(Entries), + ?assertNot(binary:match(Rendered, <<"Invisible">>) =/= nomatch), + ?assert(binary:match(Rendered, <<"Visible">>) =/= nomatch). + +hidden_entry_leaves_no_empty_submenu_test() -> + Entries = [entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true)], + Rendered = xdg_pipe_menu_renderer:render(Entries), + ?assertNot(binary:match(Rendered, <<"Utility">>) =/= nomatch). + +entries_are_sorted_case_insensitively_within_a_submenu_test() -> + Entries = [ + entry(<<"zeta">>, <<"zeta">>, undefined, [<<"Utility">>]), + entry(<<"Alpha">>, <<"alpha">>, undefined, [<<"Utility">>]), + entry(<<"beta">>, <<"beta">>, undefined, [<<"Utility">>]) + ], + Rendered = xdg_pipe_menu_renderer:render(Entries), + Positions = [ + element(1, binary:match(Rendered, <<"label=\"Alpha\"">>)), + element(1, binary:match(Rendered, <<"label=\"beta\"">>)), + element(1, binary:match(Rendered, <<"label=\"zeta\"">>)) + ], + ?assertEqual(Positions, lists:sort(Positions)). + +submenus_are_sorted_case_insensitively_test() -> + Entries = [ + entry(<<"Zed App">>, <<"zed">>, undefined, [<<"zeta">>]), + entry(<<"Alpha App">>, <<"alpha">>, undefined, [<<"Alpha">>]), + entry(<<"Beta App">>, <<"beta">>, undefined, [<<"beta">>]) + ], + Rendered = xdg_pipe_menu_renderer:render(Entries), + Positions = [ + element(1, binary:match(Rendered, <<"label=\"Alpha\"">>)), + element(1, binary:match(Rendered, <<"label=\"beta\"">>)), + element(1, binary:match(Rendered, <<"label=\"zeta\"">>)) + ], + ?assertEqual(Positions, lists:sort(Positions)). + +entry_with_multiple_categories_appears_in_each_submenu_test() -> + Entries = [entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>, <<"Development">>])], + Rendered = xdg_pipe_menu_renderer:render(Entries), + {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)), + Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content], + ?assertEqual([<<"Development">>, <<"Utility">>], lists:sort([menu_id(M) || M <- Menus])), + Items = [I || M <- Menus, I = #xmlElement{name = item} <- M#xmlElement.content], + ?assertEqual(2, length(Items)). + +escapes_special_characters_test() -> + Entry = entry( + <<"Foo & \"Baz\"">>, + <<"foo --title=\"&y\"">>, + <<"icon\"&<>">>, + [<<"Foo & ">>] + ), + Rendered = xdg_pipe_menu_renderer:render([Entry]), + ?assertEqual( + <<"\n" + "\n" + " \" label=\"Foo & <Bar>\">\n" + " "Baz"\" icon=\"icon"&<>\">\n" + " \n" + " foo --title=\"<x>&y\"\n" + " \n" + " \n" + " \n" + "\n">>, + Rendered + ). + +output_is_well_formed_xml_test() -> + Entries = [ + entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]), + entry(<<"Foo & Bar">>, <<"foo">>, undefined, [<<"Network">>]) + ], + Rendered = xdg_pipe_menu_renderer:render(Entries), + {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)), + ?assertEqual(openbox_pipe_menu, Doc#xmlElement.name), + Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content], + ?assertEqual(1, length(Menus)), + [Menu] = Menus, + Items = [E || E = #xmlElement{name = item} <- Menu#xmlElement.content], + ?assertEqual(2, length(Items)). + +menu_id(#xmlElement{attributes = Attrs}) -> + [Value] = [V || #xmlAttribute{name = id, value = V} <- Attrs], + list_to_binary(Value). diff --git a/test/xdg_pipe_menu_scanner_tests.erl b/test/xdg_pipe_menu_scanner_tests.erl new file mode 100644 index 0000000..fa321ad --- /dev/null +++ b/test/xdg_pipe_menu_scanner_tests.erl @@ -0,0 +1,144 @@ +-module(xdg_pipe_menu_scanner_tests). + +-include_lib("eunit/include/eunit.hrl"). + +data_dirs_defaults_test() -> + with_env( + [{"XDG_DATA_HOME", false}, {"XDG_DATA_DIRS", false}, {"HOME", "/home/testuser"}], + fun() -> + ?assertEqual( + [ + "/home/testuser/.local/share/applications", + "/usr/local/share/applications", + "/usr/share/applications" + ], + xdg_pipe_menu_scanner:data_dirs() + ) + end + ). + +data_dirs_honors_xdg_data_home_test() -> + with_env( + [{"XDG_DATA_HOME", "/custom/data"}, {"XDG_DATA_DIRS", false}, {"HOME", "/home/testuser"}], + fun() -> + ?assertEqual( + [ + "/custom/data/applications", + "/usr/local/share/applications", + "/usr/share/applications" + ], + xdg_pipe_menu_scanner:data_dirs() + ) + end + ). + +data_dirs_honors_xdg_data_dirs_test() -> + with_env( + [ + {"XDG_DATA_HOME", false}, + {"XDG_DATA_DIRS", "/opt/flatpak/share:/var/lib/flatpak/exports/share"}, + {"HOME", "/home/testuser"} + ], + fun() -> + ?assertEqual( + [ + "/home/testuser/.local/share/applications", + "/opt/flatpak/share/applications", + "/var/lib/flatpak/exports/share/applications" + ], + xdg_pipe_menu_scanner:data_dirs() + ) + end + ). + +data_dirs_treats_empty_as_unset_test() -> + with_env( + [{"XDG_DATA_HOME", ""}, {"XDG_DATA_DIRS", ""}, {"HOME", "/home/testuser"}], + fun() -> + ?assertEqual( + [ + "/home/testuser/.local/share/applications", + "/usr/local/share/applications", + "/usr/share/applications" + ], + xdg_pipe_menu_scanner:data_dirs() + ) + end + ). + +data_dirs_ignores_empty_segments_in_xdg_data_dirs_test() -> + with_env( + [{"XDG_DATA_HOME", false}, {"XDG_DATA_DIRS", "/a::/b:"}, {"HOME", "/home/testuser"}], + fun() -> + ?assertEqual( + ["/home/testuser/.local/share/applications", "/a/applications", "/b/applications"], + xdg_pipe_menu_scanner:data_dirs() + ) + end + ). + +data_dirs_skips_data_home_without_xdg_data_home_or_home_test() -> + with_env( + [{"XDG_DATA_HOME", false}, {"XDG_DATA_DIRS", false}, {"HOME", false}], + fun() -> + ?assertEqual( + ["/usr/local/share/applications", "/usr/share/applications"], + xdg_pipe_menu_scanner:data_dirs() + ) + end + ). + +find_desktop_files_recurses_and_skips_missing_dirs_test() -> + Root = mk_tmp_dir(), + SubDir = filename:join(Root, "kde4"), + ok = file:make_dir(SubDir), + ok = file:write_file(filename:join(Root, "top.desktop"), <<"[Desktop Entry]\n">>), + ok = file:write_file(filename:join(SubDir, "nested.desktop"), <<"[Desktop Entry]\n">>), + ok = file:write_file(filename:join(Root, "not-a-desktop-file.txt"), <<"ignore me">>), + Missing = filename:join(Root, "does-not-exist"), + Found = xdg_pipe_menu_scanner:find_desktop_files([Root, Missing]), + ok = rm_rf(Root), + ?assertEqual( + lists:sort([filename:join(Root, "top.desktop"), filename:join(SubDir, "nested.desktop")]), + lists:sort(Found) + ). + +find_desktop_files_empty_for_no_dirs_test() -> + ?assertEqual([], xdg_pipe_menu_scanner:find_desktop_files([])). + +%% -- helpers ---------------------------------------------------------------- + +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). + +mk_tmp_dir() -> + Path = filename:join( + test_tmp_dir(), "eunit-scanner-" ++ 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/xdg_pipe_menu_socket_tests.erl b/test/xdg_pipe_menu_socket_tests.erl new file mode 100644 index 0000000..91cd3e4 --- /dev/null +++ b/test/xdg_pipe_menu_socket_tests.erl @@ -0,0 +1,93 @@ +-module(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(xdg_pipe_menu, socket_path), + application:set_env(xdg_pipe_menu, socket_path, SockPath), + with_env( + [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}], + fun() -> + {ok, CachePid} = xdg_pipe_menu_cache:start_link(), + {ok, SocketPid} = 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 = 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(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/xdg_pipe_menu_watcher_tests.erl b/test/xdg_pipe_menu_watcher_tests.erl new file mode 100644 index 0000000..cca5cc2 --- /dev/null +++ b/test/xdg_pipe_menu_watcher_tests.erl @@ -0,0 +1,86 @@ +-module(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(xdg_pipe_menu, debounce_ms), + application:set_env(xdg_pipe_menu, debounce_ms, 20), + with_env( + [{"XDG_DATA_HOME", Root}, {"XDG_DATA_DIRS", "/does-not-exist-eunit"}], + fun() -> + {ok, CachePid} = xdg_pipe_menu_cache:start_link(), + {ok, WatcherPid} = xdg_pipe_menu_watcher:start_link(), + + write_desktop(AppDir, "bar.desktop", "Bar", "bar"), + WatcherPid ! {self(), {fs, file_event}, {"dummy", [modified]}}, + timer:sleep(100), + + Menu = xdg_pipe_menu_cache:get_menu(), + stop(WatcherPid), + stop(CachePid), + ?assert(binary:match(Menu, <<"Bar">>) =/= nomatch) + end + ), + ok = rm_rf(Root), + restore_env(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. -- cgit v1.2.3