diff options
| author | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
| commit | 13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch) | |
| tree | 684690c61b7b59758c2aac132c65b81c86de0608 /bin/open | |
dotfiles
Shell, editor and terminal configuration, the tools that go with them, and
szecret, an age-based secret manager whose store lives elsewhere.
History restarts here. What came before was not written to be read by
anyone else, and carried machine names, addresses and an encrypted secret
store; it is kept privately instead. What is left is meant to be safe on
any machine, including one whose owner is not me: see CLAUDE.md for the
rule it is written to.
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[@]}" |
