#!/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[@]}"
