aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/er_xdg_pipe_menu_scanner.erl62
-rw-r--r--test/er_xdg_pipe_menu_scanner_tests.erl144
2 files changed, 206 insertions, 0 deletions
diff --git a/src/er_xdg_pipe_menu_scanner.erl b/src/er_xdg_pipe_menu_scanner.erl
new file mode 100644
index 0000000..131b4f6
--- /dev/null
+++ b/src/er_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(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/test/er_xdg_pipe_menu_scanner_tests.erl b/test/er_xdg_pipe_menu_scanner_tests.erl
new file mode 100644
index 0000000..3f93496
--- /dev/null
+++ b/test/er_xdg_pipe_menu_scanner_tests.erl
@@ -0,0 +1,144 @@
+-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.