JustToThePoint English Website Version
JustToThePoint en español

Rofi Utility Tools: xkill, Web Apps, and Hotkeys

Mistakes are the best teacher when one is humble and honest enough to admit them and willing to learn and grow from them, Anawim, #justtothepoint.

image info

Introduction

Beyond launching applications and managing the clipboard, a mature desktop environment requires tools to handle edge cases —like frozen windows— and to bridge the gap between local software and web-based workflows. While the xkill utility is a lifesaver when a GUI freezes, integrating it seamlessly into a Wayland or i3 workflow requires careful handling of input grabs to prevent conflicts. Similarly, turning web services like Gmail or WhatsApp into “apps” requires more than just a bookmark; it demands the browser be launched in a specific mode.

In this article, we introduce myrofi_tools.py, a versatile Python script that aggregates these essential utilities into a clean Rofi interface. We explore advanced process management techniques, including the use of os.execvp to cleanly replace the Python process with a browser window for web apps, and the implementation of precise timing delays to safely release input grabs before invoking xkill or niri msg action close-window. This script rounds out our Rofi toolkit by adding critical window management and PWA capabilities.

#!/usr/bin/env python3
"""
myrofi_tools.py — Utility rofi functions: xkill, show hotkeys and web apps
──────────────────────────────────────────────────────────────

Usage from rofi-launcher.sh:
    29) python3 ~/dotfiles/scripts/myrofi_tools.py --xkill ;;
     6) python3 ~/dotfiles/scripts/myrofi_tools.py --web-apps ;;
     30) python3 "$HOME/dotfiles/myArch/admin/myrofi_tools.py" --hotkeys ;;

Or import from another Python rofi script:
    from myrofi_tools import xkill_window, web_apps
    xkill_window()
    web_apps()

Requirements:
    stdlib only — subprocess, time, os, sys
    xkill must be installed (pkgs.xorg.xkill on NixOS, xorg-xkill on Arch)
    google-chrome-stable must be on $PATH for web apps
    (hotkeys function is specific to the Niri compositor)
"""

import subprocess
import time
import os
import sys

# ── Configuration ─────────────────────────────────────────────────────────────

ROFI_THEME = os.path.expanduser("~/dotfiles/myArch/config/rofi/rounded-dark")
ROFI_FONT  = "hack 20"

# The browser used to open web apps.
# --app=URL opens as a standalone PWA window (no address bar, no tab strip).
BROWSER = "google-chrome-stable"

# How long to wait (seconds) after rofi closes before launching xkill.
# This ensures rofi has fully released the X11/Wayland keyboard+pointer grab
XKILL_GRAB_DELAY = 0.3

# ── Shared helpers ────────────────────────────────────────────────────────────

def _notify(title: str, body: str = "") -> None:
    """
    Send a desktop notification if notify-send is available.
    """
    # Notifications are informational — a missing notify-send should neverblock the main action
    if not _command_exists("notify-send"):
        return
    cmd = ["notify-send", "-i", "dialog-information", "-t", "4000", title]
    if body:
        cmd.append(body)
    subprocess.run(cmd, check=False, capture_output=True)


def _command_exists(name: str) -> bool:
    """Return True if `name` is found on $PATH."""
    result = subprocess.run(
        ["which", name],
        capture_output=True,
        check=False,
    )
    return result.returncode == 0


def _launch(*args: str) -> None:
    """
    Launch a process fully detached from this script's process group.

    WHY start_new_session=True:
        When called from rofi, the parent rofi process holds an X11/Wayland
        keyboard grab. If the child stays in the same process group and rofi
        exits while the child is still running, X11 can leave the grab
        dangling — the keyboard gets stuck.

        start_new_session=True calls os.setsid() in the child before exec,
        moving it to a new process session. The kernel re-parents it to PID 1
        (systemd). X11/Wayland sees a clean process group exit from rofi and
        releases the grab normally.

    WHY Popen instead of run:
        subprocess.run() blocks until the child exits.
        subprocess.Popen() returns immediately — the child runs independently.
    """
    subprocess.Popen(
        list(args),
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,   # os.setsid() in child — releases X11/Wayland grab
    )


