The problem is not the problem. The problem is your attitude about the problem, Captain Jack Sparrow

As our collection of software grows, accessing our daily drivers —code editors, password managers, and file transfer tools— can become scattered across different menus and workspaces. While Bash scripts can cobble together a launcher, they often struggle with the nuances of process detachment, ensuring that an application truly lives on after the launcher script finishes executing.
In this article, we introduce myrofi_apps.py, a Python utility designed to clean up this workflow. This script replaces the traditional Bash favourite apps function with a robust, data-driven Rofi menu. By leveraging Python’s subprocess module, we guarantee that applications launch as fully detached processes, preventing them from terminating unexpectedly when the launcher closes. We will explore how to maintain a list of favorites and launch them securely, whether on Arch Linux or NixOS.
#!/usr/bin/env python3
"""
myrofi_apps.py — Favourite Apps submenu for rofi-launcher.sh
─────────────────────────────────────────────────────────────
Displays a rofi menu of predefined applications and launches the
selected one in a fully detached process.
WHY PYTHON INSTEAD OF BASH:
- subprocess.Popen() is the Python equivalent of ( cmd & ) in bash.
It forks the child process and returns immediately, leaving the child
fully detached (no wait, no blocking).
- No quoting/escaping hell; commands are plain lists.
- Easy to extend: just add a dict to APPS.
Usage (from rofi-launcher.sh):
7) python3 "$HOME/dotfiles/scripts/myrofi_apps.py" ;;
Requirements:
- rofi (installed and on $PATH)
- The binaries listed in APPS must be installed and accessible
- Python 3.8+. It uses only Python stdlib (subprocess, os, sys) — no pip, no venv.
HOW PYTHON DETACHES A PROCESS:
subprocess.Popen(cmd) forks the process immediately and returns a Popen object.
It does NOT wait for the child to finish.
The child is re-parented to PID 1 (systemd) when this script exits and
the child does not inherit our file descriptors (close_fds=True), keeping it fully detached.
"""
import subprocess
import os
import sys
# ── Configuration ─────────────────────────────────────────────────────────────
# Path to the rofi theme file. These must match rofi-launcher.sh
# so the submenu looks visually consistent with the main launcher.
# `os.path.expanduser` converts ~ to the user's home directory.
ROFI_THEME = os.path.expanduser("~/dotfiles/myArch/config/rofi/rounded-dark")
ROFI_FONT = "hack 20"
# ── Application definitions ───────────────────────────────────────────────────────────
#
# Each entry is a dict with:
# "label" — text displayed in the rofi menu. Keep it short; rofi truncates long lines.
# "command" — the command to run as a list of strings.
# Using a list prevents shell injection and quoting issues.
#
# WHY ABSOLUTE PATHS FOR SOME APPS:
# Absolute paths are used for binaries that may not be on $PATH when invoked from a rofi keybinding
# To find a binary path on NixOS: `which code` or `ls /run/current-system/sw/bin/`
APPS = [
{
"label": " 🔐 VS Code",
"command": ["code"],
},
{
"label": " 🔐 KeePassXC",
"command": ["keepassxc"],
},
{
"label": " 📁⇄📁 LocalSend",
"command": ["localsend"],
},
{
"label": " 📤 FileZilla",
"command": ["filezilla"],
},
{
"label": " 🌄 GIMP",
"command": ["gimp"],
},
{
"label": " 🗃️ Nautilus (File Manager)",
"command": ["nautilus"],
},
]
# ── Menu display ──────────────────────────────────────────────────────────────
def show_menu(apps: list[dict]) -> dict | None:
"""
Display the application list in a rofi dmenu and return the selected app dict.
Args:
apps: List of app definitions (each a dict with "label" and "command").
Returns:
The selected app dict, or None if the user pressed Escape.
How rofi is called:
- Input: one label per line, written to rofi's stdin.
- Output: the selected label on stdout.
- Flags:
-dmenu read from stdin, write selection to stdout
-i case-insensitive filtering
-p prompt text
-no-custom user cannot type arbitrary text not in the list
-lines N fix menu height to exactly N lines (no empty space)
-theme/-font visual consistency with rofi-launcher.sh
"""
# Step 1: Build the input string for rofi's stdin.
# We create a single string with each application label on its own line.
# Rofi's dmenu mode reads these lines and presents them as selectable options.
menu_input = "\n".join(app["label"] for app in apps)
# Step 2: Invoke rofi as a subprocess.
# We use `subprocess.run` because we need to wait for the user to make a
# selection and then read the result. The command is built as a list to
# avoid shell injection vulnerabilities.
result = subprocess.run(
[
"rofi",
"-dmenu", # Operate in dmenu mode
"-i", # Case‑insensitive matching
"-p", " App", # Prompt text shown at the top
"-no-custom", # Disallow typing arbitrary text
"-lines", str(len(apps)), # Show all options without scrolling
"-theme", ROFI_THEME, # Apply the specified theme
"-font", ROFI_FONT, # Use the specified font
],
input=menu_input, # Feed the labels via stdin
capture_output=True, # Capture stdout (the selected label)
text=True, # Work with strings instead of bytes
)
# Step 3: Handle rofi's exit status.
# rofi returns exit code 0 when the user selects an option and presses Enter.
# rofi returns exit code 1 (or other non‑zero) when the user cancels (Escape).
if result.returncode != 0:
return None
# Strip any leading/trailing whitespace from the selected label.
selected_label = result.stdout.strip()
if not selected_label:
return None
# Step 4: Map the selected label back to its corresponding app dict.
# We iterate through the app list and compare the label (stripped of extra
# spaces) to the returned string (selected_label). Since rofi might add
# or remove whitespaces, we use `strip()` for a more robust comparison.
for app in apps:
if app["label"].strip() == selected_label.strip():
return app
# If we reach this point, something unexpected happened (e.g., the label
# didn't match any known app). This should not occur because `-no-custom`
# prevents the user from entering arbitrary text, but we log an error just
# in case for debugging purposes.
print(f"myrofi_apps: unknown selection: {selected_label!r}", file=sys.stderr)
return None
# ------------------------------------------------------------------------------
# Process launch (detached)
# ------------------------------------------------------------------------------
def launch(command: list[str]) -> None:
"""
Launch a command in a fully detached process.
This is the Python equivalent of bash's ( cmd & ) pattern.
The child is placed in its own process group and does not inherit
our file descriptors. After this script exits, the child is
re‑parented to PID 1.
Args:
command: List of strings forming the command to execute, e.g. ["code"] or
["alacritty", "-e", "nvim", "/path/to/file"].
How detachment works:
subprocess.Popen() forks immediately and returns — it does NOT wait for the child.
When this script exits, the child is then re-parented to PID 1 (systemd) by the kernel,
making it fully independent of this process and of rofi.
* close_fds=True — Ensures the child process does not inherit any open file descriptors
from the parent (this script). This is important for complete detachment, as inherited file descriptors can keep the
child tied to the parent's session or terminal.
* stdout/stderr → /dev/null — suppress any output from the launched app.
In other words, it redirects the child's output to /dev/null. This prevents the launched
application from accidentally writing to the terminal from which this
script was started, which could otherwise clutter the output or hang if the terminal is closed.
* start_new_session=True — puts the child in a new process group
preventing SIGHUP (hangup signal) from reaching it if this script's terminal session ends.
It also prevents the child from being affected by signals sent to the parent's process group.
Together, these flags make the launched application completely independent of this Python script and the rofi process that called it. Once the script exits, the child becomes an orphan and is automatically adopted by PID 1 (systemd on most modern Linux systems).
"""
subprocess.Popen(
command,
stdout=subprocess.DEVNULL, # suppress child stdout
stderr=subprocess.DEVNULL, # suppress child stderr
close_fds=True, # do not inherit our file descriptors
start_new_session=True, # new process group — survives our (parent) exit
)
# ══════════════════════════════════════════════════════════════════════════════
# ENTRY POINT
# ══════════════════════════════════════════════════════════════════════════════
def main() -> None:
"""
Main flow:
1. Show the rofi app picker.
2. If user selected an app, launch it detached.
3. If user pressed Escape, exit silently.
"""
# Step 1: Display the menu and get the user's choice.
selected = show_menu(APPS)
# Step 2: Handle the result.
if selected is None:
# User cancelled the menu (pressed Escape) — exit quietly, nothing to do
sys.exit(0)
# Step 3: Launch the chosen application in a detached process.
# The command is taken directly from the "command" key of the selected dict.
# This is a list of strings, safe to pass directly to Popen.
launch(selected["command"])
if __name__ == "__main__":
main()