Open With in macOS, Windows, and Ubuntu
So you know how you can right click on a file and have options to open it with different programs? That’s what I wanted to do when I was working on my Quaver Lyrics Finder project. So here’s how I did it, making it work for macOS, Windows, and Ubuntu, and how it handles in PyQt.
This is the second part of the series where I write about things I learned while writing a desktop app. The first post can be found here.
Contents
macOS
This post assumes basic knowledge of macOS application bundle structure. See here for more details.
On macOS, the framework responsible for managing integration with discovering and managing “open with” context menu items is Core Foundation. It parses the Info.plist
file in the Contents folder at the root of the application bundle, and every key that’s relevant to Core Foundation in that file starts with CF
.
To integrate with the “Open With” menu on macOS, a CFBundleDocumentTypes
1 key should be added in the Info.plist file. The value corresponding to that should be an array of <dict>
elements.
Each <dict>
element should contain a:
CFBundleTypeExtensions
2 key with a corresponding array of<string>
values, matching each filetype extension that your program targetsCFBundleTypeName
3 with a corresponding<string>
value, containing the name that your program assigns to these filetype extensionsCFBundleTypeRole
4 with a corresponding<string>
value, describing what your program does with the file. This role can be any one of Editor, Viewer, Shell, or None. See the footnote for Apple’s documentation on what each type should do.
<key>CFBundleDocumentTypes</key> <!-- Declare that program associates filetypes -->
<array>
<dict>
<key>CFBundleTypeExtensions</key> <!-- Declare which extensions to associate with -->
<array>
<string>mp3</string>
<string>mp4</string>
<string>m4a</string>
<string>m4v</string>
<string>tta</string>
<string>ape</string>
<string>wma</string>
<string>aiff</string>
<string>flac</string>
<string>ogg</string>
<string>oga</string>
<string>opus</string>
</array>
<key>CFBundleTypeName</key> <!-- Declare name to associate with these extensions -->
<string>Audio Files</string>
<key>CFBundleTypeRole</key> <!-- Declare role that program plays when opening file -->
<string>Viewer</string>
</dict>
</array>
After the Info.plist file has been updated and the program has been moved to the Applications folder, run the following command to force Core Services to register the newly claimed filetypes:
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -f /Applications/<YOUR_APP_NAME>.app/
When this is done, run killall Finder
to force restart Finder, and your program should now show up in Finder’s context menu!
Handling the file open event in PyQt
Note: this is specific to PyQt, so if you aren’t using that, skip this section!
So now, your program opens when you click on the right click menu, but nothing happens in the program.
As it turns out, the program must listen for the event that occurs when a program is launched via the right click menu, and override the default behaviour of doing nothing. In Qt, the event specific to macOS is QFileOpenEvent
, and the listener (or event handler) for all events is part of the main QApplication
class.
Thus, we must:
- Subclass
QApplication
- Override the
QFileOpenEvent
handler
Here’s the snippet that handles this in Quaver:
from gui import main_window # This file just contains code for the main window
import sys
from PyQt5 import QtCore, QtWidgets
class MainApp (QtWidgets.QApplication): # Subclass QApplication
# Just setup, nothing to see here
def __init__(self, argv):
super(MainApp, self).__init__(argv)
self._window = main_window.MainWindow()
self._window.show()
self._window.setWindowTitle('Quaver')
# This is the handler for all events!
# By defining a function in QApplication's subclass,
# we override the default implementation
def event(self, event):
# An event has occurred! See http://doc.qt.io/qt-5/qevent.html for more details
# We can access the type of event by using event.type()
# QFileOpen is an enum in the C++ version of Qt, equivalent to 116
# Access and compare against by using QEvent.FileOpen in Python binding
if event.type() == QtCore.QEvent.FileOpen:
# self._window.generateFilepathList is a function defined elsewhere that accepts a string parameter
# event.url() returns a QUrl when event is a QFileOpenEvent;
# I convert to string with .toString() and remove the file:// protocol section of the URL
self._window.generateFilepathList([event.url().toString().replace('file://', '')])
else:
# If the event is not a FileOpen event, Quaver does not intercept it
pass
return True
def main():
app = MainApp(sys.argv) # Instantiate the main app
sys.exit(app.exec_()) # Execute app event loop until exit
if __name__ == '__main__':
main()
And with this, we have: a program that integrates with Finder’s “Open With” context menu, associated to any number of filetype extensions, and the filepath to the file that the user launched the app from.
Windows
This post assumes basic knowledge of the Windows Registry. See here for more details.
On Windows, configuration information is generally stored in the Windows Registry. It also happens to store all of the file type associations, which is what we want.
Note that I am using the free version of Advanced Installer to generate an MSI installer that puts registry keys in the right places, so this section will have little to no code.
Under HKEY_CURRENT_USER/HKEY_LOCAL_MACHINE, depending on whether the user installed your program for themselves only or for the machine, create a key under Software/Clases/Applications/
with the name <YOUR_PROGRAM_HERE>.exe
.
The <YOUR_PROGRAM_NAME_HERE>.exe key should have four values:
ApplicationCompany
, of type REG_SZ, containing the name of your company.FriendlyAppName
, which is a short, friendly app name that you want to show users.Path
, which is the absolute path to your program.Version
, which is the version of your app.
Under <YOUR_PROGRAM_NAME_HERE>.exe, create three new keys:
Capabilities
, which contains a little bit of information about your app.- This key should have values:
(Default)
, of type REG_SZ, that contains no data.ApplicationDescription
, of type REG_SZ, that a short blurb about your program.ApplicationName
, of type REG_SZ, that contains the full application name.
- This key should have values:
shell
, which contains further subkeys (open
>command
) that tell Windows how to open your program.command
should have a value of type REG_SZ with data"<ABSOLUTE_PATH_TO_YOUR_PROGRAM>" "%1"
, with quotes. (This is a shell command that launches your program, with one argument.)
SupportedTypes
, which tells Windows what file types to register.- This key should contain any number of values, with the name of the extension that you wish to register with your program (such as
.mp3
), of type REG_SZ, with no data.
- This key should contain any number of values, with the name of the extension that you wish to register with your program (such as
Your registry entries should now look like this:
_HKCU/HKLM
|
└───_Software
|
└───_Classes
|
└───_Applications
│
└───_<YOUR_PROGRAM_NAME_HERE>.exe
│
├───Capabilities
│
├───_shell
│ │
│ └───_open
│ │
│ └───command
│
└───SupportedTypes
Now, when you right click on a file with an extension that you’ve registered, your program should show up, with its icon!
Handling the file in PyQt
Note: this is specific to PyQt, so if you aren’t using that, skip this section!
So now, your program opens when you click on the right click menu, but nothing happens.
In the registry key shell > open > command, notice that we have a “%1”. This means that one argument is passed in, and that argument is the filepath. Thus, what we need to do is read that filepath and pass it in to our application to do things with.
Here’s the snippet in Quaver that handles this:
from gui import main_window # This file just contains code for the main window
import sys
from PyQt5 import QtCore, QtWidgets
class MainApp (QtWidgets.QApplication): # Subclass QApplication
# Constructor, accepting list of arguments argv[]
def __init__(self, argv):
super(MainApp, self).__init__(argv) # Call constructor of superclass
# Create a main window; code is handled in other file.
self._window = main_window.MainWindow()
self._window.show()
self._window.setWindowTitle('Quaver')
# The important part!
# Wrapped in a try/except block since user may not have launched this program from a context menu
# When not launched by context menu, there is no sys.argv[1], so guard against that
try:
filepath = sys.argv[1] # sys.argv[1] contains the second command line argument, aka "%1"
self._window.generateFilepathList([filepath]) # filepath is passed in as a string
except:
pass
def main():
app = MainApp(sys.argv) # Instantiate the main app, passing in arguments
sys.exit(app.exec_()) # Execute app event loop until exit
if __name__ == '__main__':
main()
And with this, we have: a program that integrates with Windows Explorer’s “Open With” context menu, associated to any number of filetype extensions, and the filepath to the file that the user launched the app from.
Ubuntu
This section assumes general knowldge of the structure of a .deb file. This approach should also work on Ubuntu-based distros, such as Kubuntu, Lubuntu, elementary OS, etc., but has only been tested on Ubuntu 16.04/18.04, Kubuntu 18.04, and elementary OS 0.4.1.
This goes hand-in-hand with packaging a .deb file, so if you haven’t read it already, check out my post on that.
Ubuntu and all Ubuntu-based distros follow what’s called the Free Desktop Standard, and adhere specifically to the desktop entry spec, which is what we’re interested in.
What you’ll need is a <YOUR_APP_NAME>.desktop
file in the usr/share/applications
folder in the Linux machine. This file registers your program for display in an application launcher such as slingshot, and associates MIME types with your program so that they appear in the context menu for files that match the MIME type. See documentation in the GNOME developer guide for more information.
MIME types are associated with the MimeType key, separated by semicolons, and the shell command to execute your program is associated with the Exec key.
As an example, here’s the quaver.desktop file:
[Desktop Entry]
Version=0.5.513
Type=Application
Name=Quaver
GenericName=Lyrics Finder
Comment=Quickly find lyrics for your songs.
Exec=/usr/local/bin/Quaver %F
Terminal=false
MimeType=audio/x-mp3;audio/mp4;audio/x-m4a;video/x-m4v;audio/x-tta;audio/x-ape;audio/x-ms-wma;audio/x-pn-aiff;audio/x-flac;audio/ogg
Icon=/usr/local/bin/quaver.svg
Keywords=Quaver;Audio;Lyrics;Finder;Editor;Songs;
Categories=Audio;Music;Editor;AudioVideo;
StartupNotify=true
In the MimeType line, I declare support for audio/x-mp3 (.mp3 files), video/x-m4v (.m4v files), and so on. In the Exec line, the first argument is the location of the executable (in this case, /usr/local/bin/Quaver), followed by %F, meaning that my program should accept a list of files.5
With this .desktop file and the executable in the right place, my app should now appear in the context menu when right-clicking any file with a filetype listed under MimeTypes!
Handling the file(s) in PyQt
Note: this is specific to PyQt, so if you aren’t using that, skip this section!
Like Windows, the list of filepaths is passed in as an argument when launching the program. And much like Windows, we’ll need to access sys.argv
to read them and then do things with them.
Here’s what it looks like in Quaver:
from gui import main_window # This file just contains code for the main window
import sys
from PyQt5 import QtCore, QtWidgets
class MainApp (QtWidgets.QApplication): # Subclass QApplication
# Constructor, accepting list of arguments argv[]
def __init__(self, argv):
super(MainApp, self).__init__(argv) # Call constructor of superclass
# Create a main window; code is handled in other file.
self._window = main_window.MainWindow()
self._window.show()
self._window.setWindowTitle('Quaver')
# The important part!
# Wrapped in a try/except block since user may not have launched this program from a context menu
# When not launched by context menu, there is no sys.argv[1], so guard against that
try:
filepaths = []
# Loop through each element in sys.argv from the first element
# starting from the 1st index (i.e. the first file path)
# Append that filepath to the list of filepaths called filepaths[]
[filepaths.append(file) for file in sys.argv[1:]]
# Now, filepaths is a list of all the filepaths that were passed in as arguments,
# a.k.a. the paths to the files that the user selected when right-clicking to launch the program
# and self._window.generateFilepathList() accepts a list, then does something with it.
self._window.generateFilepathList(filepaths)
except:
pass
def main():
app = MainApp(sys.argv) # Instantiate the main app, passing in arguments
sys.exit(app.exec_()) # Execute app event loop until exit
if __name__ == '__main__':
main()
Note that you could probably just pass in sys.argv[1:], but this code is more explicit for illustrative purposes.
And with this, we have: a program that integrates with your distro’s file manager, be it Pantheon Files or Nautilus or Dolphin, associated to any number of filetype extensions, and the filepath(s) to the file(s) that the user launched the app from.
-
Apple’s documentation on CFBundleDocumentTypes ↩
-
See Apple’s documentation on CFBundleDocumentTypes for more details. ↩
-
See Apple’s documentation on CFBundleDocumentTypes for more details. ↩
-
Apple’s documentation on CFBundleTypeRole ↩
-
See Free Desktop Foundation’s Exec key documentation for more details. ↩
aaron at 18:04