summaryrefslogtreecommitdiff
path: root/bin/open
diff options
context:
space:
mode:
Diffstat (limited to 'bin/open')
-rwxr-xr-xbin/open302
1 files changed, 302 insertions, 0 deletions
diff --git a/bin/open b/bin/open
new file mode 100755
index 0000000..65efddb
--- /dev/null
+++ b/bin/open
@@ -0,0 +1,302 @@
+#!/bin/bash
+#
+# macOS-style `open` for Linux desktops. Linked on every host like the rest of
+# bin/, so it steps aside where it does not apply: on macOS it hands over to the
+# real /usr/bin/open, and on Linux it refuses without a graphical session.
+#
+# Everything starts detached, as it does on macOS: a new session (setsid), stdin
+# from /dev/null, stdout/stderr to the journal. So the app neither dies with the
+# terminal nor holds it open -- an ssh session that ran `open` can still exit.
+# Its output: journalctl --user -t <program>
+
+# First, before anything bash 3.2 (macOS's /bin/bash) might not parse.
+if [[ "$OSTYPE" == darwin* ]]
+then
+ exec /usr/bin/open "$@"
+fi
+
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: open [-a APP] [-n] [-W] [FILE|URL ...] [--args ARG ...]
+ open -t|-e [FILE ...]
+ open -R FILE ...
+
+ (none) open each FILE or URL with its default handler (xdg-open)
+ -a APP open with APP: a desktop entry id (org.kde.dolphin or just dolphin),
+ its Name= (Alacritty), a path to a .desktop file, or a command
+ -t, -e open with the default text editor
+ -R reveal in the file manager
+ -W wait for the app to exit instead of detaching (needs -a, -t or -e)
+ -n accepted for compatibility; every launch is a new process anyway
+ --args pass everything after it to the app verbatim
+EOF
+}
+
+die() {
+ >&2 echo "open: $*"
+ exit 1
+}
+
+# The value of KEY in the [Desktop Entry] group, ignoring [Desktop Action ...]
+# groups, which reuse Name= and Exec=.
+entry_key() {
+ awk -v key="$2=" '
+ /^\[/ { in_entry = ($0 == "[Desktop Entry]"); next }
+ in_entry && index($0, key) == 1 { print substr($0, length(key) + 1); exit }
+ ' "$1"
+}
+
+# Path of the desktop entry for $1, case-insensitively: its id with or without
+# .desktop (org.kde.dolphin), the last part of a reverse-DNS id (dolphin), or
+# failing both its Name= (Alacritty). User entries shadow system ones.
+find_entry() {
+ local want="${1%.desktop}" dirs dir f id entries=()
+ want="${want,,}"
+ IFS=: read -ra dirs <<< "${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
+ for dir in "${dirs[@]}"
+ do
+ for f in "$dir"/applications/*.desktop
+ do
+ [[ -f "$f" ]] || continue
+ entries+=("$f")
+ id="${f##*/}"
+ id="${id%.desktop}"
+ id="${id,,}"
+ if [[ "$id" == "$want" || "${id##*.}" == "$want" ]]
+ then
+ echo "$f"
+ return
+ fi
+ done
+ done
+ (( ${#entries[@]} )) || return 1
+ awk -v want="$want" '
+ FNR == 1 { in_entry = 0 }
+ /^\[/ { in_entry = ($0 == "[Desktop Entry]"); next }
+ in_entry && /^Name=/ && tolower(substr($0, 6)) == want { print FILENAME; found = 1; exit }
+ END { exit !found }
+ ' "${entries[@]}"
+}
+
+# Splits an Exec= value into NUL-terminated words by the desktop entry spec:
+# blanks separate, "..." groups, and within quotes a backslash escapes the next
+# character. The spec's outer string escapes (\s, \\\\) are not undone.
+exec_words() {
+ local s="$1" word='' have='' quoted='' c i
+ for (( i = 0; i < ${#s}; i++ ))
+ do
+ c="${s:i:1}"
+ if [[ -n "$quoted" ]]
+ then
+ if [[ "$c" == '\' ]]
+ then
+ (( ++i ))
+ word+="${s:i:1}"
+ elif [[ "$c" == '"' ]]
+ then
+ quoted=''
+ else
+ word+="$c"
+ fi
+ elif [[ "$c" == '"' ]]
+ then
+ quoted=1 have=1
+ elif [[ "$c" == ' ' || "$c" == $'\t' ]]
+ then
+ if [[ -n "$have" ]]
+ then
+ printf '%s\0' "$word"
+ fi
+ word='' have=''
+ else
+ word+="$c" have=1
+ fi
+ done
+ if [[ -n "$have" ]]
+ then
+ printf '%s\0' "$word"
+ fi
+}
+
+# Fills argv from a desktop entry. The files replace its %f/%F/%u/%U field code,
+# or go last if it has none, and --args go just before them. %f and %u mean one
+# file per launch in the spec; all of them are passed to a single launch here.
+from_entry() {
+ local entry="$1" exec word t placed='' extras_placed=''
+ exec="$(entry_key "$entry" Exec)"
+ [[ -n "$exec" ]] || die "$entry has no Exec="
+ argv=()
+ if [[ "$(entry_key "$entry" Terminal)" == true ]]
+ then
+ for t in ${TERMINAL:-} alacritty konsole xterm
+ do
+ if command -v "$t" >/dev/null
+ then
+ argv=("$t" -e)
+ break
+ fi
+ done
+ (( ${#argv[@]} )) || die "$entry needs a terminal and none was found"
+ fi
+ while IFS= read -r -d '' word
+ do
+ case "$word" in
+ # flatpak's file forwarding markers: app arguments go before them, not inside
+ @@u|@@f)
+ [[ -n "$extras_placed" ]] || argv+=("${extra[@]}")
+ extras_placed=1
+ argv+=("$word")
+ ;;
+ %[fFuU])
+ [[ -n "$extras_placed" ]] || argv+=("${extra[@]}")
+ argv+=("${files[@]}")
+ extras_placed=1 placed=1
+ ;;
+ %%) argv+=(%) ;;
+ %?) ;;
+ *) argv+=("${word//"%%"/%}") ;;
+ esac
+ done < <(exec_words "$exec")
+ [[ -n "$extras_placed" ]] || argv+=("${extra[@]}")
+ [[ -n "$placed" ]] || argv+=("${files[@]}")
+}
+
+# Fills argv with the command line that opens the files with app $1.
+resolve() {
+ local app="$1" entry cmd
+ if [[ "$app" == *.desktop && -f "$app" ]]
+ then
+ entry="$app"
+ else
+ entry="$(find_entry "$app")" || entry=''
+ fi
+ if [[ -n "$entry" ]]
+ then
+ from_entry "$entry"
+ elif cmd="$(command -v -- "$app" || command -v -- "${app,,}")"
+ then
+ argv=("$cmd" "${extra[@]}" "${files[@]}")
+ else
+ die "no application named $app"
+ fi
+ command -v -- "${argv[0]}" >/dev/null || die "$app: ${argv[0]} is not installed"
+}
+
+launch() {
+ if [[ -n "$wait" ]]
+ then
+ "$@"
+ elif command -v systemd-cat >/dev/null
+ then
+ setsid -f systemd-cat -t "${1##*/}" "$@" </dev/null >/dev/null 2>&1
+ else
+ setsid -f "$@" </dev/null >/dev/null 2>&1
+ fi
+}
+
+# Percent-encodes byte by byte, hence LC_ALL=C: ${path:i:1} must be one byte,
+# not one character, for a UTF-8 name to come out right.
+file_uri() {
+ local LC_ALL=C path out='' c i
+ path="$(realpath -- "$1")"
+ for (( i = 0; i < ${#path}; i++ ))
+ do
+ c="${path:i:1}"
+ case "$c" in
+ [A-Za-z0-9/._~-]) out+="$c" ;;
+ *) printf -v c '%%%02X' "'$c"; out+="$c" ;;
+ esac
+ done
+ echo "file://$out"
+}
+
+# Through org.freedesktop.FileManager1, which Dolphin, Nautilus, Nemo and Thunar
+# all implement; without it, open the containing directories instead.
+reveal() {
+ local f uris=()
+ (( $# )) || die "-R needs a file"
+ for f in "$@"
+ do
+ [[ -e "$f" ]] || die "the file $f does not exist"
+ uris+=("$(file_uri "$f")")
+ done
+ if dbus-send --session --print-reply --dest=org.freedesktop.FileManager1 \
+ /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems \
+ "array:string:$(IFS=,; echo "${uris[*]}")" string:'' >/dev/null 2>&1
+ then
+ return
+ fi
+ for f in "$@"
+ do
+ launch xdg-open "$(dirname -- "$(realpath -- "$f")")"
+ done
+}
+
+app='' editor='' reveal='' wait=''
+files=() extra=() argv=()
+while (( $# ))
+do
+ case "$1" in
+ -a) (( $# >= 2 )) || die "-a needs an application"; app="$2"; shift 2 ;;
+ -t|-e) editor=1; shift ;;
+ -R) reveal=1; shift ;;
+ -W) wait=1; shift ;;
+ -n) shift ;;
+ -h|--help) usage; exit 0 ;;
+ --args) shift; extra=("$@"); break ;;
+ --) shift; files+=("$@"); break ;;
+ # Bundled short flags, as in `open -na Alacritty`.
+ -[!-]?*)
+ flags=()
+ for (( i = 1; i < ${#1}; i++ ))
+ do
+ flags+=("-${1:i:1}")
+ done
+ shift
+ set -- "${flags[@]}" "$@"
+ ;;
+ -?*) die "unsupported option $1 (see open --help)" ;;
+ *) files+=("$1"); shift ;;
+ esac
+done
+
+# A server, or ssh into a desktop without forwarding: nothing could show up.
+[[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || die "no graphical session (WAYLAND_DISPLAY and DISPLAY are unset)"
+
+for f in "${files[@]}"
+do
+ [[ -e "$f" || "$f" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]] || die "the file $f does not exist"
+done
+
+if [[ -n "$reveal" ]]
+then
+ reveal "${files[@]}"
+ exit
+fi
+
+if [[ -n "$editor" ]]
+then
+ app="$(xdg-mime query default text/plain)"
+ [[ -n "$app" ]] || die "no default text editor (xdg-mime query default text/plain)"
+fi
+
+if [[ -z "$app" ]]
+then
+ [[ -z "$wait" ]] || die "-W needs -a, -t or -e: xdg-open returns before the app exits"
+ (( ${#extra[@]} == 0 )) || die "--args needs -a"
+ if (( ${#files[@]} == 0 ))
+ then
+ >&2 usage
+ exit 1
+ fi
+ for f in "${files[@]}"
+ do
+ launch xdg-open "$f"
+ done
+ exit
+fi
+
+resolve "$app"
+launch "${argv[@]}"