/
xdg-terminal-exec
executable file
·623 lines (571 loc) · 18.1 KB
/
xdg-terminal-exec
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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
#!/bin/sh
# Default Terminal Execution Utility
# Reference implementation of proposed Default Terminal Execution Specification
# https://gitlab.freedesktop.org/terminal-wg/specifications/-/merge_requests/3
#
# by Vladimir Kudrya
# https://github.com/Vladimir-csp/
# https://gitlab.freedesktop.org/Vladimir-csp/
#
# This script is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version. See <http://www.gnu.org/licenses/>.
#
# Contributors:
# Roman Chistokhodov https://github.com/FreeSlave/
# fluvf https://github.com/fluvf
# Treat non-zero exit status from simple commands as an error
# Treat unset variables as errors when performing parameter expansion
# Disable pathname expansion
set -euf
# Store original IFS value, assumed to contain the default: <space><tab><newline>
OIFS="$IFS"
# Newline, utility variable used throughout the script
N='
'
# Utility function to print messages to stderr
error() { printf '%s\n' "$@" >&2; }
check_bool() {
case "$1" in
true | True | TRUE | yes | Yes | YES | 1) return 0 ;;
false | False | FALSE | no | No | NO | 0) return 1 ;;
*)
error "Assuming '$1' means no"
return 1
;;
esac
}
# Utility function to print debug messages to stderr (or not)
if check_bool "${DEBUG-0}"; then
debug() { printf 'D: %s\n' "$@" >&2; }
else
debug() { :; }
fi
# Populates global constants and lists for later use and iteration
make_paths() {
IFS=':'
# Populate list of config files to read, in descending order of preference
for dir in ${XDG_CONFIG_HOME:-"${HOME}/.config"}${IFS}${XDG_CONFIG_DIRS:-/etc/xdg}; do
# Normalise base path and append the data subdirectory with a trailing '/'
for desktop in ${LOWERCASE_XDG_CURRENT_DESKTOP}; do
CONFIGS=${CONFIGS:+${CONFIGS}${IFS}}${dir%/}/${desktop}-xdg-terminals.list
done
CONFIGS=${CONFIGS:+${CONFIGS}${IFS}}${dir%/}/xdg-terminals.list
done
# Populate list of directories to search for entries in, in ascending order of preference
for dir in ${XDG_DATA_HOME:-${HOME}/.local/share}${IFS}${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do
# Normalise base path and append the data subdirectory with a trailing '/'
APPLICATIONS_DIRS=${dir%/}/applications/${APPLICATIONS_DIRS:+${IFS}${APPLICATIONS_DIRS}}
done
# cache
XDG_CACHE_HOME=${XDG_CACHE_HOME:-"${HOME}/.cache"}
CACHE_FILE="${XDG_CACHE_HOME}/xdg-terminal-exec"
debug "paths:" "CONFIGS=${CONFIGS}" "APPLICATIONS_DIRS=${APPLICATIONS_DIRS}"
}
# Mask IFS withing function to allow temporary changes
alias make_paths='IFS= make_paths'
gen_hash() {
# return md5 of XDG_CURRENT DESKTOP and ls -LRl output for config and data paths
# md5 is 4x faster than sha*, and there is no need for cryptography here
# shellcheck disable=SC2034
read -r hash drop <<- EOH
$(
hash_paths="${CONFIGS}:${APPLICATIONS_DIRS}"
{
echo "${XDG_CURRENT_DESKTOP-}"
IFS=':'
# shellcheck disable=SC2086
debug "> hashing '${XDG_CURRENT_DESKTOP-}' and listing of:" $hash_paths "^ end of hash listing"
# shellcheck disable=SC2012,SC2086
LANG=C ls -LRl ${hash_paths} 2> /dev/null
} | md5sum 2> /dev/null
)
EOH
case "$hash" in
[0-9a-f]??????????????????????????????[0-9a-f])
debug "got fresh hash '$hash'"
echo "$hash"
return 0
;;
*)
debug "failed to get fresh hash, got '$hash'"
return 1
;;
esac
}
read_cache() {
# reads $cached_hash, $cached_exec, $cached_execarg, $cached_cmd from cache file,
# checks if cache is actual and applies it, otherwise returns 1
# tries to bail out as soon as possible if something does not fit
if [ -f "${CACHE_FILE}" ]; then
IFS=${N}
line_num=0
while read -r line; do
line_num=$((line_num + 1))
case "${line_num}_${line}" in
1_[0-9a-f]??????????????????????????????[0-9a-f]) cached_hash=$line ;;
2_*) cached_exec=$line ;;
3_ | 3_*) cached_execarg=$line ;;
4_*)
# get cmd and break right away, line_num will be left at 4
cached_cmd=$line
break
;;
*)
debug "cache line ${line_num} is invalid: ${line}"
return 1
;;
esac
done < "${CACHE_FILE}"
if [ "$line_num" = "4" ]; then
debug "got cache:" "${cached_hash}" "${cached_exec}" "${cached_execarg}" "${cached_cmd}"
IFS=$OIFS
HASH=$(gen_hash) || return 1
if [ "$HASH" = "$cached_hash" ] && command -v "$cached_cmd" > /dev/null; then
debug "cache is actual"
EXEC=${cached_exec}
EXECARG=${cached_execarg}
return 0
else
debug "cache is out-of-date"
return 1
fi
else
debug "invalid cache data"
return 1
fi
else
debug "no cache data"
return 1
fi
}
# Mask IFS withing function to allow temporary changes
alias read_cache='IFS= read_cache'
save_cache() {
# saves $HASH, $EXEC, $EXECARG, $1 (executable) to cache file or removes it if CACHE_ENABLE is false
if check_bool "$CACHE_ENABLED"; then
[ ! -d "${XDG_CACHE_HOME}" ] && mkdir -p "${XDG_CACHE_HOME}"
if [ -z "${HASH-}" ]; then
HASH=$(gen_hash) || {
echo "could not hash listing, removing '${CACHE_FILE}'" >&2
rm -f "${CACHE_FILE}"
return 0
}
fi
UM=$(umask)
umask 0077
printf '%s\n' "${HASH}" "${EXEC}" "${EXECARG}" "${1}" > "${CACHE_FILE}"
umask "$UM"
debug "> saved cache:" "${HASH}" "${EXEC}" "${EXECARG}" "${1}" "^ end of saved cache"
else
debug "cache is disabled, removing '${CACHE_FILE}'"
rm -f "${CACHE_FILE}"
return 0
fi
}
# Parse all config files and populate $ENTRY_IDS with read desktop entry IDs
read_config_paths() {
# All config files are read immediatelly, rather than on demand, even if it's more IO intensive
# This way all IDs are already known, and in order of preference, before iterating over them
IFS=':'
for config_path in ${CONFIGS}; do
debug "reading config '$config_path'"
# Nonexistant file is not an error
[ -f "$config_path" ] || continue
# Let `read` trim leading/trailing whitespace from the line
while IFS="$OIFS" read -r line; do
#debug "read line '$line'"
case $line in
# Catch directives first
# cache control
/enable_cache)
debug "found '$line' directive${XTE_CACHE_ENABLED+ (ignored)}"
CACHE_ENABLED=${XTE_CACHE_ENABLED-true}
;;
/disable_cache)
debug "found '$line' directive${XTE_CACHE_ENABLED+ (ignored)}"
CACHE_ENABLED=${XTE_CACHE_ENABLED-false}
;;
# `[The extensionless entry filename] should be a valid D-Bus well-known name.`
# `a sequence of non-empty elements separated by dots (U+002E FULL STOP),
# none of which starts with a digit, and each of which contains only characters from the set [a-zA-Z0-9-_]`
# Stricter parts seem to be related only to reversed DNS notation but not common naming
# i.e. there is `2048-qt.desktop`.
# I do not know of any terminal that starts with a number, but it's valid.
# Catch and validate potential entry ID with action ID (be graceful about an empty one)
[a-zA-Z0-9_]*)
# consider only the first ':' as a delimiter
IFS=':' read -r entry_id action_id <<- EOL
$line
EOL
if validate_entry_id "${entry_id}" && validate_action_id "${action_id}"; then
ENTRY_IDS=${ENTRY_IDS:+${ENTRY_IDS}${N}}$line
debug "added entry ID with action ID '$line'"
else
error "Discarded possibly misspelled entry '$line'"
fi
;;
esac
# By default empty lines and comments get ignored
done < "$config_path"
done
}
# Mask IFS withing function to allow temporary changes
alias read_config_paths='IFS= read_config_paths'
replace() {
# takes $1, finds $2, replaces with $3
# does it in large chunks
# var to be modified
string=${1}
# right part of string
r_string=${1}
# left part of string
l_string=''
# previous right part of string
prev_r_string=''
while true; do
# save previous r_string
prev_r_string=${r_string}
# cut the right part with search string from the left
r_string=${r_string#*"${2}"}
# cut the left part with search string and rigth part from the right
l_string=${string%"${2}${r_string}"}
case "$r_string" in
# if the right part was not unmodified, there is nothing to replace
"$prev_r_string") break ;;
# if the right part was is modified, update string with:
# the left part, replace string, the right part
*) string=${l_string}${3}${r_string} ;;
esac
done
echo "$string"
}
# Find and map all desktop entry files from standardised paths into aliases
find_entry_paths() {
debug "registering entries"
# Append application directory paths to be searched
IFS=':'
for directory in $APPLICATIONS_DIRS; do
# Append '.' to delimit start of entry ID
set -- "$@" "$directory".
done
# Find all files
set -- "$@" -type f
# Append path conditions per directory
or_arg=''
for directory in $APPLICATIONS_DIRS; do
# Match full path with proper first character of entry ID and .desktop extension
# Reject paths with invalid characters in entry ID
set -- "$@" ${or_arg} '(' -path "$directory"'./[a-zA-Z0-9_]*.desktop' ! -path "$directory"'./*[^a-zA-Z0-9_./-]*' ')'
or_arg='-o'
done
# Loop through found entry paths and IDs
IFS=$N
while read -r entry_path && read -r entry_id; do
# Entries are checked in ascending order of preference, so use last found if duplicate
# shellcheck disable=SC2139
alias "$entry_id"="entry_path='$entry_path'"
debug "registered '$entry_path' as entry '$entry_id'"
# Add as a fallback ID regardles if it's a duplicate
FALLBACK_ENTRY_IDS=${entry_id}${FALLBACK_ENTRY_IDS:+${N}${FALLBACK_ENTRY_IDS}}
debug "added fallback ID '$entry_id'"
done <<- EOE
$(
# Don't complain about nonexistent directories
find -L "$@" 2> /dev/null |
# Print entry path and convert it into an ID and print that too
awk '{ print; sub(".*/[.]/", ""); gsub("/", "-"); print }'
)
EOE
}
# Mask IFS withing function to allow temporary changes
alias find_entry_paths='IFS= find_entry_paths'
# Check validity of a given entry key - value pair
# Modifies following global variables:
# EXEC : Program to execute, possibly with arguments. See spec for details.
# EXECARG : Execution argument for the terminal emulator.
# IS_TERMINAL : Set if application has been categorized as a terminal emulator
check_entry_key() {
key="$1"
value="$2"
action="$3"
read_exec="$4"
de_checks="$5"
# Order of checks is important
case $key in
'Categories'*=*)
debug "checking for 'TerminalEmulator' in Categories '$value'"
IFS=';'
for category in $value; do
[ "$category" = "TerminalEmulator" ] && {
IS_TERMINAL=true
return 0
}
done
# Default in this case is to fail
return 1
;;
'Actions'*=*)
# `It is not valid to have an action group for an action identifier not mentioned in the Actions key.
# Such an action group must be ignored by implementors.`
# ignore if no action requested
[ -z "$action" ] && return 0
debug "checking for '$action' in Actions '$value'"
IFS=';'
for check_action in $value; do
[ "$check_action" = "$action" ] && return 0
done
# Default in this case is to fail
return 1
;;
'OnlyShowIn'*=*)
case "$de_checks" in
true) debug "checking for intersecion between '${XDG_CURRENT_DESKTOP-}' and OnlyShowIn '$value'" ;;
false)
debug "skipping OnlyShowIn check"
return 0
;;
esac
IFS=';'
for target in $value; do
IFS=':'
for desktop in ${XDG_CURRENT_DESKTOP-}; do
[ "$desktop" = "$target" ] && return 0
done
done
# Default in this case is to fail
return 1
;;
'NotShowIn'*=*)
case "$de_checks" in
true) debug "checking for intersecion between '${XDG_CURRENT_DESKTOP-}' and NotShowIn '$value'" ;;
false)
debug "skipping NotShowIn check"
return 0
;;
esac
IFS=';'
for target in $value; do
IFS=':'
for desktop in ${XDG_CURRENT_DESKTOP-}; do
debug "checking NotShowIn match '$desktop'='$target'"
[ "$desktop" = "$target" ] && return 1
done
done
# Default in this case is to succeed
return 0
;;
'X-ExecArg'*=* | 'ExecArg'*=*)
# Set global variable
EXECARG=$value
debug "read ExecArg '$EXECARG'"
;;
'TryExec'*=*)
debug "checking TryExec executable '$value'"
command -v "$value" > /dev/null || return 1
;;
'Hidden'*=*)
debug "checking boolean Hidden '$value'"
case "$value" in
true)
debug "ignored Hidden entry"
return 1
;;
esac
;;
'Exec'*=*)
case "$read_exec" in
false)
debug "ignored Exec from wrong section"
return 0
;;
esac
debug "read Exec '$value'"
# Set global variable
EXEC=$value
# Get first word from read Exec value
IFS="$OIFS"
eval "set -- $EXEC"
debug "checking Exec[0] executable '$1'"
command -v "$1" > /dev/null || return 1
;;
esac
# By default unrecognised keys, empty lines and comments get ignored
}
# Mask IFS withing function to allow temporary changes
alias check_entry='IFS= check_entry'
# Read entry from given path
read_entry_path() {
entry_path="$1"
entry_action="$2"
de_checks="$3"
read_exec=false
# shellcheck disable=SC2016
debug "reading desktop entry '$entry_path'${entry_action:+ action '}$entry_action${entry_action:+'}"
# Let `read` trim leading/trailing whitespace from the line
while IFS="$OIFS" read -r line; do
case $line in
# `There should be nothing preceding [the Desktop Entry group] in the desktop entry file but [comments]`
# if entry_action is not requested, allow reading Exec right away from the main group
'[Desktop Entry]'*) [ -z "$entry_action" ] && read_exec=true ;;
# A `Key=Value` pair
[a-zA-Z0-9-]*)
# Split value from pair
value=${line#*=}
# Remove all but leading spaces, and trim that from the value
value=${value#"${value%%[! ]*}"}
# Check the key
check_entry_key "$line" "$value" "$entry_action" "$read_exec" "$de_checks" && continue
# Reset values that might have been set
unset EXEC
unset EXECARG
unset IS_TERMINAL
# shellcheck disable=SC2016
debug "entry discarded"
return 1
;;
# found requested action, allow reading Exec
"[Desktop Action ${entry_action}]"*) read_exec=true ;;
# Start of the next group header, stop if already read exec
'['*) [ "$read_exec" = "true" ] && break ;;
esac
# By default empty lines and comments get ignored
done < "$entry_path"
}
validate_entry_id() {
# validates entry ID ($1)
case "$1" in
# invalid characters or degrees of emptiness
*[!a-zA-Z0-9_.-]* | *[!a-zA-Z0-9_.-] | [!a-zA-Z0-9_.-]* | [!a-zA-Z0-9_.-] | '' | .desktop)
debug "string not valid as Entry ID: '$1'"
return 1
;;
# all that left with .desktop
*.desktop) return 0 ;;
# and without
*)
debug "string not valid as Entry ID '$1'"
return 1
;;
esac
}
validate_action_id() {
# validates action ID ($1)
case "$1" in
# empty is ok
'') return 0 ;;
# invalid characters
*[!a-zA-Z0-9-]* | *[!a-zA-Z0-9-] | [!a-zA-Z0-9-]* | [!a-zA-Z0-9-])
debug "string not valid as Action ID: '$1'"
return 1
;;
# all that left
*) return 0 ;;
esac
}
# Loop through IDs and try to find a valid entry
find_entry() {
# for explicitly listed entries do not apply DE *ShowIn limits
de_checks=false
IFS="$N"
for entry_id in ${ENTRY_IDS}${N}//fallback_start//${N}$FALLBACK_ENTRY_IDS; do
case "$entry_id" in
# entry has an action appended
*:*)
entry_action=${entry_id#*:}
entry_id=${entry_id%:*}
;;
# skip empty line
'') continue ;;
# fallback entries ahead, enable *ShowIn checks
'//fallback_start//')
de_checks=true
continue
;;
# nullify action
*) entry_action='' ;;
esac
debug "matching path for entry ID '$entry_id'"
# Check if a matching path was found for ID
alias "$entry_id" > /dev/null 2>&1 || continue
# Evaluates the alias, it sets $entry_path
eval "$entry_id"
# Unset the alias, so duplicate entries are skipped
unalias "$entry_id"
read_entry_path "$entry_path" "$entry_action" "$de_checks" || continue
# Check that the entry is actually executable
[ -z "${EXEC-}" ] && continue
# ensure entry is a Terminal Emulator
[ -z "${IS_TERMINAL-}" ] && continue
# Set defaults
: "${EXECARG="-e"}"
# Entry is valid, stop
return 0
done
# shellcheck disable=SC2086
IFS=':' error "No valid terminal entry was found in:" ${APPLICATIONS_DIRS}
return 1
}
# Mask IFS withing function to allow temporary changes
alias find_entry='IFS= find_entry'
## globals
LOWERCASE_XDG_CURRENT_DESKTOP=$(echo "${XDG_CURRENT_DESKTOP-}" | tr '[:upper:]' '[:lower:]')
# this will receive proper value later
APPLICATIONS_DIRS=''
# init vars used in iterations
IS_TERMINAL=''
EXEC=''
EXECARG='-e'
# path iterators
make_paths
# At this point we have no way of telling if cache is enabled or not, unless XTE_CACHE_ENABLED is set,
# so just try reading it as if default is true, otherwise do the usual thing.
# Editing config to disable cache should invalidate the cache.
# The true default is false though:
CACHE_ENABLED=${XTE_CACHE_ENABLED-false}
# HASH can be reused
HASH=''
if check_bool "${XTE_CACHE_ENABLED-true}" && read_cache; then
CACHE_USED=true
else
# continue with globals
CACHE_USED=false
# All desktop entry ids in descending order of preference from *xdg-terminals.list configs,
# with duplicates removed
ENTRY_IDS=''
# All desktop entry ids found in data dirs in descending order of preference,
# with duplicates (including those in $ENTRY_IDS) removed
FALLBACK_ENTRY_IDS=''
# Modifies $ENTRY_IDS
read_config_paths
# Modifies $ENTRY_IDS and sets global aliases
find_entry_paths
# shellcheck disable=SC2086
IFS="$N" debug "> final entry ID list:" ${ENTRY_IDS} "^ end of final entry ID list"
# shellcheck disable=SC2086
IFS="$N" debug "> final fallback entry ID list:" ${FALLBACK_ENTRY_IDS} "^ end of final fallback entry ID list"
# walk ID lists and find first applicable
find_entry || exit 1
fi
# Store original argument list, before it's modified
debug "> original args:" "$@" "^ end of original args" "EXEC=$EXEC" "EXECARG=$EXECARG"
# drop -e or custom ExecArg if given as the first arg
case "${1-}" in
'-e' | "$EXECARG")
debug "dropping '$1' from received args"
shift
;;
esac
# `Implementations must undo quoting [in the Exec argument(s)][...]`
if [ "$#" -gt 0 ]; then
eval "set -- $EXEC ${EXECARG:+'"$EXECARG"'} \"\$@\""
else
eval "set -- $EXEC"
fi
debug "> final args:" "$@" "^ end of final args"
if [ "$CACHE_USED" = "false" ]; then
# saves or removes cache, forked out of the way
save_cache "$1" &
fi
exec "$@"