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:
- Run
open FILE. - The console process terminates (is killed).
- Our desired program (e.g:
zathura doc.pdf) is spawned. - As soon as the opened program is terminated, a new console
is launched back in the same directory
openwas 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!
-
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
zathuraandfeh(an image viewer) are found inPATH. 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,filegives us a robust way to detect the file type of the filename that is passed to the script.From the second pattern in the
caseclause it seems that we will callfehonly when jpeg, tiff or png formats are detected. That's very limited, what about other image formats? Thecaseclause in bash is surprisingly versatile as you can see in the second pattern above: it allows us to|(or) the test to matchFILETYPE.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_filefunction 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
filewill report ainode/symlinktype. 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
FILENAMEand 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.
-
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
setsidallows 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
nohupor "disowned" withdisown(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:- Kill the original terminal.
- Run our program.
- 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-cflag, 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}" "
TERMINALis the path to your beloved console. I useurxvt, so I have:local TERMINAL="$(which urxvt)".COMMANDandFILENAMEare the arguments received in a function where thesetsidlogic resides. I named this functionrecall. 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
PPIDenvironment 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.
-
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 (urxvtin my case).
Our
recallfunction 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
PPIDenvironment variable exists and use it, otherwise runppid_oftwice as shown above.
-
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,zathuracomes 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 doecho recall "zathura" "${FILENAME}"to verify at least if the path is correct just before entering therecallfunction.I can see
echoprints:recall zathura Object Oriented C.pdf. That looks good to me. Let's do the same but insiderecalland also print all the other relevant variables instead of justFILENAMEand 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_PIDagainst the exported environment variablePPID. 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
FILENAMEseems 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
killcall, we are doing:${COMMAND} ${FILENAME}. So the bash snippet part that calls our program would render aszathura Object Oriented C.pdfand indeed if we literally try this out by ourselves in the consolezathurawill attempt to open the NON EXISTING filenames:Object,Oriented, andC.pdf. Ouch!The fix? Instead of having:
${COMMAND} "${FILENAME}"we must have:${COMMAND} \"${FILENAME}\".Does it work now? It does! When quitting
zathuraa newurxvtis spawned in its place. Are we done? Well... What ifCOMMANDhas a path that contains spaces? AndPWD? 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
TERMINALshould also be escaped. I'm assuming thatTERMINALis found inPATH, 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
fehalways 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
caseclause: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
openwas 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!
-
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.
-
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!