A handy script

For quite some time I've been wanting to write a short bash wrapper to open any file type the same way, e.g: open doc.pdf, open film.mp4, open book.epub and so on.

If you are familiar with terminal tools you have probably heard about xdg-open (which is part of the xdg-utils package) and that would be problem solved.

But I didn't want xdg-open. Nothing wrong with it but I wanted my own script to also perform a little trick!

Given I'm extremely lazy when using the terminal I always open files launching a program and letting the terminal sit there lurking around e.g: zathura doc.pdf and then urxvt would simply stay there wasting desktop space (note: zathura is a document viewer, urxvt is a terminal emulator).

The alternative would be to launch programs like this: zathura DOC.pdf & disown but that's a lot of typing. And yes, you can have that in a tiny function if you are considering using the terminal after executing one or more programs.

But if in turn we don't want that terminal consume desktop space? I wanted to precisely address that use case. The idea is really dumb:

  1. Run open FILE.
  2. The console process terminates (is killed).
  3. Our desired program (e.g: zathura doc.pdf) is spawned.
  4. As soon as the opened program is terminated, a new console is launched back in the same directory open was originally called from.

If you are a user of a tiled window manager (e.g: i3, dwm) you can easily see how nice is to run programs this way. You don't waste desktop space and the terminal always comes back.

