aboutsummaryrefslogtreecommitdiff
path: root/src/er_xdg_pipe_menu_desktop_entry.erl
blob: bbd25944327695eb501cc2d8be6583f6ec315ee0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
%%%-------------------------------------------------------------------
%% @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'. 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">>, <<"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
        <<Name:Size/binary, "]">> -> {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, <<Acc/binary, $\\>>);
unescape(<<$\\, $s, Rest/binary>>, Acc) ->
    unescape(Rest, <<Acc/binary, $\s>>);
unescape(<<$\\, $n, Rest/binary>>, Acc) ->
    unescape(Rest, <<Acc/binary, $\n>>);
unescape(<<$\\, $t, Rest/binary>>, Acc) ->
    unescape(Rest, <<Acc/binary, $\t>>);
unescape(<<$\\, $r, Rest/binary>>, Acc) ->
    unescape(Rest, <<Acc/binary, $\r>>);
unescape(<<C, Rest/binary>>, Acc) ->
    unescape(Rest, <<Acc/binary, C>>).

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),
                        hidden = is_hidden(Fields)
                    }}
            end
    end.

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