#!/usr/bin/env python3 import logging from typing import Optional, List import argparse import re import subprocess import abc class RgbColor(): red: int green: int blue: int def __init__(self, red: int, green: int, blue: int): self.red = red self.green = green self.blue = blue UserId = str # eg 'root' HostId = str # eg alambix50.ipr.univ-rennes.fr or alambix50 TermProfileName = str # terminal profile name eg 'alambix50.ipr.univ-rennes.fr' TermProfileUuid = str # eg '2e0dec33-5ec2-4355-b671-7922010cee9f' class SshTarget(): user_id: Optional[UserId] host_id: HostId # eg root@alambix50.ipr.univ-rennes.fr def __init__(self, ssh_target_as_str: str): parts = ssh_target_as_str.split('@') assert len(parts) > 0 and len(parts) <= 2, "malformed ssh target : {ssh_target}. It is expected to be of the form [@]" self.host_id = parts[-1] # eg 'alambix50.ipr.univ-rennes.fr' self.user_id = None if len(parts) > 1: self.user_id = parts[0] assert re.match(r'^[a-z]+$', self.user_id), f'malformed user id : {self.user_id}' def __str__(self) -> str: target_as_str = f'{self.host_id}' if self.user_id: target_as_str = f'{self.user_id}@{target_as_str}' return target_as_str TerminalLabel: str # label used as the title and the color profile of the terminal class ITerminalLauncher(abc.ABC): @abc.abstractmethod def launch_terminal(self, terminal_label: TerminalLabel, bg_color: RgbColor, command: List[str]): ''' terminal_label: eg 'alambix50.ipr.univ-rennes.fr' command: the command to execute in the terminal eg ['/usr/bin/ssh', 'root@alambix50.ipr.univ-rennes.fr'] ''' # ''' # function getHostRealFqdn() # { # local strHostname="$1" # local strHostFqdn="$(host $strHostname | tail -1 | awk '{print $1}')" # echo "$strHostFqdn" # } # HOST_FQDN=$(getHostRealFqdn $HOSTNAME) # SOLARIZED_BACKGROUND='{0, 8256, 10368}' #002b36 (solarized dark base03) # SAILOR_BLUE='{8832, 14208, 18816}' # 2e4a62 # PRUNE='{15360, 10944, 14592}' #50394c # TAUPE='{19968, 18816, 16512}' #686256 # DARK_ORANGE='{27456, 12864, 0}' #8f4300 # DARK_OLIVE='{8832, 11904, 5568}' #2e3e1d # function get_current_terminal_window_id() # { # local terminal_windows_id='' # terminal_windows_id=$(osascript -e "tell application \"Terminal\" to get id of window 1") # echo "$terminal_windows_id" # } # function set_bg () # { # local strOsxTermWindowId="$1" # local strColor="$2" # eg {45000, 0, 0, 50000} # osascript -e "tell application \"Terminal\" to set background color of (every window whose id is $strOsxTermWindowId) to $strColor" # } # function on_exit () # { # set_bg "$THIS_OSX_TERM_WINDOW_ID" "$SOLARIZED_BACKGROUND" # } # # if [ $(echo $HOST_FQDN | grep '^simpatix') ] # # then # # BG_COLOR="$SAILOR_BLUE" # # elif [ $(echo $HOST_FQDN | grep '^physix') ] # # then # # BG_COLOR="$TAUPE" # # else # # case $HOST_FQDN in # # 'pr079234.spm.univ-rennes1.fr') # # BG_COLOR="$SOLARIZED_BACKGROUND" # # ;; # # 'puppet3.ipr.univ-rennes1.fr') # # BG_COLOR="$PRUNE" # # ;; # # *) # # BG_COLOR="$DARK_OLIVE" # # ;; # # esac # # fi # #if [ "$AS_ROOT" = 'true' ] # #then # # BG_COLOR="$DARK_ORANGE" # #fi def is_valid_uuid(uuid: str) -> bool: ''' uuid: eg 'b1dcc9dd-5262-4d8d-a863-c897e6d979b9' ''' m = re.match(r'^[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]$', uuid) return m is not None GSettingsKeyPath = str # eg org.gnome.Terminal.ProfilesList class GnomeSettings(): def get_list(self, key_id: GSettingsKeyPath) -> List[str]: # 20260616-17:34:08 graffy@graffy-ws2:~$ gsettings get org.gnome.Terminal.ProfilesList list # ['b1dcc9dd-5262-4d8d-a863-c897e6d979b9', '2e0dec33-5ec2-4355-b671-7922010cee9f'] proc = subprocess.run(['gsettings', 'get', key_id, 'list'], check=True, stdout=subprocess.PIPE) lines = proc.stdout.decode('utf8').split('\n') values = [] assert len(lines) == 2 m = re.match(r'^\[(?P[^]]*)\]$', lines[0]) assert m is not None series_of_strings = m['series_of_strings'] logging.debug('get_list: series_of_strings = %s', str(series_of_strings)) strings = series_of_strings.split(', ') for s in strings: m = re.match(r"'(?P[^']*)'", s) assert m is not None values.append(m['unquoted_string']) logging.debug('get_list: values = %s', str(values)) return values def set_list(self, key_id: GSettingsKeyPath, value: List[str]): # gsettings set org.gnome.Terminal.ProfilesList list "$(echo "$CURRENT_LIST" | tr -d "[]" | sed "s/$/,$PROFILE_ID/")" value_as_str = ','.join([f"'{unq_str}'" for unq_str in value]) logging.debug('value_as_str = "%s"', value_as_str) subprocess.run(['gsettings', 'set', key_id, 'list', f"[{value_as_str}]"], check=True) def register_profile_uuid(self, profile_uuid: TermProfileUuid): ''' profile_uuid: eg '2e0dec33-5ec2-4355-b671-7922010cee9f' ''' assert is_valid_uuid(profile_uuid), f'invalid uuid: "{profile_uuid}"' profiles_key_id = 'org.gnome.Terminal.ProfilesList' profile_uuids = self.get_list(profiles_key_id) assert profile_uuid not in profile_uuids profile_uuids.append(profile_uuid) self.set_list(profiles_key_id, profile_uuids) class GnomeTerminal(): DCONF_PROFILES_PATH = '/org/gnome/terminal/legacy/profiles:' # DCONF_PROFILES_PATH='/org/gnome/terminal/legacy/profiles:' @staticmethod def create_new_profile(profile_name: TermProfileName) -> TermProfileUuid: ''' copied from https://askubuntu.com/questions/270469/how-can-i-create-a-new-profile-for-gnome-terminal-via- command-line ''' # 20260616-16:24:12 graffy@graffy-ws2:~$ dconf list '/org/gnome/terminal/legacy/profiles:/' # :2a05665f-baa7-484e-b179-4e94f3814238/ # :2e0dec33-5ec2-4355-b671-7922010cee9f/ # last command status : [0] old_profile_uuids = GnomeTerminal.get_term_profiles() # local profile_ids=($(dconf list $DCONF_PROFILES_PATH/ | grep ^: |\ # sed 's/\///g' | sed 's/://g')) # local profile_name="$1" # local profile_ids_old="$(dconf read "$DCONF_PROFILES_PATH"/list | tr -d "]")" proc = subprocess.run(['uuidgen'], check=True, stdout=subprocess.PIPE) lines = proc.stdout.decode('utf8').split('\n') assert len(lines) == 2 profile_uuid = lines[0] m = re.match(r'^[a-z0-9\-]+$', profile_uuid) logging.info("creating profile profile_uuid=%s with profile_name=%s", profile_uuid, profile_name) # echo "new_profiles_list=$new_profiles_list" # dconf write $DCONF_PROFILES_PATH/list "$new_profiles_list" proc = subprocess.run(['dconf', 'write', f"{GnomeTerminal.DCONF_PROFILES_PATH}/{profile_uuid}/visible-name", f"'{profile_name}'"], check=True) assert GnomeTerminal.get_term_profile_name(profile_name) == profile_uuid # dconf write /org/gnome/terminal/legacy/profiles:/:dc932a58-6a38-4511-876a-d453b5e33a26/visible-name "'toto'" gsettings = GnomeSettings() gsettings.register_profile_uuid(profile_uuid) return profile_uuid @staticmethod def get_term_profiles() -> List[TermProfileUuid]: proc = subprocess.run(['dconf', 'list', f"{GnomeTerminal.DCONF_PROFILES_PATH}/"], check=True, stdout=subprocess.PIPE) profile_uuids = [] for line in proc.stdout.decode('utf8').split('\n'): print(line) # eg ':b1dcc9dd-5262-4d8d-a863-c897e6d979b9/' m = re.match(r'^:?(?P[0-9a-f\-]+)/$', line) if m is None: assert line in ['list', ''], f'unexpected format for line: {line}' else: profile_uuids.append(m['uuid']) return profile_uuids @staticmethod def get_term_profile_name(profile_uuid: TermProfileUuid) -> Optional[TermProfileName]: profile_name = None proc = subprocess.run(['dconf', 'read', f"{GnomeTerminal.DCONF_PROFILES_PATH}/{profile_uuid}/visible-name"], check=True, stdout=subprocess.PIPE) lines = proc.stdout.decode('utf8').split('\n') if (len(lines) >= 2): assert len(lines) == 2, lines assert lines[1] == '' m = re.match(r"^\'(?P[^']+)'$", lines[0]) assert m, f'unexpected line : {line}' profile_name = m['profile_name'] else: pass # (the default profile has no name) #assert False, f'unexpected case: the terminal profile profile_uuid={profile_uuid} has no name!!!' return profile_name @staticmethod def get_terminal_profile_uuid(profile_name: TermProfileName) -> TermProfileUuid: ''' returns the id of the profile which has the given name. if the named profile doesn't exist, it creates it first local profile_name="$1" # eg physix.ipr.univ-rennes1.fr ''' profile_uuid = None for profile_uuid in GnomeTerminal.get_term_profiles(): logging.debug(f'processing profile_uuid={profile_uuid}') prof_name = GnomeTerminal.get_term_profile_name(profile_uuid) logging.debug('prof_name = %s', prof_name) if profile_name == prof_name: return profile_uuid logging.info('the profile named %s doesnt exist... create it then', profile_name) profile_uuid = GnomeTerminal.create_new_profile(profile_name) return profile_uuid @staticmethod def set_terminal_profile_bg_color(profile_name: TermProfileName, bg_color: RgbColor): # function set_terminal_profile_bg_color() # profile_name # eg physix.ipr.univ-rennes1.fr # bg_color # eg RgbColor(17, 25, 24) profile_uuid = GnomeTerminal.get_terminal_profile_uuid(profile_name) logging.debug("profile_name=%s profile_uuid=%s", profile_name, profile_uuid) subprocess.run(['dconf', 'write', f"{GnomeTerminal.DCONF_PROFILES_PATH}/{profile_uuid}/use-theme-colors", 'false'], check=True, stdout=subprocess.PIPE) subprocess.run(['dconf', 'write', f"{GnomeTerminal.DCONF_PROFILES_PATH}/{profile_uuid}/background-color", f'({bg_color.red}, {bg_color.green}, {bg_color.blue})'], check=True, stdout=subprocess.PIPE) class GnomeTerminalLauncher(ITerminalLauncher): def launch_terminal(self, terminal_label: TerminalLabel, bg_color: RgbColor, command: List[str]): ''' command: the command to execute in the terminal eg ['/usr/bin/ssh', f'{str(ssh_target)}'] ''' GnomeTerminal.set_terminal_profile_bg_color(terminal_label, bg_color) profile_uuid = GnomeTerminal.get_terminal_profile_uuid(terminal_label) launch_term_command = ['gnome-terminal', f'--window-with-profile={profile_uuid}', f'--title={terminal_label}', '--'] + command logging.debug('launch_term_command = %s', launch_term_command) subprocess.run(launch_term_command, check=True) def main(): logging.basicConfig(level=logging.DEBUG) arg_parser = argparse.ArgumentParser('cssh (colored ssh) opens a terminal window with a background color that depends on the fully qualified domain name of the target machine') arg_parser.add_argument('ssh_target', help='eg root@alambix50.ipr.univ-rennes.fr') args = arg_parser.parse_args() logging.debug(args) ssh_target = SshTarget(args.ssh_target) # eg 'root@alambix50.ipr.univ-rennes.fr' # AS_ROOT='false' # if [ "$(echo $@ | grep '^root@' > /dev/null)" ] # then # AS_ROOT='true' # fi color_saturation = 0.3 color_value = 0.2 proc = subprocess.run(['uname'], check=True, stdout=subprocess.PIPE) os_name = proc.stdout.decode('utf8').strip() terminal_launcher: Optional[ITerminalLauncher] = None if os_name == 'Darwin': raise NotImplementedError() # 'Darwin') # BG_COLOR=$(make_color.py $HOST_FQDN $COLOR_VALUE $COLOR_SATURATION osx) # THIS_OSX_TERM_WINDOW_ID=$(get_current_terminal_window_id) # echo "THIS_OSX_TERM_WINDOW_ID=$THIS_OSX_TERM_WINDOW_ID" # set_bg "$THIS_OSX_TERM_WINDOW_ID" "$BG_COLOR" # trap on_exit EXIT # /usr/bin/ssh "$@" # ;; elif os_name == 'Linux': terminal_launcher = GnomeTerminalLauncher() else: raise NotImplementedError(f'unhandled os : "{os_name}"') proc = subprocess.run(['make_color.py', ssh_target.host_id, str(color_value), str(color_saturation), os_name.lower()], check=True, stdout=subprocess.PIPE) bg_color_as_str = proc.stdout.decode('utf8').strip() match = re.match(r'\((?P[0-9]+), (?P[0-9]+), (?P[0-9]+)\)', bg_color_as_str) assert match, f'unexpected color as string: {bg_color_as_str}' bg_color = RgbColor(match['red'], match['green'], match['blue']) logging.debug('bg_color = %s', str(bg_color)) # eg (35, 43, 51) command = ['/usr/bin/ssh', f'{str(ssh_target)}'] terminal_launcher.launch_terminal(terminal_label=ssh_target.host_id, bg_color=bg_color, command=command) main()