%%%------------------------------------------------------------------- %% @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.