Guest User

HailMarry

a guest
Sep 11th, 2026
29
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 54.70 KB | None | 0 0
  1. #!/usr/bin/env python3
  2.  
  3. import argparse
  4. import os
  5. import re
  6. import shutil
  7. import stat
  8. import subprocess
  9. import sys
  10. import tempfile
  11. import time
  12.  
  13.  
  14. # =============================================================
  15. # GENERAL SETTINGS
  16. # =============================================================
  17.  
  18. # Initial xterm geometry before it gets repositioned/resized
  19. # into its grid cell. Doesn't need to be precise - it's just a
  20. # starting size.
  21. WINDOW_GEOMETRY = "80x24"
  22.  
  23. FONT = "Monospace"
  24.  
  25. FONT_SIZE = "11"
  26.  
  27. # Delay between launching terminals.
  28. #
  29. # 0 = launch all immediately
  30. #
  31. LAUNCH_DELAY = 0.25
  32.  
  33.  
  34. # =============================================================
  35. # GRID / TILING SETTINGS
  36. # =============================================================
  37.  
  38. # Rows x Cols must have at least as many cells as terminals (6).
  39. # 2x3 gives six equal-sized tiles.
  40. GRID_ROWS = 2
  41. GRID_COLS = 3
  42.  
  43. # Pixel gap between tiles (also applied around window edges).
  44. GRID_GAP = 6
  45.  
  46. # Reserve space (in pixels) at the top/bottom for taskbars,
  47. # panels, etc. Adjust to taste for your window manager.
  48. SCREEN_MARGIN_TOP = 0
  49. SCREEN_MARGIN_BOTTOM = 0
  50.  
  51. # How long (seconds) and how often to poll while waiting for a
  52. # freshly-launched xterm's window to appear before positioning
  53. # it.
  54. POSITION_ATTEMPTS = 30
  55. POSITION_DELAY = 0.15
  56.  
  57.  
  58. # =============================================================
  59. # WPScan SETTINGS
  60. # =============================================================
  61.  
  62. # The API token can be supplied through the environment:
  63. #
  64. #     export WPSCAN_API_TOKEN="your-token"
  65. #
  66. # If the environment variable isn't present, the token below
  67. # will be used.
  68. #
  69. WPSCAN_API_TOKEN = os.environ.get(
  70.     "WPSCAN_API_TOKEN",
  71.     "APICODEHERE"
  72. )
  73.  
  74.  
  75. # =============================================================
  76.  
  77. # =============================================================
  78. # TOOL INSTALLATION SETTINGS
  79. # =============================================================
  80.  
  81. # Set to False if you only want the script to report missing tools.
  82. AUTO_INSTALL_TOOLS = True
  83.  
  84. # apt packages used by this launcher.
  85. APT_PACKAGES = {
  86.     "xterm": "xterm",
  87.     "xdotool": "xdotool",
  88.     "xrandr": "x11-xserver-utils",
  89.     "curl": "curl",
  90.     "unzip": "unzip",
  91.     "sudo": "sudo",
  92.     "wpscan": "wpscan",
  93.     "sqlmap": "sqlmap",
  94.     "nikto": "nikto",
  95.     "nmap": "nmap",
  96.     "python3": "python3",
  97. }
  98.  
  99. # Nuclei is normally easiest to install from its official release
  100. # when it is not available through apt.
  101. NUCLEI_INSTALL_PATH = "/usr/local/bin/nuclei"
  102.  
  103. # Findomain is installed from its GitHub release when absent.
  104. FINDOMAIN_INSTALL_PATH = "/usr/local/bin/findomain"
  105.  
  106.  
  107. # =============================================================
  108. # TERMINAL 1
  109. # =============================================================
  110.  
  111. TERMINAL_1 = {
  112.     "key": "wpscan",
  113.     "title": "WPScan",
  114.  
  115.     "command": (
  116.         "wpscan "
  117.         "--proxy http://127.0.0.1:8080 "
  118.         "--rua "
  119.         "--api-token \"$WPSCAN_API_TOKEN\" "
  120.         "--url {host}"
  121.     ),
  122.  
  123.     "background": "#000000",
  124.     "foreground": "#00ff00",
  125. }
  126.  
  127.  
  128. # =============================================================
  129. # TERMINAL 2
  130. # =============================================================
  131.  
  132. TERMINAL_2 = {
  133.     "key": "sqlmap",
  134.     "title": "SQLMap",
  135.  
  136.     "command": (
  137.         "python3 /media/veracrypt1/sqlmap/sqlmap.py "
  138.         "--beep "
  139.         "--eta "
  140.         "--batch "
  141.         "--random-agent "
  142.         "--threads=5 "
  143.         "--tamper=space2comment,randomcase,charencode "
  144.         "--crawl=5 "
  145.         "--risk=3 "
  146.         "--level=5 "
  147.         "--dump-all "
  148.         "-o "
  149.         "--forms "
  150.         "--proxy=http://127.0.0.1:8080 "
  151.         "-u {host}"
  152.     ),
  153.  
  154.     "background": "#000000",
  155.     "foreground": "#00ffff",
  156. }
  157.  
  158.  
  159. # =============================================================
  160. # TERMINAL 3
  161. # =============================================================
  162.  
  163. TERMINAL_3 = {
  164.     "key": "nikto",
  165.     "title": "Nikto",
  166.  
  167.     "command": (
  168.         "nikto "
  169.         "-Tuning 1,2,3,4,5,7,8,9,0,a,b,c "
  170.         "-output nikto.txt "
  171.         "-mutate 2,3,4,5 "
  172.         "-Display 1,2,3,4,V "
  173.         "-ask auto "
  174.         "-host {host}"
  175.     ),
  176.  
  177.     "background": "#000000",
  178.     "foreground": "#ffff00",
  179. }
  180.  
  181.  
  182. # =============================================================
  183. # TERMINAL 4
  184. # =============================================================
  185.  
  186. TERMINAL_4 = {
  187.     "key": "nmap",
  188.     "title": "NMap",
  189.  
  190.     "command": (
  191.         "nmap "
  192.         "-sC "
  193.         "-sV "
  194.         "-oG \"nmap log\" "
  195.         "{host}"
  196.     ),
  197.  
  198.     "background": "#000000",
  199.     "foreground": "#ff00ff",
  200. }
  201.  
  202.  
  203. # =============================================================
  204. # TERMINAL 5
  205. # =============================================================
  206.  
  207. TERMINAL_5 = {
  208.     "key": "nuclei",
  209.     "title": "Nuclei",
  210.  
  211.     "command": (
  212.         "nuclei "
  213.         "-target {host}"
  214.     ),
  215.  
  216.     "background": "#000000",
  217.     "foreground": "#ffffff",
  218. }
  219.  
  220.  
  221. # =============================================================
  222. # TERMINAL 6
  223. # FINDOMAIN
  224. # =============================================================
  225.  
  226. TERMINAL_6 = {
  227.     "key": "findomain",
  228.     "title": "Findomain",
  229.  
  230.     "command": (
  231.         "findomain -t {host}"
  232.     ),
  233.  
  234.     "background": "#000000",
  235.     "foreground": "#ff6600",
  236. }
  237.  
  238.  
  239. # =============================================================
  240. # TERMINAL LIST
  241. # =============================================================
  242.  
  243. TERMINALS = [
  244.     TERMINAL_1,
  245.     TERMINAL_2,
  246.     TERMINAL_3,
  247.     TERMINAL_4,
  248.     TERMINAL_5,
  249.     TERMINAL_6,
  250. ]
  251.  
  252.  
  253. # =============================================================
  254. # ARGUMENT PARSER
  255. # =============================================================
  256.  
  257. def parse_arguments():
  258.  
  259.     parser = argparse.ArgumentParser(
  260.         description=(
  261.             "Check/install dependencies and launch six independent xterm windows "
  262.             "using a supplied host."
  263.         )
  264.     )
  265.  
  266.     parser.add_argument(
  267.         "host",
  268.         nargs="?",
  269.         default=None,
  270.         help="Target hostname or URL."
  271.     )
  272.  
  273.     parser.add_argument(
  274.         "--monitor",
  275.         default=None,
  276.         help=(
  277.             "Force a specific monitor instead of "
  278.             "auto-detecting one. Accepts either the monitor's "
  279.             "index (0-based, in the order `xrandr --query` "
  280.             "lists connected monitors) or its xrandr name "
  281.             "(e.g. HDMI-1, eDP-1, DP-2). Run with "
  282.             "--list-monitors to see the available values."
  283.         )
  284.     )
  285.  
  286.     parser.add_argument(
  287.         "--list-monitors",
  288.         action="store_true",
  289.         help=(
  290.             "Print detected monitors (with the index/name "
  291.             "you can pass to --monitor) and exit without "
  292.             "launching anything."
  293.         )
  294.     )
  295.  
  296.     parser.add_argument(
  297.         "--terminals",
  298.         default=None,
  299.         help=(
  300.             "Comma-separated list of terminals to launch, "
  301.             "instead of all six. Accepts either 1-based index "
  302.             "(in the order below) or name, case-insensitive: "
  303.             "1=wpscan, 2=sqlmap, 3=nikto, 4=nmap, 5=nuclei, "
  304.             "6=findomain. Example: --terminals nmap,nuclei or "
  305.             "--terminals 4,5. If omitted, all six run. Run "
  306.             "with --list-terminals to see the available "
  307.             "values."
  308.         )
  309.     )
  310.  
  311.     parser.add_argument(
  312.         "--list-terminals",
  313.         action="store_true",
  314.         help=(
  315.             "Print available terminals (with the index/name "
  316.             "you can pass to --terminals) and exit without "
  317.             "launching anything."
  318.         )
  319.     )
  320.  
  321.     args = parser.parse_args()
  322.  
  323.     skip_host_requirement = (
  324.         args.list_monitors
  325.         or args.list_terminals
  326.     )
  327.  
  328.     if not skip_host_requirement and not args.host:
  329.         parser.error(
  330.             "the following arguments are required: host "
  331.             "(unless --list-monitors or --list-terminals is "
  332.             "given)"
  333.         )
  334.  
  335.     return args
  336.  
  337.  
  338. # =============================================================
  339. # NORMALIZE HOST
  340. # =============================================================
  341.  
  342. def normalize_host(host):
  343.  
  344.     """
  345.    Remove http:// or https:// from the supplied host.
  346.  
  347.    Examples:
  348.  
  349.        https://example.com
  350.            ->
  351.        example.com
  352.  
  353.        http://example.com/test
  354.            ->
  355.        example.com/test
  356.  
  357.        example.com
  358.            ->
  359.        example.com
  360.    """
  361.  
  362.     host = host.strip()
  363.  
  364.     host = re.sub(
  365.         r"^https?://",
  366.         "",
  367.         host,
  368.         flags=re.IGNORECASE
  369.     )
  370.  
  371.     if host.startswith("//"):
  372.         host = host[2:]
  373.  
  374.     host = host.rstrip("/")
  375.  
  376.     return host
  377.  
  378.  
  379. # =============================================================
  380. # CHECK COMMAND
  381. # =============================================================
  382.  
  383. def command_exists(command):
  384.  
  385.     """
  386.    Determine whether an executable is available.
  387.    """
  388.  
  389.     # Handle absolute paths.
  390.     if os.path.isabs(command):
  391.  
  392.         return (
  393.             os.path.isfile(command)
  394.             and os.access(command, os.X_OK)
  395.         )
  396.  
  397.     return shutil.which(command) is not None
  398.  
  399.  
  400. # =============================================================
  401. # TOOL CHECK / INSTALL HELPERS
  402. # =============================================================
  403.  
  404. def run_command(command, check=False, **kwargs):
  405.     """Run a command while keeping output visible to the user."""
  406.     return subprocess.run(command, check=check, **kwargs)
  407.  
  408.  
  409. def apt_update():
  410.     """Refresh apt package indexes once."""
  411.     print()
  412.     print("[*] Updating apt package indexes...")
  413.     try:
  414.         run_command(["sudo", "apt-get", "update"], check=True)
  415.         return True
  416.     except (subprocess.CalledProcessError, FileNotFoundError) as error:
  417.         print(f"[ERROR] apt update failed: {error}")
  418.         return False
  419.  
  420.  
  421. def install_apt_packages(packages):
  422.     """Install missing Debian/Ubuntu/Mint packages in one apt call."""
  423.     if not packages:
  424.         return True
  425.  
  426.     if not command_exists("sudo"):
  427.         print("[ERROR] sudo is required for automatic package installation.")
  428.         return False
  429.  
  430.     if not apt_update():
  431.         return False
  432.  
  433.     print()
  434.     print("[*] Installing missing apt packages:")
  435.     print("    " + " ".join(packages))
  436.     print()
  437.  
  438.     try:
  439.         run_command(
  440.             ["sudo", "apt-get", "install", "-y", *packages],
  441.             check=True,
  442.         )
  443.         return True
  444.     except subprocess.CalledProcessError as error:
  445.         print(f"[ERROR] apt installation failed: {error}")
  446.         return False
  447.  
  448.  
  449. def get_missing_commands(commands):
  450.     """Return commands that are not currently available."""
  451.     return [
  452.         command
  453.         for command in commands
  454.         if not command_exists(command)
  455.     ]
  456.  
  457.  
  458. def install_nuclei():
  459.     """
  460.    Install Nuclei from the latest ProjectDiscovery GitHub release.
  461.  
  462.    The release API is used to select the correct Linux architecture.
  463.    """
  464.     if command_exists("nuclei"):
  465.         return True
  466.  
  467.     print()
  468.     print("[*] Nuclei was not found - attempting automatic installation...")
  469.  
  470.     if not command_exists("curl"):
  471.         print("[ERROR] curl is required before Nuclei can be installed.")
  472.         return False
  473.  
  474.     machine = subprocess.check_output(
  475.         ["uname", "-m"], text=True
  476.     ).strip()
  477.  
  478.     architecture_map = {
  479.         "x86_64": "amd64",
  480.         "amd64": "amd64",
  481.         "aarch64": "arm64",
  482.         "arm64": "arm64",
  483.         "armv7l": "armv7",
  484.     }
  485.  
  486.     nuclei_arch = architecture_map.get(machine)
  487.     if not nuclei_arch:
  488.         print(f"[ERROR] Unsupported CPU architecture for Nuclei: {machine}")
  489.         return False
  490.  
  491.     with tempfile.TemporaryDirectory(prefix="nuclei-install-") as tmp_dir:
  492.         try:
  493.             api_url = (
  494.                 "https://api.github.com/repos/projectdiscovery/"
  495.                 "nuclei/releases/latest"
  496.             )
  497.  
  498.             metadata_path = os.path.join(tmp_dir, "release.json")
  499.  
  500.             run_command(
  501.                 ["curl", "-fsSL", api_url, "-o", metadata_path],
  502.                 check=True,
  503.             )
  504.  
  505.             import json
  506.  
  507.             with open(metadata_path, "r", encoding="utf-8") as file:
  508.                 release = json.load(file)
  509.  
  510.             asset = None
  511.             for candidate in release.get("assets", []):
  512.                 name = candidate.get("name", "")
  513.                 if (
  514.                     name.endswith(".zip")
  515.                     and "linux" in name.lower()
  516.                     and nuclei_arch in name.lower()
  517.                 ):
  518.                     asset = candidate
  519.                     break
  520.  
  521.             if not asset:
  522.                 print(
  523.                     "[ERROR] Could not find a matching Nuclei release "
  524.                     f"asset for Linux/{nuclei_arch}."
  525.                 )
  526.                 return False
  527.  
  528.             archive_name = asset["name"]
  529.             archive_path = os.path.join(tmp_dir, archive_name)
  530.  
  531.             print(f"[*] Downloading Nuclei: {archive_name}")
  532.  
  533.             run_command(
  534.                 ["curl", "-fL", asset["browser_download_url"],
  535.                  "-o", archive_path],
  536.                 check=True,
  537.             )
  538.  
  539.             run_command(
  540.                 ["unzip", "-o", archive_name],
  541.                 cwd=tmp_dir,
  542.                 check=True,
  543.             )
  544.  
  545.             binary_path = os.path.join(tmp_dir, "nuclei")
  546.  
  547.             if not os.path.isfile(binary_path):
  548.                 print("[ERROR] Nuclei binary was not found after extraction.")
  549.                 return False
  550.  
  551.             os.chmod(
  552.                 binary_path,
  553.                 os.stat(binary_path).st_mode
  554.                 | stat.S_IXUSR
  555.                 | stat.S_IXGRP
  556.                 | stat.S_IXOTH,
  557.             )
  558.  
  559.             print(f"[*] Installing Nuclei to {NUCLEI_INSTALL_PATH}")
  560.  
  561.             run_command(
  562.                 ["sudo", "install", "-m", "0755",
  563.                  binary_path, NUCLEI_INSTALL_PATH],
  564.                 check=True,
  565.             )
  566.  
  567.         except (subprocess.CalledProcessError, OSError, ValueError) as error:
  568.             print(f"[ERROR] Nuclei installation failed: {error}")
  569.             return False
  570.  
  571.     if command_exists("nuclei"):
  572.         print("[OK] Nuclei installed successfully.")
  573.         return True
  574.  
  575.     print("[ERROR] Nuclei is still unavailable after installation.")
  576.     return False
  577.  
  578.  
  579. def install_findomain():
  580.     """
  581.    Install Findomain from its latest GitHub release.
  582.  
  583.    The release API is used so the correct architecture is selected
  584.    instead of assuming an i386 machine.
  585.    """
  586.     if command_exists("findomain"):
  587.         return True
  588.  
  589.     print()
  590.     print("[*] Findomain was not found - attempting automatic installation...")
  591.  
  592.     if not command_exists("curl"):
  593.         print("[ERROR] curl is required before Findomain can be installed.")
  594.         return False
  595.  
  596.     machine = subprocess.check_output(
  597.         ["uname", "-m"], text=True
  598.     ).strip()
  599.  
  600.     architecture_map = {
  601.         "x86_64": "amd64",
  602.         "amd64": "amd64",
  603.         "aarch64": "arm64",
  604.         "arm64": "arm64",
  605.         "i686": "i386",
  606.         "i386": "i386",
  607.     }
  608.  
  609.     findomain_arch = architecture_map.get(machine)
  610.     if not findomain_arch:
  611.         print(
  612.             "[ERROR] Unsupported CPU architecture for Findomain: "
  613.             f"{machine}"
  614.         )
  615.         return False
  616.  
  617.     with tempfile.TemporaryDirectory(prefix="findomain-install-") as tmp_dir:
  618.         try:
  619.             api_url = (
  620.                 "https://api.github.com/repos/Findomain/Findomain/"
  621.                 "releases/latest"
  622.             )
  623.  
  624.             metadata_path = os.path.join(tmp_dir, "release.json")
  625.  
  626.             run_command(
  627.                 ["curl", "-fsSL", api_url, "-o", metadata_path],
  628.                 check=True,
  629.             )
  630.  
  631.             import json
  632.  
  633.             with open(metadata_path, "r", encoding="utf-8") as file:
  634.                 release = json.load(file)
  635.  
  636.             asset = None
  637.             for candidate in release.get("assets", []):
  638.                 name = candidate.get("name", "").lower()
  639.  
  640.                 if (
  641.                     "linux" in name
  642.                     and findomain_arch in name
  643.                     and name.endswith(".zip")
  644.                 ):
  645.                     asset = candidate
  646.                     break
  647.  
  648.             if not asset:
  649.                 print(
  650.                     "[ERROR] Could not find a matching Findomain release "
  651.                     f"asset for Linux/{findomain_arch}."
  652.                 )
  653.                 return False
  654.  
  655.             archive_name = asset["name"]
  656.             archive_path = os.path.join(tmp_dir, archive_name)
  657.  
  658.             print(f"[*] Downloading Findomain: {archive_name}")
  659.  
  660.             run_command(
  661.                 ["curl", "-fL", asset["browser_download_url"],
  662.                  "-o", archive_path],
  663.                 check=True,
  664.             )
  665.  
  666.             run_command(
  667.                 ["unzip", "-o", archive_name],
  668.                 cwd=tmp_dir,
  669.                 check=True,
  670.             )
  671.  
  672.             binary_path = os.path.join(tmp_dir, "findomain")
  673.  
  674.             if not os.path.isfile(binary_path):
  675.                 print("[ERROR] Findomain binary was not found after extraction.")
  676.                 return False
  677.  
  678.             os.chmod(
  679.                 binary_path,
  680.                 os.stat(binary_path).st_mode
  681.                 | stat.S_IXUSR
  682.                 | stat.S_IXGRP
  683.                 | stat.S_IXOTH,
  684.             )
  685.  
  686.             print(f"[*] Installing Findomain to {FINDOMAIN_INSTALL_PATH}")
  687.  
  688.             run_command(
  689.                 ["sudo", "install", "-m", "0755",
  690.                  binary_path, FINDOMAIN_INSTALL_PATH],
  691.                 check=True,
  692.             )
  693.  
  694.         except (subprocess.CalledProcessError, OSError, ValueError) as error:
  695.             print(f"[ERROR] Findomain installation failed: {error}")
  696.             return False
  697.  
  698.     if command_exists("findomain"):
  699.         print("[OK] Findomain installed successfully.")
  700.         return True
  701.  
  702.     print("[ERROR] Findomain is still unavailable after installation.")
  703.     return False
  704.  
  705.  
  706. def check_and_install_all_tools():
  707.     """
  708.    Check every external dependency used by this launcher.
  709.  
  710.    Missing apt-based tools are installed together. Nuclei and
  711.    Findomain are installed from their current GitHub releases.
  712.    """
  713.     print()
  714.     print("======================================================")
  715.     print(" Checking required tools")
  716.     print("======================================================")
  717.     print()
  718.  
  719.     # First make sure sudo itself exists. We cannot auto-install
  720.     # anything else without it.
  721.     if not command_exists("sudo"):
  722.         print("[ERROR] sudo is not available.")
  723.         print("Install sudo manually, then run this script again.")
  724.         return False
  725.  
  726.     apt_commands = [
  727.         command for command in APT_PACKAGES
  728.         if not command_exists(command)
  729.     ]
  730.  
  731.     if apt_commands:
  732.         apt_packages = [
  733.             APT_PACKAGES[command]
  734.             for command in apt_commands
  735.             if command != "sudo"
  736.         ]
  737.  
  738.         print("[*] Missing apt tools:")
  739.         print("    " + ", ".join(apt_commands))
  740.  
  741.         if AUTO_INSTALL_TOOLS:
  742.             if not install_apt_packages(apt_packages):
  743.                 return False
  744.         else:
  745.             print("[WARNING] Automatic installation is disabled.")
  746.     else:
  747.         print("[OK] All apt-based tools are already installed.")
  748.  
  749.     # Re-check after apt installation.
  750.     missing_after_apt = get_missing_commands(
  751.         list(APT_PACKAGES.keys())
  752.     )
  753.  
  754.     if missing_after_apt:
  755.         print()
  756.         print("[ERROR] Still missing after apt installation:")
  757.         print("    " + ", ".join(missing_after_apt))
  758.         return False
  759.  
  760.     if command_exists("nuclei"):
  761.         print("[OK] nuclei")
  762.     elif AUTO_INSTALL_TOOLS:
  763.         if not install_nuclei():
  764.             return False
  765.     else:
  766.         print("[MISSING] nuclei")
  767.  
  768.     if command_exists("findomain"):
  769.         print("[OK] findomain")
  770.     elif AUTO_INSTALL_TOOLS:
  771.         if not install_findomain():
  772.             return False
  773.     else:
  774.         print("[MISSING] findomain")
  775.  
  776.     final_required = [
  777.         "xterm",
  778.         "xdotool",
  779.         "xrandr",
  780.         "curl",
  781.         "unzip",
  782.         "sudo",
  783.         "python3",
  784.         "wpscan",
  785.         "sqlmap",
  786.         "nikto",
  787.         "nmap",
  788.         "nuclei",
  789.         "findomain",
  790.     ]
  791.  
  792.     missing_final = get_missing_commands(final_required)
  793.  
  794.     print()
  795.     if missing_final:
  796.         print("======================================================")
  797.         print(" TOOL CHECK FAILED")
  798.         print("======================================================")
  799.         print()
  800.         print("Missing:")
  801.         for tool in missing_final:
  802.             print(f"    - {tool}")
  803.         print()
  804.         return False
  805.  
  806.     print("======================================================")
  807.     print(" All required tools are available")
  808.     print("======================================================")
  809.     print()
  810.  
  811.     return True
  812.  
  813.  
  814. # =============================================================
  815. # CHECK XTERM
  816. # =============================================================
  817.  
  818. def check_xterm():
  819.  
  820.     if not command_exists("xterm"):
  821.  
  822.         print()
  823.         print("ERROR: xterm was not found.")
  824.         print()
  825.         print("Install it with:")
  826.         print()
  827.         print("    sudo apt install xterm")
  828.         print()
  829.  
  830.         sys.exit(1)
  831.  
  832.  
  833. # =============================================================
  834. # CHECK XDOTOOL
  835. # =============================================================
  836.  
  837. def check_xdotool():
  838.  
  839.     """
  840.    xdotool is used to move/resize windows into the grid after
  841.    launch. Tiling still works without it if xrandr also isn't
  842.    found, but windows will keep their default placement.
  843.    """
  844.  
  845.     if not command_exists("xdotool"):
  846.  
  847.         print()
  848.         print(
  849.             "WARNING: xdotool was not found. Terminals will "
  850.             "launch but will NOT be tiled into equal grid "
  851.             "cells."
  852.         )
  853.         print()
  854.         print("Install it with:")
  855.         print()
  856.         print("    sudo apt install xdotool")
  857.         print()
  858.  
  859.         return False
  860.  
  861.     return True
  862.  
  863.  
  864. # =============================================================
  865. # ENUMERATE MONITORS
  866. # =============================================================
  867.  
  868. def get_monitors():
  869.  
  870.     """
  871.    Enumerate connected monitors using xrandr.
  872.  
  873.    Returns a list of dicts:
  874.  
  875.        {
  876.            "name": "HDMI-1",
  877.            "width": 1920,
  878.            "height": 1080,
  879.            "x": 0,
  880.            "y": 0,
  881.            "primary": True
  882.        }
  883.  
  884.    Returns an empty list if xrandr isn't available or nothing
  885.    could be parsed.
  886.    """
  887.  
  888.     monitors = []
  889.  
  890.     if not command_exists("xrandr"):
  891.         return monitors
  892.  
  893.     try:
  894.         output = subprocess.check_output(
  895.             ["xrandr", "--query"],
  896.             text=True,
  897.             stderr=subprocess.DEVNULL
  898.         )
  899.     except Exception:
  900.         return monitors
  901.  
  902.     for line in output.splitlines():
  903.  
  904.         if " connected" not in line:
  905.             continue
  906.  
  907.         match = re.search(
  908.             r"(\d+)x(\d+)\+(\d+)\+(\d+)",
  909.             line
  910.         )
  911.  
  912.         if not match:
  913.             # Connected but currently disabled/no geometry
  914.             # (e.g. lid-closed laptop panel) - skip it.
  915.             continue
  916.  
  917.         width, height, x, y = (
  918.             int(value) for value in match.groups()
  919.         )
  920.  
  921.         monitors.append(
  922.             {
  923.                 "name": line.split()[0],
  924.                 "width": width,
  925.                 "height": height,
  926.                 "x": x,
  927.                 "y": y,
  928.                 "primary": " primary" in line,
  929.             }
  930.         )
  931.  
  932.     return monitors
  933.  
  934.  
  935. # =============================================================
  936. # GET ACTIVE WINDOW GEOMETRY
  937. # =============================================================
  938.  
  939. def get_active_window_geometry():
  940.  
  941.     """
  942.    Return (x, y, width, height) of the currently active window
  943.    (in absolute screen pixels) using xdotool, or None if it
  944.    can't be determined.
  945.  
  946.    Called early in main(), before any xterms are launched, so
  947.    this should be the terminal the script was run from.
  948.    """
  949.  
  950.     if not command_exists("xdotool"):
  951.         return None
  952.  
  953.     try:
  954.         result = subprocess.run(
  955.             [
  956.                 "xdotool",
  957.                 "getactivewindow",
  958.                 "getwindowgeometry",
  959.                 "--shell"
  960.             ],
  961.             capture_output=True,
  962.             text=True
  963.         )
  964.     except Exception:
  965.         return None
  966.  
  967.     if result.returncode != 0:
  968.         return None
  969.  
  970.     values = {}
  971.  
  972.     for line in result.stdout.splitlines():
  973.         if "=" in line:
  974.             key, _, value = line.partition("=")
  975.             values[key.strip()] = value.strip()
  976.  
  977.     try:
  978.         return (
  979.             int(values["X"]),
  980.             int(values["Y"]),
  981.             int(values["WIDTH"]),
  982.             int(values["HEIGHT"])
  983.         )
  984.     except (KeyError, ValueError):
  985.         return None
  986.  
  987.  
  988. # =============================================================
  989. # GET MOUSE POSITION
  990. # =============================================================
  991.  
  992. def get_mouse_position():
  993.  
  994.     """
  995.    Return (x, y) of the current mouse pointer in absolute
  996.    screen pixels using xdotool, or None if it can't be
  997.    determined.
  998.    """
  999.  
  1000.     if not command_exists("xdotool"):
  1001.         return None
  1002.  
  1003.     try:
  1004.         result = subprocess.run(
  1005.             ["xdotool", "getmouselocation", "--shell"],
  1006.             capture_output=True,
  1007.             text=True
  1008.         )
  1009.     except Exception:
  1010.         return None
  1011.  
  1012.     if result.returncode != 0:
  1013.         return None
  1014.  
  1015.     values = {}
  1016.  
  1017.     for line in result.stdout.splitlines():
  1018.         if "=" in line:
  1019.             key, _, value = line.partition("=")
  1020.             values[key.strip()] = value.strip()
  1021.  
  1022.     try:
  1023.         return int(values["X"]), int(values["Y"])
  1024.     except (KeyError, ValueError):
  1025.         return None
  1026.  
  1027.  
  1028. # =============================================================
  1029. # FIND MONITOR CONTAINING A POINT
  1030. # =============================================================
  1031.  
  1032. def find_monitor_for_point(monitors, point_x, point_y):
  1033.  
  1034.     for monitor in monitors:
  1035.  
  1036.         if (
  1037.             monitor["x"] <= point_x < monitor["x"] + monitor["width"]
  1038.             and
  1039.             monitor["y"] <= point_y < monitor["y"] + monitor["height"]
  1040.         ):
  1041.             return monitor
  1042.  
  1043.     return None
  1044.  
  1045.  
  1046. # =============================================================
  1047. # DETERMINE TARGET MONITOR
  1048. # =============================================================
  1049.  
  1050. def determine_target_monitor(xdotool_available, verbose=True):
  1051.  
  1052.     """
  1053.    Work out which monitor to tile the six terminals on: the
  1054.    one the launcher itself is currently running on.
  1055.  
  1056.    Resolution order:
  1057.  
  1058.        1. Active window's center point (the terminal running
  1059.           this script).
  1060.        2. Current mouse position.
  1061.        3. Monitor marked "primary" by xrandr.
  1062.        4. First monitor xrandr reports.
  1063.        5. Hardcoded 1920x1080 @ 0,0 if xrandr found nothing.
  1064.  
  1065.    When `verbose` is True, prints each step's raw result so
  1066.    you can see *why* a given monitor was picked - this is the
  1067.    thing to check if it picks the wrong one. If it's wrong,
  1068.    pass --monitor to force a specific one instead of relying
  1069.    on auto-detection.
  1070.    """
  1071.  
  1072.     def log(message):
  1073.         if verbose:
  1074.             print(f"    [monitor-detect] {message}")
  1075.  
  1076.     monitors = get_monitors()
  1077.  
  1078.     if verbose:
  1079.         print("Detecting target monitor...")
  1080.  
  1081.     if not monitors:
  1082.         log("xrandr reported no usable monitors.")
  1083.         return {
  1084.             "name": "fallback",
  1085.             "width": 1920,
  1086.             "height": 1080,
  1087.             "x": 0,
  1088.             "y": 0,
  1089.             "primary": True,
  1090.             "detected_via": "hardcoded fallback",
  1091.         }
  1092.  
  1093.     log(f"xrandr monitors: {monitors}")
  1094.  
  1095.     if len(monitors) == 1:
  1096.         monitor = dict(monitors[0])
  1097.         monitor["detected_via"] = "only monitor"
  1098.         log("Only one monitor detected, using it.")
  1099.         return monitor
  1100.  
  1101.     if not xdotool_available:
  1102.         log(
  1103.             "xdotool unavailable - can't check active window "
  1104.             "or mouse position."
  1105.         )
  1106.  
  1107.     if xdotool_available:
  1108.  
  1109.         geometry = get_active_window_geometry()
  1110.         log(f"Active window geometry (x, y, w, h): {geometry}")
  1111.  
  1112.         if geometry:
  1113.             x, y, width, height = geometry
  1114.             center_x = x + width // 2
  1115.             center_y = y + height // 2
  1116.  
  1117.             log(
  1118.                 f"Active window center point: "
  1119.                 f"({center_x}, {center_y})"
  1120.             )
  1121.  
  1122.             match = find_monitor_for_point(
  1123.                 monitors,
  1124.                 center_x,
  1125.                 center_y
  1126.             )
  1127.  
  1128.             if match:
  1129.                 monitor = dict(match)
  1130.                 monitor["detected_via"] = "active window"
  1131.                 log(
  1132.                     f"Center point falls on monitor "
  1133.                     f"'{match['name']}' - using it."
  1134.                 )
  1135.                 return monitor
  1136.  
  1137.             log(
  1138.                 "Active window center point didn't fall "
  1139.                 "inside any reported monitor bounds."
  1140.             )
  1141.         else:
  1142.             log(
  1143.                 "Couldn't read an active window (no window "
  1144.                 "manager support for _NET_ACTIVE_WINDOW, or "
  1145.                 "DISPLAY/XAUTHORITY not set correctly for "
  1146.                 "this process)."
  1147.             )
  1148.  
  1149.         position = get_mouse_position()
  1150.         log(f"Mouse position (x, y): {position}")
  1151.  
  1152.         if position:
  1153.             match = find_monitor_for_point(monitors, *position)
  1154.  
  1155.             if match:
  1156.                 monitor = dict(match)
  1157.                 monitor["detected_via"] = "mouse position"
  1158.                 log(
  1159.                     f"Mouse position falls on monitor "
  1160.                     f"'{match['name']}' - using it."
  1161.                 )
  1162.                 return monitor
  1163.  
  1164.             log(
  1165.                 "Mouse position didn't fall inside any "
  1166.                 "reported monitor bounds."
  1167.             )
  1168.  
  1169.     log(
  1170.         "Falling back to the primary/first monitor - this is "
  1171.         "likely NOT the monitor you're actually using. Pass "
  1172.         "--monitor to force the right one (see "
  1173.         "--list-monitors)."
  1174.     )
  1175.  
  1176.     for monitor in monitors:
  1177.         if monitor["primary"]:
  1178.             result = dict(monitor)
  1179.             result["detected_via"] = "primary flag"
  1180.             return result
  1181.  
  1182.     result = dict(monitors[0])
  1183.     result["detected_via"] = "first monitor"
  1184.     return result
  1185.  
  1186.  
  1187. # =============================================================
  1188. # RESOLVE MONITOR BY EXPLICIT SELECTOR (--monitor)
  1189. # =============================================================
  1190.  
  1191. def resolve_monitor_by_selector(selector, monitors):
  1192.  
  1193.     """
  1194.    Resolve a user-supplied --monitor value against the
  1195.    detected monitor list.
  1196.  
  1197.    Accepts either a 0-based index (in xrandr's listed order)
  1198.    or an xrandr output name (e.g. "HDMI-1"), matched case-
  1199.    insensitively.
  1200.  
  1201.    Returns the matching monitor dict (with "detected_via" set
  1202.    to "manual selection"), or None if it couldn't be resolved.
  1203.    """
  1204.  
  1205.     if not monitors:
  1206.         return None
  1207.  
  1208.     # Try as a numeric index first.
  1209.     try:
  1210.         index = int(selector)
  1211.     except ValueError:
  1212.         index = None
  1213.  
  1214.     if index is not None:
  1215.         if 0 <= index < len(monitors):
  1216.             monitor = dict(monitors[index])
  1217.             monitor["detected_via"] = "manual selection (index)"
  1218.             return monitor
  1219.         return None
  1220.  
  1221.     # Fall back to matching by name.
  1222.     for monitor in monitors:
  1223.         if monitor["name"].lower() == selector.lower():
  1224.             result = dict(monitor)
  1225.             result["detected_via"] = "manual selection (name)"
  1226.             return result
  1227.  
  1228.     return None
  1229.  
  1230.  
  1231. # =============================================================
  1232. # PRINT MONITOR LIST
  1233. # =============================================================
  1234.  
  1235. def print_monitor_list(monitors):
  1236.  
  1237.     if not monitors:
  1238.         print(
  1239.             "No monitors detected (xrandr missing, "
  1240.             "unavailable, or returned nothing usable)."
  1241.         )
  1242.         return
  1243.  
  1244.     print()
  1245.     print("Detected monitors:")
  1246.     print()
  1247.  
  1248.     for index, monitor in enumerate(monitors):
  1249.  
  1250.         primary_tag = " (primary)" if monitor["primary"] else ""
  1251.  
  1252.         print(
  1253.             f"    [{index}] {monitor['name']}{primary_tag}: "
  1254.             f"{monitor['width']}x{monitor['height']}"
  1255.             f"+{monitor['x']}+{monitor['y']}"
  1256.         )
  1257.  
  1258.     print()
  1259.     print(
  1260.         "Pass either the index or the name to --monitor, e.g.:"
  1261.     )
  1262.     print()
  1263.     print(
  1264.         f"    python3 {os.path.basename(sys.argv[0])} "
  1265.         f"--monitor {monitors[0]['name']} <host>"
  1266.     )
  1267.     print()
  1268.  
  1269.  
  1270.  
  1271. # =============================================================
  1272. # RESOLVE SELECTED TERMINALS (--terminals)
  1273. # =============================================================
  1274.  
  1275. def resolve_selected_terminals(selector_string, terminals):
  1276.  
  1277.     """
  1278.    Parse a comma-separated --terminals value into a list of
  1279.    terminal configs, preserving `terminals`' original order
  1280.    (not the order tokens were given in).
  1281.  
  1282.    Each token may be a 1-based index (in `terminals`' order)
  1283.    or a terminal's "key" (case-insensitive, e.g. "wpscan").
  1284.  
  1285.    Raises ValueError with a message listing any tokens that
  1286.    didn't match anything, for the caller to display alongside
  1287.    print_terminal_list().
  1288.    """
  1289.  
  1290.     tokens = [
  1291.         token.strip()
  1292.         for token in selector_string.split(",")
  1293.         if token.strip()
  1294.     ]
  1295.  
  1296.     if not tokens:
  1297.         raise ValueError("no terminals specified")
  1298.  
  1299.     keys = [config["key"] for config in terminals]
  1300.  
  1301.     selected_indices = set()
  1302.     unknown_tokens = []
  1303.  
  1304.     for token in tokens:
  1305.  
  1306.         if token.isdigit():
  1307.  
  1308.             index = int(token) - 1
  1309.  
  1310.             if 0 <= index < len(terminals):
  1311.                 selected_indices.add(index)
  1312.             else:
  1313.                 unknown_tokens.append(token)
  1314.  
  1315.             continue
  1316.  
  1317.         normalized = token.lower()
  1318.  
  1319.         if normalized in keys:
  1320.             selected_indices.add(keys.index(normalized))
  1321.         else:
  1322.             unknown_tokens.append(token)
  1323.  
  1324.     if unknown_tokens:
  1325.         raise ValueError(
  1326.             f"unrecognized terminal(s): {', '.join(unknown_tokens)}"
  1327.         )
  1328.  
  1329.     return [
  1330.         terminals[index]
  1331.         for index in sorted(selected_indices)
  1332.     ]
  1333.  
  1334.  
  1335. # =============================================================
  1336. # PRINT TERMINAL LIST
  1337. # =============================================================
  1338.  
  1339. def print_terminal_list(terminals):
  1340.  
  1341.     print()
  1342.     print("Available terminals:")
  1343.     print()
  1344.  
  1345.     for index, config in enumerate(terminals, start=1):
  1346.  
  1347.         print(
  1348.             f"    [{index}] {config['key']} - {config['title']}"
  1349.         )
  1350.  
  1351.     print()
  1352.     print(
  1353.         "Pass a comma-separated list of indexes or names to "
  1354.         "--terminals, e.g.:"
  1355.     )
  1356.     print()
  1357.     print(
  1358.         f"    python3 {os.path.basename(sys.argv[0])} "
  1359.         f"--terminals {terminals[0]['key']},{terminals[-1]['key']} "
  1360.         f"<host>"
  1361.     )
  1362.     print()
  1363.  
  1364.  
  1365.  
  1366. # =============================================================
  1367. # INTERACTIVE HOST PROMPT
  1368. # =============================================================
  1369.  
  1370. def prompt_for_host():
  1371.  
  1372.     """
  1373.    Interactively ask for a target host. Used when the script
  1374.    is run with no CLI arguments at all, since `host` is
  1375.    otherwise required.
  1376.    """
  1377.  
  1378.     while True:
  1379.  
  1380.         raw_host = input(
  1381.             "Target host (e.g. https://example.com): "
  1382.         ).strip()
  1383.  
  1384.         if raw_host:
  1385.             return raw_host
  1386.  
  1387.         print("Host cannot be empty. Try again.")
  1388.  
  1389.  
  1390. # =============================================================
  1391. # INTERACTIVE TERMINAL SELECTION MENU
  1392. # =============================================================
  1393.  
  1394. def prompt_for_terminal_selection(terminals):
  1395.  
  1396.     """
  1397.    Interactively ask which terminals to run. Used when the
  1398.    script is run with no CLI arguments at all.
  1399.  
  1400.    Returns a comma-separated selector string suitable for
  1401.    resolve_selected_terminals(), or None to mean "run all".
  1402.    """
  1403.  
  1404.     print()
  1405.     print("Which terminals do you want to run?")
  1406.     print()
  1407.     print("    [0] All terminals")
  1408.  
  1409.     for index, config in enumerate(terminals, start=1):
  1410.         print(
  1411.             f"    [{index}] {config['key']} - {config['title']}"
  1412.         )
  1413.  
  1414.     print()
  1415.     print(
  1416.         "Enter comma-separated numbers or names (e.g. 1,3,6 "
  1417.         "or wpscan,nmap,findomain)."
  1418.     )
  1419.     print(
  1420.         "Press Enter or 0 to run all terminals."
  1421.     )
  1422.     print()
  1423.  
  1424.     while True:
  1425.  
  1426.         selection = input(
  1427.             "Terminals to run [0=all]: "
  1428.         ).strip()
  1429.  
  1430.         if not selection or selection == "0":
  1431.             return None
  1432.  
  1433.         try:
  1434.             resolve_selected_terminals(selection, terminals)
  1435.  
  1436.         except ValueError as error:
  1437.             print(f"  {error} - try again.")
  1438.             continue
  1439.  
  1440.         return selection
  1441.  
  1442.  
  1443. # =============================================================
  1444. # INTERACTIVE MONITOR SELECTION MENU
  1445. # =============================================================
  1446.  
  1447. def prompt_for_monitor_selection(monitors):
  1448.  
  1449.     """
  1450.    Interactively ask which monitor to tile terminals on. Used
  1451.    when the script is run with no CLI arguments at all.
  1452.  
  1453.    Returns a --monitor-style selector string (a 0-based index
  1454.    into `monitors`, as a string) suitable for
  1455.    resolve_monitor_by_selector(), or None to mean
  1456.    "auto-detect".
  1457.    """
  1458.  
  1459.     if not monitors:
  1460.         return None
  1461.  
  1462.     print()
  1463.     print("Which monitor do you want to tile terminals on?")
  1464.     print()
  1465.     print("    [0] Auto-detect (recommended)")
  1466.  
  1467.     for index, monitor in enumerate(monitors):
  1468.  
  1469.         primary_tag = " (primary)" if monitor["primary"] else ""
  1470.  
  1471.         print(
  1472.             f"    [{index + 1}] {monitor['name']}{primary_tag}: "
  1473.             f"{monitor['width']}x{monitor['height']}"
  1474.             f"+{monitor['x']}+{monitor['y']}"
  1475.         )
  1476.  
  1477.     print()
  1478.  
  1479.     while True:
  1480.  
  1481.         selection = input(
  1482.             "Monitor to use [0=auto-detect]: "
  1483.         ).strip()
  1484.  
  1485.         if not selection or selection == "0":
  1486.             return None
  1487.  
  1488.         try:
  1489.             menu_index = int(selection)
  1490.         except ValueError:
  1491.             print(
  1492.                 "  Enter a number from the list above - "
  1493.                 "try again."
  1494.             )
  1495.             continue
  1496.  
  1497.         monitor_index = menu_index - 1
  1498.  
  1499.         if 0 <= monitor_index < len(monitors):
  1500.             return str(monitor_index)
  1501.  
  1502.         print(
  1503.             "  Enter a number from the list above - try again."
  1504.         )
  1505.  
  1506.  
  1507. # =============================================================
  1508. # COMPUTE GRID CELL GEOMETRY
  1509. # =============================================================
  1510.  
  1511. def compute_cell_geometry(
  1512.     index,
  1513.     monitor_width,
  1514.     monitor_height,
  1515.     origin_x=0,
  1516.     origin_y=0,
  1517.     rows=GRID_ROWS,
  1518.     cols=GRID_COLS,
  1519.     gap=GRID_GAP
  1520. ):
  1521.  
  1522.     """
  1523.    Compute the absolute pixel geometry (x, y, width, height)
  1524.    for cell `index` (0-based, filled left-to-right, top-to-
  1525.    bottom) in a rows x cols grid that evenly divides the given
  1526.    monitor.
  1527.  
  1528.    `origin_x`/`origin_y` are the monitor's top-left corner in
  1529.    the X server's combined virtual screen space (from xrandr),
  1530.    since xdotool's windowmove takes absolute coordinates, not
  1531.    coordinates relative to a single monitor.
  1532.    """
  1533.  
  1534.     usable_height = (
  1535.         monitor_height
  1536.         - SCREEN_MARGIN_TOP
  1537.         - SCREEN_MARGIN_BOTTOM
  1538.     )
  1539.  
  1540.     row = index // cols
  1541.     col = index % cols
  1542.  
  1543.     cell_width = monitor_width // cols
  1544.     cell_height = usable_height // rows
  1545.  
  1546.     x = origin_x + col * cell_width + gap // 2
  1547.     y = (
  1548.         origin_y
  1549.         + SCREEN_MARGIN_TOP
  1550.         + row * cell_height
  1551.         + gap // 2
  1552.     )
  1553.  
  1554.     width = cell_width - gap
  1555.     height = cell_height - gap
  1556.  
  1557.     return x, y, max(width, 1), max(height, 1)
  1558.  
  1559.  
  1560. # =============================================================
  1561. # POSITION WINDOW (MOVE + RESIZE VIA XDOTOOL)
  1562. # =============================================================
  1563.  
  1564. def position_window(
  1565.     pid,
  1566.     x,
  1567.     y,
  1568.     width,
  1569.     height,
  1570.     xdotool_available,
  1571.     attempts=POSITION_ATTEMPTS,
  1572.     delay=POSITION_DELAY
  1573. ):
  1574.  
  1575.     """
  1576.    Find the xterm window belonging to `pid` and move/resize it
  1577.    to the given pixel geometry using xdotool.
  1578.  
  1579.    Retries repeatedly because the X window for a just-launched
  1580.    process may not exist/be mapped yet when Popen returns.
  1581.    """
  1582.  
  1583.     if not xdotool_available:
  1584.         return False
  1585.  
  1586.     window_id = None
  1587.  
  1588.     for _ in range(attempts):
  1589.  
  1590.         try:
  1591.             result = subprocess.run(
  1592.                 ["xdotool", "search", "--pid", str(pid)],
  1593.                 capture_output=True,
  1594.                 text=True
  1595.             )
  1596.  
  1597.             ids = [
  1598.                 window_id
  1599.                 for window_id in result.stdout.split()
  1600.                 if window_id
  1601.             ]
  1602.  
  1603.             if ids:
  1604.                 window_id = ids[-1]
  1605.                 break
  1606.  
  1607.         except Exception:
  1608.             pass
  1609.  
  1610.         time.sleep(delay)
  1611.  
  1612.     if window_id is None:
  1613.         return False
  1614.  
  1615.     try:
  1616.         subprocess.run(
  1617.             [
  1618.                 "xdotool",
  1619.                 "windowmove",
  1620.                 window_id,
  1621.                 str(x),
  1622.                 str(y)
  1623.             ]
  1624.         )
  1625.  
  1626.         subprocess.run(
  1627.             [
  1628.                 "xdotool",
  1629.                 "windowsize",
  1630.                 window_id,
  1631.                 str(width),
  1632.                 str(height)
  1633.             ]
  1634.         )
  1635.  
  1636.     except Exception:
  1637.         return False
  1638.  
  1639.     return True
  1640.  
  1641.  
  1642. # =============================================================
  1643. # BUILD COMMAND
  1644. # =============================================================
  1645.  
  1646. def build_command(command_template):
  1647.  
  1648.     """
  1649.    Replace {host} with the HOST environment variable.
  1650.  
  1651.    This allows the host to be supplied to each xterm through
  1652.    its environment.
  1653.    """
  1654.  
  1655.     return command_template.replace(
  1656.         "{host}",
  1657.         "$HOST"
  1658.     )
  1659.  
  1660.  
  1661. # =============================================================
  1662. # CREATE STANDARD TERMINAL SCRIPT
  1663. # =============================================================
  1664.  
  1665. def create_terminal_script(config):
  1666.  
  1667.     title = config["title"]
  1668.  
  1669.     command = build_command(
  1670.         config["command"]
  1671.     )
  1672.  
  1673.     script = f'''
  1674. printf "\\033]0;{title} - $HOST\\007"
  1675.  
  1676. printf "\\n"
  1677. printf "\\033[1;36m==================================================\\033[0m\\n"
  1678. printf "\\033[1;36m {title}\\033[0m\\n"
  1679. printf "\\033[1;36m==================================================\\033[0m\\n"
  1680. printf "\\n"
  1681.  
  1682. printf "Host: %s\\n" "$HOST"
  1683.  
  1684. printf "Command:\\n"
  1685. printf "  {config["command"]}\\n"
  1686.  
  1687. printf "\\n"
  1688. printf "\\033[1;36m--------------------------------------------------\\033[0m\\n"
  1689. printf "\\n"
  1690.  
  1691. {command}
  1692.  
  1693. EXIT_CODE=$?
  1694.  
  1695. printf "\\n"
  1696. printf "\\033[1;36m==================================================\\033[0m\\n"
  1697. printf "\\033[1;36m PROCESS COMPLETED\\033[0m\\n"
  1698. printf "\\033[1;36m==================================================\\033[0m\\n"
  1699. printf "\\n"
  1700.  
  1701. printf "Terminal: %s\\n" "{title}"
  1702. printf "Host:     %s\\n" "$HOST"
  1703. printf "Exit code: %s\\n" "$EXIT_CODE"
  1704.  
  1705. printf "\\n"
  1706. printf "The terminal will remain open.\\n"
  1707. printf "Press CTRL+C to close this terminal.\\n"
  1708. printf "\\n"
  1709.  
  1710. trap 'exit 0' INT
  1711.  
  1712. while true
  1713. do
  1714.    sleep 1
  1715. done
  1716. '''
  1717.  
  1718.     return script
  1719.  
  1720.  
  1721. # =============================================================
  1722. # LAUNCH STANDARD XTERM
  1723. # =============================================================
  1724.  
  1725. def launch_terminal(config, host):
  1726.  
  1727.     title = config["title"]
  1728.  
  1729.     background = config["background"]
  1730.  
  1731.     foreground = config["foreground"]
  1732.  
  1733.     shell_script = create_terminal_script(
  1734.         config
  1735.     )
  1736.  
  1737.     environment = os.environ.copy()
  1738.  
  1739.     environment["HOST"] = host
  1740.  
  1741.     environment["WPSCAN_API_TOKEN"] = (
  1742.         WPSCAN_API_TOKEN
  1743.     )
  1744.  
  1745.     process = subprocess.Popen(
  1746.         [
  1747.             "xterm",
  1748.  
  1749.             "-title",
  1750.             title,
  1751.  
  1752.             "-geometry",
  1753.             WINDOW_GEOMETRY,
  1754.  
  1755.             "-bg",
  1756.             background,
  1757.  
  1758.             "-fg",
  1759.             foreground,
  1760.  
  1761.             "-fa",
  1762.             FONT,
  1763.  
  1764.             "-fs",
  1765.             FONT_SIZE,
  1766.  
  1767.             "-e",
  1768.             "bash",
  1769.             "-c",
  1770.             shell_script
  1771.         ],
  1772.  
  1773.         env=environment
  1774.     )
  1775.  
  1776.     return process
  1777.  
  1778.  
  1779. # =============================================================
  1780. # TERMINATE CHILD PROCESSES
  1781. # =============================================================
  1782.  
  1783. def terminate_processes(processes):
  1784.  
  1785.     for process in processes:
  1786.  
  1787.         if process.poll() is None:
  1788.  
  1789.             try:
  1790.                 process.terminate()
  1791.  
  1792.             except ProcessLookupError:
  1793.                 pass
  1794.  
  1795.  
  1796. # =============================================================
  1797. # MAIN
  1798. # =============================================================
  1799.  
  1800. def main():
  1801.  
  1802.     # =========================================================
  1803.     # Parse arguments (or fall back to an interactive prompt
  1804.     # if the script was run with no arguments at all)
  1805.     # =========================================================
  1806.  
  1807.     if len(sys.argv) == 1:
  1808.  
  1809.         print()
  1810.         print("======================================================")
  1811.         print(" Six XTerm Launcher - interactive setup")
  1812.         print("======================================================")
  1813.         print(
  1814.             "No arguments given - run with -h/--help to see "
  1815.             "available flags instead."
  1816.         )
  1817.  
  1818.         host_input = prompt_for_host()
  1819.  
  1820.         terminal_selector = prompt_for_terminal_selection(
  1821.             TERMINALS
  1822.         )
  1823.  
  1824.         monitor_selector = prompt_for_monitor_selection(
  1825.             get_monitors()
  1826.         )
  1827.  
  1828.         args = argparse.Namespace(
  1829.             host=host_input,
  1830.             monitor=monitor_selector,
  1831.             list_monitors=False,
  1832.             terminals=terminal_selector,
  1833.             list_terminals=False,
  1834.         )
  1835.  
  1836.     else:
  1837.  
  1838.         args = parse_arguments()
  1839.  
  1840.  
  1841.     # =========================================================
  1842.     # --list-monitors: show monitors and exit
  1843.     # =========================================================
  1844.  
  1845.     if args.list_monitors:
  1846.         print_monitor_list(get_monitors())
  1847.         sys.exit(0)
  1848.  
  1849.  
  1850.     # =========================================================
  1851.     # --list-terminals: show terminals and exit
  1852.     # =========================================================
  1853.  
  1854.     if args.list_terminals:
  1855.         print_terminal_list(TERMINALS)
  1856.         sys.exit(0)
  1857.  
  1858.  
  1859.     # =========================================================
  1860.     # Resolve which terminals to launch
  1861.     # =========================================================
  1862.  
  1863.     if args.terminals is not None:
  1864.  
  1865.         try:
  1866.             selected_terminals = resolve_selected_terminals(
  1867.                 args.terminals,
  1868.                 TERMINALS
  1869.             )
  1870.  
  1871.         except ValueError as error:
  1872.  
  1873.             print()
  1874.             print(f"ERROR: {error}")
  1875.  
  1876.             print_terminal_list(TERMINALS)
  1877.  
  1878.             sys.exit(1)
  1879.  
  1880.     else:
  1881.  
  1882.         selected_terminals = TERMINALS
  1883.  
  1884.  
  1885.     # =========================================================
  1886.     # Normalize host
  1887.     # =========================================================
  1888.  
  1889.     host = normalize_host(
  1890.         args.host
  1891.     )
  1892.  
  1893.  
  1894.     # =========================================================
  1895.     # Validate host
  1896.     # =========================================================
  1897.  
  1898.     if not host:
  1899.  
  1900.         print()
  1901.         print("ERROR: Host cannot be empty.")
  1902.         print()
  1903.  
  1904.         sys.exit(1)
  1905.  
  1906.  
  1907.     # =========================================================
  1908.     # Check / install ALL required tools before launching anything
  1909.     # =========================================================
  1910.  
  1911.     if not check_and_install_all_tools():
  1912.         sys.exit(1)
  1913.  
  1914.     # xdotool is guaranteed to be present after the tool check.
  1915.     xdotool_available = True
  1916.  
  1917.  
  1918.     # =========================================================
  1919.     # Determine which monitor to tile on
  1920.     # =========================================================
  1921.     #
  1922.     # Either a manual --monitor override, or auto-detection
  1923.     # done before launching any xterms, while this script's
  1924.     # own terminal should still be the active window.
  1925.  
  1926.     if args.monitor is not None:
  1927.  
  1928.         monitors = get_monitors()
  1929.  
  1930.         target_monitor = resolve_monitor_by_selector(
  1931.             args.monitor,
  1932.             monitors
  1933.         )
  1934.  
  1935.         if target_monitor is None:
  1936.  
  1937.             print()
  1938.             print(
  1939.                 f"ERROR: --monitor '{args.monitor}' didn't "
  1940.                 f"match any detected monitor."
  1941.             )
  1942.  
  1943.             print_monitor_list(monitors)
  1944.  
  1945.             sys.exit(1)
  1946.  
  1947.     else:
  1948.  
  1949.         target_monitor = determine_target_monitor(
  1950.             xdotool_available
  1951.         )
  1952.  
  1953.     cell_count = GRID_ROWS * GRID_COLS
  1954.  
  1955.     terminal_count = len(selected_terminals)
  1956.  
  1957.  
  1958.     # =========================================================
  1959.     # Display launcher information
  1960.     # =========================================================
  1961.  
  1962.     print()
  1963.     print("======================================================")
  1964.     print(" Six XTerm Launcher (equal-grid tiling)")
  1965.     print("======================================================")
  1966.     print()
  1967.  
  1968.     print(
  1969.         f"Original host:   {args.host}"
  1970.     )
  1971.  
  1972.     print(
  1973.         f"Normalized host: {host}"
  1974.     )
  1975.  
  1976.     print()
  1977.  
  1978.     print(
  1979.         f"Target monitor: {target_monitor['name']} "
  1980.         f"{target_monitor['width']}x{target_monitor['height']}"
  1981.         f"+{target_monitor['x']}+{target_monitor['y']} "
  1982.         f"(detected via: {target_monitor['detected_via']})"
  1983.     )
  1984.  
  1985.     print(
  1986.         f"Grid: {GRID_ROWS} rows x {GRID_COLS} cols "
  1987.         f"({cell_count} cells available)"
  1988.     )
  1989.  
  1990.     print(
  1991.         f"Terminals selected ({terminal_count}/"
  1992.         f"{len(TERMINALS)}): "
  1993.         + ", ".join(
  1994.             config["key"] for config in selected_terminals
  1995.         )
  1996.     )
  1997.  
  1998.     print()
  1999.  
  2000.     print(
  2001.         f"Launching {terminal_count} independent "
  2002.         f"terminal{'s' if terminal_count != 1 else ''}..."
  2003.     )
  2004.  
  2005.     print()
  2006.  
  2007.  
  2008.     # =========================================================
  2009.     # Store child processes
  2010.     # =========================================================
  2011.  
  2012.     processes = []
  2013.  
  2014.  
  2015.     # =========================================================
  2016.     # Launch the selected terminals
  2017.     # =========================================================
  2018.  
  2019.     for number, config in enumerate(
  2020.         selected_terminals,
  2021.         start=1
  2022.     ):
  2023.  
  2024.         print(
  2025.             f"[{number}/{terminal_count}] "
  2026.             f"Launching {config['title']}"
  2027.         )
  2028.  
  2029.         print(
  2030.             f"       Command: "
  2031.             f"{config['command']}"
  2032.         )
  2033.  
  2034.         print(
  2035.             f"       Background: "
  2036.             f"{config['background']}"
  2037.         )
  2038.  
  2039.         print(
  2040.             f"       Foreground: "
  2041.             f"{config['foreground']}"
  2042.         )
  2043.  
  2044.         try:
  2045.  
  2046.             process = launch_terminal(
  2047.                 config,
  2048.                 host
  2049.             )
  2050.  
  2051.             processes.append(
  2052.                 process
  2053.             )
  2054.  
  2055.             print(
  2056.                 f"       PID: "
  2057.                 f"{process.pid}"
  2058.             )
  2059.  
  2060.             x, y, width, height = compute_cell_geometry(
  2061.                 number - 1,
  2062.                 target_monitor["width"],
  2063.                 target_monitor["height"],
  2064.                 origin_x=target_monitor["x"],
  2065.                 origin_y=target_monitor["y"]
  2066.             )
  2067.  
  2068.             print(
  2069.                 f"       Grid cell: "
  2070.                 f"{width}x{height}+{x}+{y}"
  2071.             )
  2072.  
  2073.             placed = position_window(
  2074.                 process.pid,
  2075.                 x,
  2076.                 y,
  2077.                 width,
  2078.                 height,
  2079.                 xdotool_available
  2080.             )
  2081.  
  2082.             if xdotool_available and not placed:
  2083.                 print(
  2084.                     "       WARNING: could not locate/position "
  2085.                     "this window."
  2086.                 )
  2087.  
  2088.         except Exception as error:
  2089.  
  2090.             print(
  2091.                 f"       ERROR: {error}"
  2092.             )
  2093.  
  2094.         print()
  2095.  
  2096.         if (
  2097.             LAUNCH_DELAY > 0
  2098.             and number < terminal_count
  2099.         ):
  2100.  
  2101.             time.sleep(
  2102.                 LAUNCH_DELAY
  2103.             )
  2104.  
  2105.  
  2106.     # =========================================================
  2107.     # Finished launching
  2108.     # =========================================================
  2109.  
  2110.     print()
  2111.     print("======================================================")
  2112.     print(
  2113.         f" All {terminal_count} terminal"
  2114.         f"{'s' if terminal_count != 1 else ''} have been "
  2115.         f"launched and tiled."
  2116.     )
  2117.     print("======================================================")
  2118.     print()
  2119.  
  2120.     print(
  2121.         f"Target: {host}"
  2122.     )
  2123.  
  2124.     print()
  2125.  
  2126.     print(
  2127.         "Each terminal runs independently."
  2128.     )
  2129.  
  2130.     print()
  2131.  
  2132.  
  2133.     print(
  2134.         "Each terminal remains open after its command"
  2135.     )
  2136.  
  2137.     print(
  2138.         "finishes until CTRL+C is pressed inside that"
  2139.     )
  2140.  
  2141.     print(
  2142.         "individual terminal."
  2143.     )
  2144.  
  2145.     print()
  2146.  
  2147.  
  2148.     # =========================================================
  2149.     # Keep launcher alive
  2150.     # =========================================================
  2151.  
  2152.     try:
  2153.  
  2154.         while True:
  2155.  
  2156.             processes = [
  2157.                 process
  2158.                 for process in processes
  2159.                 if process.poll() is None
  2160.             ]
  2161.  
  2162.             time.sleep(1)
  2163.  
  2164.  
  2165.     except KeyboardInterrupt:
  2166.  
  2167.         print()
  2168.         print(
  2169.             "Launcher interrupted."
  2170.         )
  2171.  
  2172.         print(
  2173.             "Closing remaining xterm processes..."
  2174.         )
  2175.  
  2176.         terminate_processes(
  2177.             processes
  2178.         )
  2179.  
  2180.         print(
  2181.             "Done."
  2182.         )
  2183.  
  2184.         sys.exit(0)
  2185.  
  2186.  
  2187. # =============================================================
  2188. # PROGRAM ENTRY POINT
  2189. # =============================================================
  2190.  
  2191. if __name__ == "__main__":
  2192.  
  2193.     main()
Advertisement
Add Comment
Please, Sign In to add comment