JustToThePoint English Website Version
JustToThePoint en español

Rofi Clipboard History: Python Automation for CopyQ

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

image info

Introduction

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()
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.