JustToThePoint English Website Version
JustToThePoint en español

Rofi Terminal Automation: Python Script for Workspace and Process Management

Life is a math equation. In order to gain the most, you have to know how to convert negatives into positives, Anonymous

Topology and Limits

Introduction

In the pursuit of a seamless desktop workflow, launching terminal windows to perform specific tasks —like opening an SSH session, running a script on a remote server, or simply moving to a specific workspace —often requires complex Bash workarounds. These scripts can become brittle, struggling with shell quoting, background process management, and keeping the window open after a command completes.

Enter myrofi_terminal.py, a robust Python utility designed to replace these fragile Bash patterns. This script introduces a unified launch_terminal function that intelligently handles three common scenarios: plain interactive terminals, commands that require the window to stay open (via a . By leveraging Python’s subprocess and tempfile modules, it solves the pain of command escaping and ensures proper process detachment, while also integrating with the Niri compositor to switch workspaces before the terminal appears.

#!/usr/bin/env python3
"""
myrofi_terminal.py — Terminal launcher for rofi scripts
────────────────────────────────────────────────────────
Provides launch_terminal(command) which replicates three bash patterns:

  PATTERN A — plain detached terminal (no command):
    _goto_workspace
    sleep 0.1
    alacritty &

  PATTERN B — terminal that runs a command and waits (run-in-term):
    tmpscript=$(mktemp)
    printf '%s' "$cmd" > "$tmpscript"
    printf 'read -rp "Press Enter to close..."' >> "$tmpscript"
    alacritty --title "..." -e bash "$tmpscript" &

  PATTERN C — terminal running an SSH session that stays open  :
    alacritty --title "SSH · user@host" -e bash -c "ssh -X user@host; exec bash" &

All three collapse into one function:
    launch_terminal()                          # plain terminal
    launch_terminal("ssh -X user@host")        # SSH session, keeps shell after
    launch_terminal("python script.py",        # command + wait-for-enter footer
                    title="My Script",
                    wait=True)

Usage from rofi-launcher.sh:
    # Plain terminal
    9)  python3 ~/dotfiles/scripts/myrofi_terminal.py ;;

    # SSH session
    17) python3 ~/dotfiles/scripts/myrofi_terminal.py --ssh ;;

    # Run a remote script and wait
    18) python3 ~/dotfiles/scripts/myrofi_terminal.py --check-links ;;

Or import from another Python rofi script:
    from myrofi_terminal import launch_terminal
    launch_terminal("python greetings.py", title="Greetings", wait=True)
"""

import subprocess
import tempfile
import time
import os
import sys
import stat


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

TERMINAL       = "alacritty"
TERMINAL_FONT  = "Hack"
TERMINAL_SIZE  = 14          # font size in points

USER           = "nmaximo7"
SERVER         = "192.168.1.49"
PYTHON_DIR     = "/home/nmaximo7/myNewPython"
PYTHON_LOCAL   = "/mnt/data/justtothepoint/mypython"
SCRIPTS_DIR    = f"/home/{USER}/dotfiles/myArch/admin"

TERM_WORKSPACE = "1"
WORKSPACE_SETTLE_DELAY = 0.15   # seconds to wait after workspace switch


# ── Workspace helper ──────────────────────────────────────────────────────────

def goto_workspace(workspace: str = TERM_WORKSPACE) -> None:
    """
    Switch to a workspace in the Niri compositor.
    Niri uses 0‑based indices: workspace "1" → index 0.
    If the `niri` command is not available, this does nothing (prints a warning).
    """

    workspace = int(workspace) - 1

    subprocess.run(
        ["niri", "msg", "action", "workspace-focus", str(workspace)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False, # Don't crash if niri fails (e.g., not running)
    )


# ── Core launcher ─────────────────────────────────────────────────────────────

def launch_terminal(
    command: str | None = None,
    *,
    title: str = "Terminal",
    wait: bool = False,
    workspace: str = TERM_WORKSPACE,
) -> None:
    """
    Open a terminal window, optionally running a shell command inside it.

    Args:
        command:   Shell command string to run inside the terminal.
                   None → opens an interactive shell (plain alacritty).
                   String → passed to  bash -c "command"  inside alacritty.

        title:     Window title shown in the window title bar.

        wait:      When True, append a "Press Enter to close..." footer so
                   the terminal window stays open after the command finishes.
                   Mirrors the  _run_in_term  bash helper.
                   Ignored when command is None (interactive shell never needs it).

        workspace: If False, skip workspace switching entirely.

    Implementation notes
    ────────────────────
    WHY a temp script file instead of  alacritty -e bash -c "...":
        Long or multi-line commands passed via -c require careful shell
        quoting. Every quote, dollar sign, and backslash must be escaped.
        Writing the command to a temp file avoids ALL quoting — the file
        content is verbatim, no shell interpretation of the string happens.
        This is exactly what _run_in_term does in bash.

    WHY start_new_session=True:
        alacritty stays in our process group without this. When rofi exits
        it holds an X11 keyboard grab; if alacritty is in rofi's process
        group, X11 can leave the grab dangling. start_new_session=True calls
        os.setsid() in the child before exec — equivalent to bash ( cmd & ).

    WHY Popen instead of run:
        subprocess.run() blocks until the child exits.
        subprocess.Popen() returns immediately — the terminal runs
        independently. This mirrors bash's  &  background operator.

    WHY the temp file is NOT deleted here:
        The temp file must outlive this Python script. We exit immediately
        after Popen(). The terminal reads the file after we are gone.
        We append  rm -f "$file"  as the last line of the script so the
        terminal cleans it up after the user presses Enter to close.
    """
    # Step 1 — switch workspace if requested
    goto_workspace(workspace)
    time.sleep(WORKSPACE_SETTLE_DELAY)

    # Step 2 — build the alacritty command list
    if command is None:
        # ── PATTERN A: plain interactive terminal ─────────────────────────────
        # alacritty with no -e flag opens a shell directly.
        alacritty_cmd = [
            TERMINAL,
            "--title", title,
        ]

    else:
        # ── PATTERN B / C: run a command, optionally wait for keypress ────────
        #
        # Write the command (and optional footer) to a temp file.
        # NamedTemporaryFile with delete=False keeps the file on disk after
        # we close it — Popen will read it after this script exits.
        #
        tmp = tempfile.NamedTemporaryFile(
            mode="w",
            prefix="rofi-term-",
            suffix=".sh",
            delete=False,       # ← do NOT delete when we close() it
        )

        # Write shebang + the actual command
        tmp.write("#!/usr/bin/env bash\n")
        tmp.write(f"{command}\n")

        if wait:
            # Append a visual separator and a blocking read so the window
            # stays open after the command finishes — user can read output.
            tmp.write('\necho\necho "─── done ───"\n')
            tmp.write('read -rp "Press Enter to close..."\n')

        # Self-destruct: the script deletes itself as its very last action.
        tmp.write(f'rm -f "{tmp.name}"\n')
        tmp.close()

        # Make the temp file executable so bash can run it directly
        os.chmod(tmp.name, os.stat(tmp.name).st_mode | stat.S_IEXEC)

        alacritty_cmd = [
            TERMINAL,
            "--title", title,
            "-e", "bash", tmp.name,   # alacritty runs: bash /tmp/rofi-term-XXXX.sh
        ]

    # Step 3 — spawn the terminal, fully detached from our process group
    subprocess.Popen(
        alacritty_cmd,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,     # os.setsid() in child — releases X11 grab
    )
    # Popen returns immediately. Terminal runs independently.
    # This Python script exits now, rofi's grab is released cleanly.


# ── Pre-built actions ─────────────────────────────────────────────────────────
# Each function composes the right launch_terminal() call for a specific task.
# These are what rofi-launcher.sh calls via  python3 myrofi_terminal.py --flag.

def open_terminal() -> None:
    """Plain interactive Alacritty on the terminal workspace."""
    launch_terminal(
        command=None,
        title="Terminal",
    )


def open_ssh() -> None:
    """
    SSH session to SERVER.
    Mirrors the bash pattern:
        alacritty --title "SSH · user@host" \
          -e bash -c "ssh -X user@host; exec bash" &

    WHY  ssh ... ; exec bash:
        After the SSH connection closes (logout or network drop), the
        terminal would immediately close too — no time to read the last
        output. The trailing  ; exec bash  replaces the ssh process with
        an interactive bash shell, keeping the window open.
    """
    launch_terminal(
        command=f"ssh -X {USER}@{SERVER}; exec bash",
        title=f"SSH · {USER}@{SERVER}",
        wait=False,     # exec bash keeps the window open itself
    )


def _remote_python(script_name: str, title: str) -> None:
    """
    Helper: SSH to SERVER, activate a nix environment, run a Python script, then wait.
    Mirrors _run_in_term with an SSH + nix-shell command.

    Args:
        script_name: Filename of the Python script in PYTHON_DIR on the server.
        title:       Window title.
    """
    # The command is a single bash string that:
    #   1. SSHes to the server
    #   2. Changes directory and uses nix-shell to execute the python script.
    # Using ssh -t forces a pseudo-TTY so colours and interactive output work.
    command = (
        f"ssh -t {USER}@{SERVER} "
        f"'cd {PYTHON_DIR} && nix-shell && python {script_name}'"
        # That works for Arch
        # f"'cd {PYTHON_DIR} && source venv/bin/activate && python {script_name}'"
    )
    launch_terminal(command=command, title=title, wait=True)

def _local_python(script_name: str, title: str) -> None:
    """
    Helper: Activate a local venv, run a Python script, then wait.

    Args:
        script_name: Filename of the Python script in PYTHON_DIR on the server.
        title:       Window title.
    """
    # The command is a single bash string that runs a here-string as a remote shell command (cd + venv + python)
    command = (
        f"cd {PYTHON_LOCAL} && source venv/bin/activate && python3 {script_name}"
    )
    launch_terminal(command=command, title=title, wait=True)


def check_links() -> None:
    """Run link_checker.py locally and keep window open to read results."""
    _local_python("link_checker.py", f"Check Links · {SERVER}")


def greetings() -> None:
    """Run greetings.py on the server."""
    _remote_python("greetings.py", f"Greetings · {SERVER}")


def deploy() -> None:
    """
    Run deploy.sh locally, keep window open.
    Mirrors:
        _run_in_term "Deploy" "sh deploy.sh"
    """
    command = (
        f"sh {SCRIPTS_DIR}/deploy.sh"
    )
    launch_terminal(command=command, title="Deploy", wait=True)

# ── CLI entry point ───────────────────────────────────────────────────────────
#
# rofi-launcher.sh calls this script with a --flag argument:
#
#   9)  python3 ~/dotfiles/scripts/myrofi_terminal.py
#   17) python3 ~/dotfiles/scripts/myrofi_terminal.py --ssh
#   18) python3 ~/dotfiles/scripts/myrofi_terminal.py --check-links
#   20) python3 ~/dotfiles/scripts/myrofi_terminal.py --greetings
#   21) python3 ~/dotfiles/scripts/myrofi_terminal.py --deploy
#
# Mapping from flag → function:
ACTIONS = {
    "--ssh":           open_ssh,
    "--check-links":   check_links,
    "--greetings":     greetings,
    "--deploy":        deploy,
}

def main() -> None:
    flag = sys.argv[1] if len(sys.argv) > 1 else None

    if flag is None:
        # No argument → plain terminal
        open_terminal()
    elif flag in ACTIONS:
        ACTIONS[flag]()
    else:
        print(f"myrofi_terminal: unknown flag: {flag}", file=sys.stderr)
        print(f"Valid flags: {', '.join(ACTIONS)}", file=sys.stderr)
        sys.exit(1)


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.