#!/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 # 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 2>&1 else setsid -f "$@" /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[@]}"