UnQuarantize, a Dropzone action
A from-zero guide to coding a Dropzone action in Ruby or Python, covering the bundle, the runtimes, the metadata header and the dz API, with a worked quarantine-removal example.
13 minutes read
2634 words
Contents13
Dropzone is a macOS utility that sits in the menu bar. Clicking its icon opens a grid of targets, and dragging files or text onto a target runs a script called an action. This page is a guide to writing one. It covers the bundle on disk, the Ruby and Python runtimes Dropzone provides, the metadata header, the API an action talks to, and a complete worked example that removes the quarantine attribute from downloaded files. The finished bundle is linked at the end.
What Dropzone does
Dropzone gives you a drop target and runs your code when something lands on it. The target can copy files somewhere, upload them, transform them, or run any command a script can run. Each target in the grid is one action, and an action is a script with a small header that tells Dropzone how to treat it. Writing an action means writing that script.
An action receives what was dropped. For a file drop, it receives the list of paths. For a text drop, it receives the string. What the action does with them is entirely the script’s business.
The bundle
An action is a folder whose name ends in .dzbundle. Two files live inside it.
Remove Quarantine.dzbundle/
├── action.rb the script (or action.py for Python)
└── icon.png the grid icon, at least 300 by 300 pixels
The script is named action.rb for Ruby or action.py for Python. The icon is a square PNG shown in the grid. Dropzone draws it at 150 points, which is 300 pixels on a Retina display, so 300 by 300 is the practical minimum.
The bundle may hold more than these two files. It can also carry libraries the action depends on, or a helper executable it runs. Bundling dependencies this way is what keeps an action portable, since the bundle then travels with everything it needs. The Ruby and Python sections below cover how each language does it.
Ruby or Python
Dropzone runs actions in either language, and the choice is yours. The two are close to equivalent in what they can reach. The differences are the runtime each one gets and the libraries that come with it.
The clearest difference is how each ships extra libraries. Ruby has a documented step that downloads gems into the bundle, so a Ruby action carries its dependencies cleanly. Python has no such step, but a pure-Python package can be copied into the bundle and imported directly, and a package with compiled parts is reached through a bundled executable or the PythonPath field. Both cases are covered below. If the task needs a specific library, let its language and how well it bundles guide the choice. Otherwise either language is a fine starting point, and both share the same API and the same header.
The Ruby runtime
Dropzone ships its own Ruby and runs Ruby actions with it. The bundled version is Ruby 2.6.8. Actions do not use the system Ruby at /usr/bin/ruby, so the version is the same on every machine regardless of what the user has installed.
Seven gems come bundled and can be required without installing anything: rest-client, httparty, faraday, excon, aws-sdk, multi_json, and google-api-client. Three of those are HTTP clients, which reflects what most shared actions do, namely upload files to a service.
require 'rest-client' # available without a gem install
A gem outside that set has to be provided, and two routes do that.
The portable route bundles the gem inside the action, so it travels with the bundle and runs on any machine. List the gem in a Gemfile in the bundle, then run Aptonic’s bundle-gems.sh script, which downloads the listed gems into the bundle. In the action, after the metadata header and before requiring those gems, add this line:
require './bundler/setup'
The leading ./ matters. It loads the bundler/setup inside your bundle rather than the one Dropzone ships, so the bundled gems are the ones found.
The other route points the action at a Ruby that already has the gem, using the RubyPath header field. That Ruby then owns the gem, so an action written this way is only as portable as the interpreter it names.
In Ruby, the API object is the global $dz, and the dropped items are the global $items.
The Python runtime
Dropzone ships its own Python too, and runs Python actions with it. The bundled version is Python 3.10. Python 2 is not supported, so an action written for Python 2 will not run. As with Ruby, the system Python is not used unless you ask for it.
The bundled interpreter carries the Python standard library. It does not carry third-party packages, so import requests fails under the default runtime. Python has no download script like Ruby’s bundle-gems.sh, but a package can still travel inside the bundle.
For a pure-Python package, one with no compiled parts, copy the package directory into the bundle next to action.py and import it directly. Dropzone runs the action with the bundle as the script’s own directory, and Python puts that directory on the import path, so no path setup is needed. The second example below does this with the requests package.
# action.py, with a requests/ directory sitting in the bundle
import requests # imports the copy in the bundle, no sys.path line needed
The script can find its own bundle at runtime through __file__, which is useful for reaching a bundled data file or executable.
import os
here = os.path.dirname(os.path.realpath(__file__)) # the bundle directory
A package with a compiled extension is the harder case, because the compiled part is built against one interpreter and the bundled Python is fixed at 3.10. Two routes handle it. The first ships a small command-line executable in the bundle and calls it as a subprocess, which sidesteps the Python side entirely. The second sets the PythonPath header field to a Python that already has the package, such as a system install, a Homebrew Python, or a virtual environment. Inside a venv, PythonPath is the python3 under that environment’s bin directory. That interpreter then owns the package, so the action is only as portable as the Python it names.
In Python, the API object is the global dz, without the dollar sign, and the dropped items are the global items. Use print() for debug output. Values from a configuration panel arrive through os.environ.
The metadata header
Every action opens with a comment block that Dropzone reads before running anything. The first line must be exactly # Dropzone Action Info. Each field after it is a comment of the form # Field: value.
# Dropzone Action Info
# Name: Remove Quarantine
# Description: Removes the com.apple.quarantine attribute from dropped files and folders.
# Handles: Files
# Creator: Eddie
# URL: https://foundingfuture.com/software/unquarantine-dropzone-action/
# Events: Dragged
# SkipConfig: Yes
# RunsSandboxed: No
# Version: 1.0
# UniqueID: 4815162342
# MinDropzoneVersion: 4.0
These fields are required. Name and Description label the grid square. Handles states what the action accepts. Creator and URL identify the author, and Dropzone refuses an action whose header omits URL. RunsSandboxed states whether the action can run under sandbox restrictions. Version and UniqueID let Dropzone recognise an update, where UniqueID is any fixed number that identifies the action across versions.
These fields are optional. Events chooses when the action runs, covered below. SkipConfig: Yes installs the action with no setup panel. MinDropzoneVersion is the oldest Dropzone that may load it. KeyModifiers names modifier keys the action responds to, such as Command or Option. OptionsNIB selects a built-in configuration panel, for example a login or an API-key form, whose values reach the script through the environment. RubyPath and PythonPath override the interpreter, as described above.
Handles and the item list
Handles decides both what the target accepts and what the script receives.
With # Handles: Files, the action accepts a file or folder drop, and the dropped paths arrive as an array: $items in Ruby, items in Python. A drop of several files, several folders, or a mix fills the array with every one, so a single action call can process a whole selection.
With # Handles: Text, the action accepts dragged text, and the string arrives as the first element, $items[0]. With # Handles: Files, Text, the action accepts both, and the script checks which kind arrived before using it.
Events, dragged and clicked
An action runs on one of two events, set by the Events field.
# Events: Dragged runs the action when something is dropped on it, and calls a method named dragged. # Events: Clicked runs it when the grid square is clicked with nothing dropped, and calls a method named clicked. An action may declare both and implement both methods. A file action normally uses Dragged.
The dz API
The script talks to Dropzone through one object, $dz in Ruby and dz in Python. The method set is the same in both languages.
Progress and completion:
begin(message)shows a progress label as the action starts.determinate(flag)chooses a percentage bar (true) or an indeterminate one (false).percent(n)moves a determinate bar tonout of 100.finish(message)shows the final notification.url(value)ortext(value)places a result on the clipboard, and must be the last call. Passurl(false)when the action leaves the clipboard alone.fail(message)ends the action with an error notification.
User interaction and storage:
alert(title, message)anderror(title, message)show dialogs.inputbox(title, prompt, field)asks for a line of text.read_clipboardreturns the clipboard contents.add_dropbar(items)places files in Dropzone’s drop bar for later dragging.save_value(name, value)andremove_value(name)keep a small string across runs.temp_folderreturns a writable temporary directory.
Every action ends with either finish followed by url, or fail. That final call tells Dropzone the action is done.
A Ruby action
The action below removes the macOS quarantine attribute. macOS writes an extended attribute named com.apple.quarantine onto a downloaded file, and Gatekeeper reads it before the file first opens, producing a warning for software from an unidentified developer. Removing the attribute clears that warning for files whose source you already trust.
# Dropzone Action Info
# Name: Remove Quarantine
# Description: Removes the com.apple.quarantine attribute from dropped files and folders.
# Handles: Files
# Creator: Eddie
# URL: https://foundingfuture.com/software/unquarantine-dropzone-action/
# Events: Dragged
# SkipConfig: Yes
# RunsSandboxed: No
# Version: 1.0
# UniqueID: 4815162342
# MinDropzoneVersion: 4.0
def dragged
$dz.begin("Removing quarantine...")
$dz.determinate(false)
$items.each do |item|
system("/usr/bin/xattr", "-dr", "com.apple.quarantine", item,
out: File::NULL, err: File::NULL)
end
count = $items.length
label = count == 1 ? File.basename($items.first) : "#{count} items"
$dz.finish("Quarantine removed from #{label}")
$dz.url(false)
end
The header declares a file action that runs on a drop. $dz.begin and $dz.determinate(false) show an indeterminate progress bar, which suits a task whose length is not known in advance. The loop walks $items, and each path is passed to xattr.
xattr reads and changes extended attributes. The -d option deletes the named attribute, and -r applies the command through a folder’s whole contents, so dropping an application bundle or a folder of files clears every file inside it. Output and errors go to File::NULL, so a file that carried no quarantine mark, where xattr -d reports a missing-attribute error, still finishes cleanly.
$dz.finish shows the notification, and $dz.url(false) ends the action without touching the clipboard.
A Python action with a bundled package
The second action is written in Python and carries a third-party package. It validates JSON from one of two sources, a dropped file or the clipboard, and reports the result. It also sends that result to a service with the bundled requests library.
The point of the example is the bundled requests library. To include it, copy the requests package directory into the bundle next to action.py, so the bundle looks like this.
Validate JSON.dzbundle/
├── action.py
├── icon.png
└── requests/ the requests package, copied into the bundle
One way to get that directory is to install the package into an empty folder and copy the result, for example with pip install --target . requests run in a scratch directory, then move the requests folder into the bundle. With the directory in place, import requests in the action loads the bundled copy.
# Dropzone Action Info
# Name: Validate JSON
# Description: Validates a dropped file or the clipboard as JSON and shows the result.
# Handles: Files
# Creator: Eddie
# URL: https://foundingfuture.com/software/unquarantine-dropzone-action/
# Events: Clicked, Dragged
# SkipConfig: Yes
# RunsSandboxed: No
# Version: 1.2
# UniqueID: 7391056482
# MinDropzoneVersion: 4.0
import os
import json
import requests # the copy bundled in this action
def source_bytes():
# A dropped file first, then the clipboard. On a drag Dropzone sets the
# global items to the dropped paths, and on a click it leaves it unset.
try:
paths = list(items)
except NameError:
paths = []
for path in paths:
if os.path.isfile(path):
with open(path, "rb") as handle:
return handle.read()
clip = dz.read_clipboard()
if clip is not None and clip.strip() != "":
return clip.encode("utf-8")
return None
def validate():
dz.begin("Validating JSON...")
dz.determinate(False)
data = source_bytes()
if data is None:
dz.fail("No file or clipboard to validate")
return
# The standard library decides validity. json.loads raises on bad input
# and accepts bytes, so a file and clipboard text both work.
try:
json.loads(data)
verdict = "Valid JSON"
except ValueError:
verdict = "Invalid JSON"
# The bundled requests library makes one real call, best effort, so the
# verdict still shows when the network is down.
try:
requests.post("https://httpbin.org/post",
json={"result": verdict}, timeout=15)
except requests.RequestException:
pass
dz.finish(verdict)
dz.url(False)
def dragged():
validate()
def clicked():
validate()
The header sets Events: Clicked, Dragged, so Dropzone calls dragged when a file is dropped and clicked when the grid square is clicked. Both call one validate function. Handles: Files allows a file drop.
The source_bytes function decides what to read. On a drag, Dropzone sets the global items to the dropped paths, so the function reads the first dropped file. On a click, items is unset, so it falls back to dz.read_clipboard. With neither a file nor clipboard text, it returns nothing and the action ends through dz.fail. In Python the dropped paths are the global items, and the API object is dz.
The verdict comes from json.loads in the standard library, which parses the input and raises ValueError when it is not valid JSON. It accepts both bytes from a file and text from the clipboard. This is exact and needs no network, so it is the right tool for the check itself.
The requests.post call is what shows the bundled library at work. It sends the result to httpbin.org, a request-inspection service, and passing json={...} makes requests encode the body and set the JSON content type. The call is wrapped so a network failure is ignored, which keeps the verdict showing when the machine is offline. Point it at any endpoint that should receive the result, or remove it if the action only needs the local check.
Installing and developing
To try an action while writing it, open Dropzone’s grid, add an action, and choose Develop Action. Paste the script, choose the language, and Dropzone writes the bundle and loads it. The debug console opens with Command-Shift-D, and in it puts from Ruby or print from Python appears as the action runs.
To install a finished bundle, double-click the .dzbundle folder and Dropzone offers to add it. Installed actions live in ~/Library/Application Support/Dropzone 5/Actions/ under Dropzone 5. To reach that folder in any version without typing the path, right-click the action in the grid and choose Reveal.
Download
The finished action is packaged and ready to install: Remove Quarantine.dzbundle.zip .
The JSON validator turned out to be more useful than imagined: Validate JSON.dzbundle.zip
Unzip it and double-click the .dzbundle file, and Dropzone offers to install it. The MinDropzoneVersion: 4.0 line in the header lets both Dropzone 4 and Dropzone 5 load it.