diff options
Diffstat (limited to 'bin/open')
| -rwxr-xr-x | bin/open | 302 |
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[@]}" |