Before continuing reading, I highly encourage you to try writing this script! Is an interesting one, useful as well and being your OWN creation you can tweak it to fit your custom needs. You will learn some interesting things!


  1. Preliminaries

    The first step of the script involves detecting the file type that we are trying to open an associate a program for it. This is somehow easy to achieve and the main logic looks like this:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    #!/usr/bin/env bash
    
    
    FILENAME="$1"
    FILETYPE="$(file --mime-type -b "${FILENAME}")"
    
    case "${FILETYPE}" in
        "application/pdf")
            zathura "${FILENAME}"
            ;;
    
        "image/jpeg"|"image/tiff"|"image/png")
            feh "${FILENAME}"
            ;;
    
        *)
            echo "Error - No associated program for filetype: ${FILETYPE}."
            ;;
    esac
    

    Note: we assume zathura and feh (an image viewer) are found in PATH. Replace the above snippet with you favourite programs.

    The key work is done by file, a command capable of detecting filetypes. Given that in unix systems the extension of a file plays no role at all, file gives us a robust way to detect the file type of the filename that is passed to the script.

    From the second pattern in the case clause it seems that we will call feh only when jpeg, tiff or png formats are detected. That's very limited, what about other image formats? The case clause in bash is surprisingly versatile as you can see in the second pattern above: it allows us to | (or) the test to match FILETYPE.

    It is also possible to do this:

    1
    2
    3
    4
    5
    6
    7
    case "${FILETYPE}" in
        ...
        "image/"*)
            feh "${FILENAME}"
            ;;
        ...
    esac
    

    We can match any supported image mime type. That is a powerful feature!


    Now let's isolate the above logic in the open_file function which receives the path to the filename to be opened:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    open_file() {
        local FILENAME="$1"
        local FILE_TYPE="$(file --mime-type -b "${FILENAME}")"
    
        case "${FILE_TYPE}" in
            "application/pdf")
                zathura "${FILENAME}"
                ;;
    
            "image/"*)
                feh "${FILENAME}"
                ;;
    
            *)
                echo "Error - No associated program for filetype: ${FILE_TYPE}."
                ;;
        esac
    }
    

    What happens if the filename is a symlink? Well in that case file will report a inode/symlink type. So how do we open a symlink? We don't!

    We can in turn do something like this:

    1
    2
    3
    4
    5
    6
    7
    local FILENAME="$1"
    
    ...
    if [ -L "${FILENAME}" ]; then
        FILENAME="$(realpath "${FILENAME}")"
    fi
    ...
    

    We test FILENAME and if it is a symlink we resolve its real path and voila! A more elaborated test should also check for example that the resolved path is not a directory, that is left aside.


  1. The interesting part

    We need a way to spawn a disconnected process that runs our desired program(s) but we also need to re-launch the terminal AFTER our process finishes.

    Enter setsid. The manpage excerpt tell us:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    NAME
         setsid - run a program in a new session
    
    SYNOPSIS
         setsid [options] program [arguments]
    
    DESCRIPTION
         setsid runs a program in a new session. The command calls fork(2) if
         already a process group leader. Otherwise, it executes a program in the
         current process. This default behavior is possible to override by the
         --fork option.
         ...
    

    Here the most important thing to understand is that setsid allows us to run a program in a NEW SESSION. When we open a terminal and spawn processes these are part of the terminal's session: close the terminal (and hence its session) and what you are running is also terminated.

    This obviously excludes programs called with nohup or "disowned" with disown (note: disown is a bash builtin command).

    So we need to call setsid, but how? We need to perform these steps in a new session:

    1. Kill the original terminal.
    2. Run our program.
    3. Relaunch the terminal.

    If you are familiar enough with bash, it is possible to call it and pass one or more commands to be executed with the -c flag, e.g: bash -c "program args; program args; ...".

    If you happen to be a programmer see how the body to the bash -c "..." call resembles an anonymous function.


    Putting these pieces together we have:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    # Launch commands in a new session.
    setsid bash -c "
        # 1. Kill the original terminal.
        kill ${TERM_PID}
    
        # 2. Run our program.
        ${COMMAND} ${FILENAME}
    
        # 3. Relaunch the terminal.
        ${TERMINAL} -cd "${PWD}"
    "
    

    TERMINAL is the path to your beloved console. I use urxvt, so I have: local TERMINAL="$(which urxvt)".

    COMMAND and FILENAME are the arguments received in a function where the setsid logic resides. I named this function recall. So we have:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    recall() {
        local COMMAND="$1"
        local FILENAME="$2"
        local TERMINAL="$(which urxvt)"
        local TERM_PID=""
    
        # Launch commands in a new session.
        setsid bash -c "
            # 1. Kill the original terminal.
            kill ${TERM_PID}
    
            # 2. Run our program.
            ${COMMAND} ${FILENAME}
    
            # 3. Relaunch the terminal window.
            ${TERMINAL} -cd ${PWD}
        "
    }
    

    We need to figure out the pid of the running terminal. Some shells have the PPID environment variable exported and it can be directly used. In those cases: local TERM_PID="$(ps -o ppid= -p $PPID)" is all we need.

    But we can't assume the availability of PPID, so we need get the terminal pid some other way.


  1. A tiny deviation: It's pids all the way up!

    Quickly! How many pids to our running terminal? Well... Let's better run a tiny test to figure it out:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #!/usr/bin/env bash
    
    
    ppid_of() {
        local PID=$1
    
        ps -o ppid= -p "${PID}"
    }
    
    main() {
        local PAPPA="$(ppid_of $$)"
        local GRANDPA="$(ppid_of "${PAPPA}")"
    
        echo "I'm: $$."
        echo "My pappa is: ${PAPPA}."
        echo "My grandpa is: ${GRANDPA}."
    
        read -p "Kill grandpa? [y/n]: " -r YES_NO
    
        if [[ "${YES_NO}" == y* ]]; then
            kill "${GRANDPA}"
        fi
    }
    
    
    main
    

    It seems that it's 2 pids away: $$ is the short lived script's pid spawned to display the pids (that script pid does not count), then we have its parent, which should be the shell were you typed and launched this tiny script, and finally the terminal (urxvt in my case).


    Our recall function should now look like:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    ppid_of() {
        local PID="$1"
    
        ps -o ppid= -p "${PID}"
    }
    
    recall() {
        ...
        local TERM_PID="$(ppid_of "$(ppid_of $$)")"
        ...
    }
    

    Note that a better way to do this is just to check if the PPID environment variable exists and use it, otherwise run ppid_of twice as shown above.


  1. Test time

    So, does it work as expected? Let's give it a try. By now we should have:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    #!/usr/bin/env bash
    
    
    ppid_of() {
        local PID="$1"
    
        ps -o ppid= -p "${PID}"
    }
    
    recall() {
        local COMMAND="$1"
        local FILENAME="$2"
    
        local TERMINAL="$(which urxvt)"
        local TERM_PID="$(ppid_of "$(ppid_of $$)")"
    
        # Launch commands in a new sesion.
        setsid bash -c "
            # 1. Kill the original terminal.
            kill ${TERM_PID}
    
            # 2. Run our program.
            ${COMMAND} "${FILENAME}"
    
            # 3. Relaunch the terminal.
            ${TERMINAL} -cd "${PWD}"
        "
    }
    
    open_file() {
        local FILENAME="$1"
    
        if [ -L "${FILENAME}" ]; then
            FILENAME="$(realpath "${FILENAME}")"
        fi
    
        local FILE_TYPE="$(file --mime-type -b "${FILENAME}")"
    
        case "${FILE_TYPE}" in
            "application/pdf")
                recall "zathura" "${FILENAME}"
                ;;
    
            # Extend me with more filetypes!
    
            *)
                echo "Error - No associated program for filetype: ${FILE_TYPE}."
                ;;
        esac
    }
    
    main() {
        local FILENAME="$1"
    
        open_file "${FILENAME}"
    }
    
    
    main "$@"
    

    I don't know in your computer, but in mine when I run for example: ./open Object\ Oriented\ C.pdf, zathura comes up with a black screen. At the bottom its indicator says: "[No name]" and when I quit it, the terminal does not come back. Nothing seems to be working. Disappointed.

    We need to take a closer look why the script is failing.

    Instead of doing: recall "zathura" "${FILENAME}" we can do echo recall "zathura" "${FILENAME}" to verify at least if the path is correct just before entering the recall function.

    I can see echo prints: recall zathura Object Oriented C.pdf. That looks good to me. Let's do the same but inside recall and also print all the other relevant variables instead of just FILENAME and make sure they are correct:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    recall() {
        local COMMAND="$1"
        local FILENAME="$2"
    
        local TERMINAL="$(which urxvt)"
        local TERM_PID="$(ppid_of "$(ppid_of $$)")"
    
        echo "${COMMAND} ${FILENAME}"
        echo "${TERMINAL} ${TERM_PID}"
    
        exit
        ...
    }
    

    On my machine, for example I get:

    1
    2
    3
    4
    5
    6
    lucas@dstation /tmp$ ./open Object\ Oriented\ C.pdf
    zathura Object Oriented C.pdf
    /usr/local/bin/urxvt 41721
    
    lucas@dstation /tmp$ echo $PPID
    41721
    

    I also checked TERM_PID against the exported environment variable PPID. It all looks good.

    So what's going on? Hint: From the above output there's actually a subtle detail that can indirectly lead to the bug, can you spot it?

    It's really subtle and it took me a bit of patience to track it but it had to do with the filename. Across the whole script FILENAME seems to be correctly holding the path to the file ("Object Oriented C.pdf" in this case).

    But note that this filename HAS SPACES! From the output that looks ok, but the critical issue lays inside our bash snippet when calling setsid.

    After the kill call, we are doing: ${COMMAND} ${FILENAME}. So the bash snippet part that calls our program would render as zathura Object Oriented C.pdf and indeed if we literally try this out by ourselves in the console zathura will attempt to open the NON EXISTING filenames: Object, Oriented, and C.pdf. Ouch!

    The fix? Instead of having: ${COMMAND} "${FILENAME}" we must have: ${COMMAND} \"${FILENAME}\".

    Does it work now? It does! When quitting zathura a new urxvt is spawned in its place. Are we done? Well... What if COMMAND has a path that contains spaces? And PWD? Let's kill those potential bugs before they arise:

    1
    2
    3
    4
    5
    6
    7
    recall() {
        ...
        \"${COMMAND}\" \"${FILENAME}\"
    
        ${TERMINAL} -cd \"${PWD}\"
        ...
    }
    

    One could argue if TERMINAL should also be escaped. I'm assuming that TERMINAL is found in PATH, otherwise feel free to escape it too.

    That should be pretty much it. From here there are also other relevant details that I'm not handling, the most important being: extra arguments passed to the bash snippet.

    For example if I want feh always print the filename of the opened image how do I pass the --draw-tinted -d -. options to it?

    One way would be to hardcode it in the case clause:

    1
    2
    3
    4
    5
    6
    case "${FILE_TYPE}" in
        "image/"*)
            recall "feh --draw-tinted -d -." "${FILENAME}"
            ;;
         ...
    esac
    

    which does not look good. Note however that this (at least to me) is a valid hardcoding use case as chances are that we always want to pass the same set of options to our favorite programs, after all open was created to save us typing time and we can always fallback to a custom call if we need something different than what we usually use.


    Please bear in mind that IS NOT an extensive application "opener" nor a robust one, but I hope it makes you play around with it and "own it" by customizing it to fit your needs.


    Exercise: What about opening (decompressing) MOST known compressed file types? Or opening URLs, magnet links? Or what about integrating this script with a console file manager? Have fun!


  1. Final remarks

    I believe this is an interesting script, not so much for what it does BUT for what it shows and teaches us:

    • File detection.
    • Pattern matching.
    • Disconnected processes.
    • White space handling (VERY important).
    • Process management.
    • Shell functions.

    Bash scripting is known for being TRICKY, especially if you're a beginner, forget something and you pay it big time!

    There's always something new to learn from the shell, always.

    What truly amazes me about the shell (and shell scripting) is the immense POWER that you have at the tip of your fingers every single time you type in commands.

    Think about it. You can achieve way too many things! We usually take for granted a lot of tech but I consider the shell is special because:

    • Its interface is pure plain text and text is a universal interface. Is everywhere.

    • It has successfully stood the test of time and will definitely remain doing so. GUIs change faster than you change your underwear. Shell, commands and console programs? Rarely, if ever. A learnt command stays with you forever!

    • With the aid of pipes you can make powerful and complex constructions just as if you were combining LEGO pieces.

    • After becoming comfortable, leaving the shell feels like losing too much power.

    • Is a great way to get you introduced to automation and programming. It might also make you scratch that itch to learn more about computers, OSS software and the unix mindset.


    So do yourself a favor: learn the shell, I guarantee you that you won't regret the investment!

    You will be able to profit, use and abuse a richer computing world that today unfortunately is still invisible to most regular users.


  1. Resources

    • Linux Pocket Guide - Daniel J. Barrett (excellent intro and reference for beginners).

    • The Linux Command Line - William Shotts (fantastic deep dive into the shell, available for free in PDF format).

    • Efficient Linux At The Command Line - Daniel J. Barrett (advanced book to raise your skills and shell comprehension to the next level).

    • man bash - The ultimate reference, as they say: RTFM.

    • Shellcheck - Your loyal companion for writing good shell scripts!


    Disclaimer: I'm not associated in any way with any of these authors nor I receive any benefits from them nor their publishers.


If you made it here I hope you enjoyed the article. Unfortunately you can't ring the bell, nor subscribe, nor buy me a coffee but that's ok with me. Thanks for reading!