def _rofi_menu(
    options: list[str],
    prompt: str,
    lines: int | None = None,
) -> str | None:
    """
    Show a rofi dmenu with the given options and return the selected string.

    Args:
        options: List of strings to display.
        prompt:  Text shown to the left of the input box.
        lines:   Number of visible lines. Defaults to len(options).

    Returns:
        The exact string the user selected, or None if they pressed Escape.

    WHY -no-custom:
        Prevents the user from typing arbitrary text that isn't in the list.
        We only want to handle known options — unknown input would require
        a fallback case in every caller, making error handling messier and complicated.

    WHY -format s (not -format i):
        -format i returns the 0-based integer index of the selection.
        -format s returns the selected string itself.
        Using the string is safer: if the options list is reordered, the
        string-based match still works; an index-based match would silently
        execute the wrong action.
    """
    n_lines = lines if lines is not None else len(options)
    menu_input = "\n".join(options)

    result = subprocess.run(
        [
            "rofi",
            "-dmenu",
            "-i",                     # case-insensitive filtering
            "-p", prompt,
            "-no-custom",             # only accept options from the list
            "-format", "s",           # return selected string (not index)
            "-lines", str(n_lines),
            "-theme", ROFI_THEME,
            "-font",  ROFI_FONT,
        ],
        input=menu_input,
        capture_output=True,
        text=True,
        check=False,
    )

    # rofi exit codes:
    #   0  = normal selection
    #   1  = user pressed Escape (no selection)
    #   10 = custom keybind 1 (we don't use these)
    if result.returncode != 0:
        return None

    selected = result.stdout.strip()
    return selected if selected else None


# ══════════════════════════════════════════════════════════════════════════════
# TOOL 1 — xkill: click to force-kill a frozen window
# ══════════════════════════════════════════════════════════════════════════════

def xkill_window() -> None:
    """
    Trigger a “kill window” action, adapted for Niri.

    Historically, on X11, this workflow used `xkill`:
        - A small delay, then `xkill` grabs the pointer,
        - turns it into a crosshair,
        - and the next click force‑kills the selected window.
      This relies on X11’s global pointer‑grab mechanism.

    Under Niri (Wayland), this model no longer applies:
        - Wayland compositors do not allow clients to take global pointer grabs.
        - `xkill` still exists, but it cannot function as intended because
          it cannot intercept clicks outside its own surface.
        - Instead, Niri exposes a compositor‑level action:
              `niri msg action close-window`
          which instructs the compositor to close the focused window directly.

    This function therefore behaves like a “Wayland‑safe xkill”:
        - Notify the user that a kill action is about to occur.
        - Wait briefly to ensure that rofi (or any launcher) has released
          its own pointer/keyboard grab before invoking the compositor action.
        - Ask Niri to close the currently focused window.

    WHY the delay matters:
        Both rofi and X11/Wayland kill actions require exclusive input focus.
        If rofi still holds the grab when we invoke the close-window action,
        the request may be ignored or applied to the wrong window.
        A short delay ensures the compositor has clean control again.
    """
    # if not _command_exists("xkill"):
    #    _notify(
    #        "xkill not found",
    #        "Install it: sudo pacman -S xorg-xkill  (Arch)  or  pkgs.xorg.xkill  (NixOS)",
    #    )
    #    print("xkill not found on PATH", file=sys.stderr)
    #    sys.exit(1)

    # Notify BEFORE the delay so the user knows what's happening
    #_notify("xkill", "Click a window to kill it  (Escape to cancel)")
    _notify("niri msg action close-window", "the compositor is going to close the focused window directly")

    # Wait for rofi to fully release the X11/Wayland grab
    time.sleep(XKILL_GRAB_DELAY)

    # subprocess.run() — blocking, foreground execution.
    # xkill exits when the user clicks a window (kills it) or presses Escape.
    # The Python instruction would be: subprocess.run(["xkill"], check=False)
    # In Niri, we use a compositor‑level action:
    subprocess.run(["niri", "msg", "action", "close-window"], check=False)

# ══════════════════════════════════════════════════════════════════════════════
# TOOL 2 — show_hotkeys: Show Hotkey Overlay (Super + Shift + /)
# ══════════════════════════════════════════════════════════════════════════════

def show_hotkeys() -> None:
    """
    Trigger Niri's built‑in hotkey overlay (Super + Shift + /).
    This function is specific to the Niri Wayland compositor.
    """

    # Notify BEFORE the delay so the user knows what's happening
    _notify("hotkeys", "Use the shortcut: Mod+Shift+Slash")

    # Wait for rofi to fully release the X11/Wayland grab
    time.sleep(XKILL_GRAB_DELAY)

    subprocess.run(["niri", "msg", "action", "show-hotkey-overlay"], check=False)

# ══════════════════════════════════════════════════════════════════════════════
# TOOL 3 — web_apps: open a web application as a standalone PWA window
# ══════════════════════════════════════════════════════════════════════════════

