Now I am become Death, the destroyer of worlds, Robert Oppenheimer

Having established a robust method for launching applications, we now turn our attention to a vital component of the daily workflow: the clipboard. While tools like CopyQ offer powerful history management, integrating them seamlessly into a tiling window manager via keybindings can be tricky. The primary challenge lies in CopyQ’s architecture: it requires a background daemon to be running and listening on a socket before the client interface can be opened. A simple keybind often fails if the daemon hasn’t finished initializing or if essential environment variables (like WAYLAND_DISPLAY on Niri) aren’t correctly propagated to the new process.
In this article, we introduce myrofi_clipboard.py, a Python utility designed to bridge this gap. This script goes beyond a simple execution command; it intelligently manages the CopyQ lifecycle —detecting if the daemon is active, launching it if necessary with the correct environment, and polling for socket readiness— ensuring that your clipboard history is always accessible instantly and reliably.
#!/usr/bin/env python3
"""
myrofi_clipboard.py — Rofi-driven CopyQ clipboard history picker
─────────────────────────────────────────────────────────────────────────
A lightweight launcher script that integrates CopyQ's clipboard history with rofi on both Wayland (Niri, Sway) and X11 (i3).
It ensures the CopyQ daemon is running, then opens the history picker window.
Supported environments: Arch Linux / Niri (Wayland)
NixOS / i3 (X11)
NixOS / Niri (Wayland)
Usage from rofi-launcher.sh:
python3 ~/dotfiles/scripts/myrofi_clipboard.py
Or bind directly in niri config.kdl:
Mod+V { spawn "python3" "~/dotfiles/scripts/myrofi_clipboard.py"; }
Dependencies:
* copyq (clipboard manager). Arch: sudo pacman -S copyq. NixOS: environment.systemPackages = [ pkgs.copyq ];
* rofi (or any launcher that calls this script)
* notify-send (optional, for error notifications)
CopyQ must be running as a background daemon before the picker can open.
The recommended setup is to start it at compositor startup:
Niri (config.kdl): spawn-at-startup "copyq" "--start-server"
i3 (config): exec --no-startup-id copyq --start-server
NixOS (home.nix): services.copyq.enable = true; # (if available)
OR add copyq to systemd user services
This script also starts the daemon on‑demand if not already running,
so the above startup config is optional but recommended.
"""
import subprocess
import os
import sys
import time
import shutil
# ══════════════════════════════════════════════════════════════════════════════
# HOW COPYQ WORKS
# ══════════════════════════════════════════════════════════════════════════════
#
# CopyQ has a client‑server architecture:
#
# 1. The SERVER (daemon process): runs permanently, monitors clipboard, stores history.
# Started with: copyq --start-server
#
# 2. CLIENT commands: short-lived processes that connect/talk to the running server socket. They exit immediately.
# Examples:
# copyq show — open the main window (searchable history)
# copyq menu — open the compact tray-style menu
# copyq version — print version (also works as a connection test)
# copyq read 0 — print the current clipboard contents
# copyq add "text" — add text to history
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════
# Maximum seconds to wait for the CopyQ daemon to start after we launch it.
COPYQ_START_TIMEOUT = 5.0
# Interval between connection retries while waiting for daemon startup.
COPYQ_RETRY_INTERVAL = 0.25
# ══════════════════════════════════════════════════════════════════════════════
# ENVIRONMENT HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def _build_env() -> dict[str, str]:
"""
Build the environment dictionary to pass when spawning CopyQ.
WHY this matters on Wayland / Niri:
When Python spawns a subprocess, it inherits the current process's
environment. But rofi-launcher.sh is invoked from a keybind, and the
environment at that point may be missing some variables that CopyQ
needs to connect to the display.
On Wayland/X11, CopyQ needs certain environment variables to connect
to the display and session bus. When this script is invoked from a
keybinding (via rofi or the compositor), the environment may be
incomplete. This function copies the current environment and ensures
essential variables are passed through.
Returns:
A copy of os.environ with display-related variables explicitly retained.
"""
env = os.environ.copy()
# Ensure display variables are present — CopyQ will fail silently without them
for var in ("WAYLAND_DISPLAY", "DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR", "XDG_SESSION_TYPE"):
if var not in env:
# These must come from the running session — we can't fabricate them.
# If they are genuinely absent, CopyQ will fail; this is expected
# when called outside a graphical session (e.g. from a TTY).
pass # leave absent, don’t fabricate, just pass through rather than set to a wrong value
return env
# ══════════════════════════════════════════════════════════════════════════════
# SERVER MANAGEMENT
# ══════════════════════════════════════════════════════════════════════════════
def _is_copyq_running() -> bool:
"""
Check whether the CopyQ daemon is running and accepting client connections.
We use a two‑step verification:
1. `pgrep -x copyq` — fast OS-level check: is a process named 'copyq' alive?
2. copyq version — actually connect to the socket and get a response.
We need BOTH because pgrep may find a copyq process that is still
initialising (socket may not be ready yet). `copyq version` failing means
"process exists but socket not ready" — keep waiting.
`copyq version` confirms the socket is alive and responding.
Returns:
True if the daemon is fully operational, False otherwise.
"""
# Quick process existence check first (avoids fork overhead if not running)
pgrep = subprocess.run(
["pgrep", "-x", "copyq"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if pgrep.returncode != 0:
return False # no copyq process at all
# Process exists — test the socket by running a harmless client command
connect_test = subprocess.run(
["copyq", "version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=_build_env(),
timeout=2, # generous timeout for a local socket
)
return connect_test.returncode == 0
def _start_copyq_daemon() -> None:
"""
Launch the CopyQ daemon in the background, detached from our process group.
Uses `copyq --start-server` which starts the daemon silently (in the background) and returns
immediately. The daemon will continue running after this script exits.
Why Popen with start_new_session=True?
subprocess.run() would block forever because the daemon never exits. Popen starts the process and returns immediately.
start_new_session=True calls os.setsid() in the child, creating a new process session.
The daemon is detached from our process group so it keeps running after this script exits and after rofi closes.
Without this, the daemon gets SIGHUP (a signal sent to a process when its controlling terminal is closed) when our session ends.
"""
subprocess.Popen(
["copyq", "--start-server"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=_build_env(),
start_new_session=True, # detach from our session → survives script exit
)
def ensure_copyq_running(timeout: float = COPYQ_START_TIMEOUT) -> bool:
"""
Ensure CopyQ daemon is running. Start it if it isn't.
This function is idempotent: if CopyQ is already running, it returns
True immediately. Otherwise, it launches the daemon and polls until
the socket becomes responsive or the timeout is reached.
Args:
timeout: Maximum seconds to wait after starting the daemon.
Returns:
True if CopyQ is ready to accept commands, False if startup fails.
"""
# Checks if CopyQ is already running
if _is_copyq_running():
return True
# If CopyQ is not running, starts it
_start_copyq_daemon()
# Wait for the socket to become ready or we time out
# Uses a monotonic deadline to wait up to timeout seconds
# ✔ time.monotonic() is a clock that never goes backwards
# Unlike time.time() that can jump if: NTP adjusts the clock, the user changes the system time, daylight savings changes.
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
time.sleep(COPYQ_RETRY_INTERVAL)
if _is_copyq_running():
return True
# Timed out — CopyQ failed to start
return False
# ══════════════════════════════════════════════════════════════════════════════
# Notifications
# ══════════════════════════════════════════════════════════════════════════════
def _notify(title: str, body: str = "", *, error: bool = False) -> None:
"""
Send a desktop notification using `notify-send`. Falls back silently if notify-send is absent.
Args:
title: Notification title.
body: Optional body text.
error: If True, uses an error icon and longer timeout.
"""
if not shutil.which("notify-send"):
return
icon = "dialog-error" if error else "dialog-information"
timeout = "6000" if error else "3000"
subprocess.run(
["notify-send", "-i", icon, "-t", timeout, title, body],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# ------------------------------------------------------------------------------
# Clipboard Picker
# ------------------------------------------------------------------------------
def show_clipboard_menu() -> None:
"""
Open the CopyQ clipboard history picker window.
We use `copyq show` (the main window) rather than `copyq menu` because:
- `copyq menu` expects a system tray, which may be missing or
unreliable on some Wayland compositors.
- `copyq show` works consistently on both Wayland and X11.
If the command fails, an error notification is displayed.
"""
result = subprocess.run(
["copyq", "show"],
env=_build_env(),
# Output is not captured; We want CopyQ to use the terminal's stdout
# for any error messages it may print.
)
if result.returncode != 0:
_notify(
"Clipboard",
"CopyQ failed to open.\n"
"Is it installed? Arch: sudo pacman -S copyq\n"
"NixOS: environment.systemPackages = [ pkgs.copyq ];",
error=True,
)
# ══════════════════════════════════════════════════════════════════════════════
# ENTRY POINT
# ══════════════════════════════════════════════════════════════════════════════
def main() -> None:
"""
Orchestrate the clipboard picker flow.
1. Make sure CopyQ is installed and CopyQ daemon is running (start it if not).
2. Open the clipboard history picker.
3. If CopyQ can't start, show an error notification.
If any step fails, an appropriate error notification is shown and the
script exits with a non‑zero status.
"""
# Check if copyq is installed
if not shutil.which("copyq"):
_notify(
"Clipboard",
"copyq not found.\n"
"Arch: sudo pacman -S copyq\n"
"NixOS: environment.systemPackages = [ pkgs.copyq ];",
error=True,
)
sys.exit(1)
# Ensure CopyQ daemon is ready
if not ensure_copyq_running():
_notify(
"Clipboard",
f"CopyQ failed to start after {COPYQ_START_TIMEOUT:.0f}s.\n"
"Run: copyq --start-server in a terminal to see error details.",
error=True,
)
sys.exit(1)
# Open the clipboard history picker.
show_clipboard_menu()
if __name__ == "__main__":
main()