aboutsummaryrefslogtreecommitdiff
path: root/src/xdg_pipe_menu_desktop_entry.erl
blob: 62adaf1074bdccd20042f58af554066cbd14e4ed (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
132
133
134
135
136
137
138
139
140
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
        <<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),
                        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.