# Each web app is defined as (label, url).
# The label is what rofi displays. The url is what gets opened.
#
WEB_APPS: list[tuple[str, str]] = [
    ("  ✉️  Gmail",            "https://mail.google.com"),
    ("  💬  WhatsApp",         "https://web.whatsapp.com"),
    ("  📺  YouTube",          "https://www.youtube.com"),
    ("  🍿  Netflix",          "https://www.netflix.com/browse"),
    ("  🤖  Arena.ai",         "https://arena.ai/"),
    ("  🧮  Big Naughty list", "http://192.168.1.46:1313/maths/bignaughtylist/"),
    ("  💵  Bankinter",        "https://bancaonline.bankinter.com/gestion/login.xhtml"),
    ("  📡  Send Anywhere",     "https://send-anywhere.com"),
]

def web_apps() -> None:
    """
    Display a rofi menu of web applications and launch the selected one.

    Each entry in WEB_APPS is a (label, url) pair. When the user selects a label,
    we open the corresponding URL using: google-chrome-stable --app=URL

    Chrome’s “app mode” creates a standalone PWA-style window:
        • no address bar
        • no tabs
        • no bookmarks bar
        • its own window in the task switcher / overview

    WHY we use _launch() for most external programs, but NOT for Chrome here:
        `_launch()` detaches the child process (start_new_session=True) and returns
        immediately. That’s normally ideal for GUI apps. However, Chrome can receive
        a SIGHUP during the brief moment when Python exits and the new session is
        still being established. The result: Chrome starts, flashes, and dies.

        To avoid this problem entirely, we use os.execvp():
            • execvp() REPLACES the Python process with Chrome.
            • There is no parent/child relationship.
            • No SIGHUP, no handoff, no timing window.
            • The PID of this script becomes Chrome’s PID.
        This is the correct pattern for “launch one thing and then exit” scripts
        (And exec never returns, so nothing should follow it.)

    WHY we strip whitespace when matching:
        Our labels include leading spaces for visual padding (e.g. "  ✉️  Gmail").
        rofi strips leading/trailing whitespace from its output, even with -format s.
        Comparing label.strip() to selected.strip() makes the match whitespace-agnostic.

    Overall flow:
        1. Show rofi menu of labels.
        2. User selects a label (or presses Escape).
        3. Find the matching URL.
        4. Replace this Python process with Chrome in app mode.

    This provides a clean, predictable, PWA-like launcher for web apps.
    """
    labels = [label for label, _ in WEB_APPS]

    selected = _rofi_menu(labels, prompt="  Web App")

    if selected is None:
        # User pressed Escape — exit cleanly, nothing to do
        sys.exit(0)

    # Find the url that matches the selected label, ignoring leading spaces in labels
    # WHY .strip() on both sides:
    #   rofi strips leading/trailing whitespace from the string it returns,
    #   even when -format s is used. Our labels have leading spaces for visual
    #   padding (e.g. "  ✉️  Gmail"), but rofi returns "✉️  Gmail".
    #   Stripping both sides makes the match whitespace-agnostic.
    url = next(
        (url for label, url in WEB_APPS if label.strip() == selected.strip()),
        None,
    )

    if url is None:
        # Should never happen since -no-custom prevents arbitrary input
        print(f"myrofi_tools: unknown app selection: {selected!r}", file=sys.stderr)
        sys.exit(1)

    # Open as a standalone PWA window — no tabs, no address bar.
    # os.execvp() REPLACES the current Python process with Chrome in app mode.
    os.execvp(BROWSER, [BROWSER, url])

# ── CLI entry point ───────────────────────────────────────────────────────────

ACTIONS: dict[str, callable] = {
    "--xkill":    xkill_window,
    "--web-apps": web_apps,
    "--hotkeys": show_hotkeys,
}


def main() -> None:
    """
    Dispatch to the correct tool based on the CLI flag.

    Called from rofi-launcher.sh:
        29) python3 ~/dotfiles/scripts/myrofi_tools.py --xkill    ;;
         6) python3 ~/dotfiles/scripts/myrofi_tools.py --web-apps ;;
         30) python3 ~/dotfiles/scripts/myrofi_tools.py --hotkeys ;;
    """
    flag = sys.argv[1] if len(sys.argv) > 1 else None

    if flag is None:
        print("Usage: myrofi_tools.py --xkill | --web-apps | --hotkeys", file=sys.stderr)
        print(f"Available: {', '.join(ACTIONS)}", file=sys.stderr)
        sys.exit(1)

    if flag not in ACTIONS:
        print(f"myrofi_tools: unknown flag: {flag!r}", file=sys.stderr)
        print(f"Available: {', '.join(ACTIONS)}", file=sys.stderr)
        sys.exit(1)

    ACTIONS[flag]()

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.