Compare commits

..

No commits in common. "main" and "1.0" have entirely different histories.
main ... 1.0

4 changed files with 145 additions and 324 deletions

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
*.pyc

View file

@ -1,42 +0,0 @@
#!/usr/bin/env python3
# This code taken from:
# http://stackoverflow.com/questions/8290740/simple-versatile-and-re-usable-entry-dialog-sometimes-referred-to-as-input-dia
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk as gtk
class EntryDialog(gtk.MessageDialog):
def __init__(self, *args, **kwargs):
'''
Creates a new EntryDialog. Takes all the arguments of the usual
MessageDialog constructor plus one optional named argument
"default_value" to specify the initial contents of the entry.
'''
if 'default_value' in kwargs:
default_value = kwargs['default_value']
del kwargs['default_value']
else:
default_value = ''
super(EntryDialog, self).__init__(*args, **kwargs)
entry = gtk.Entry()
entry.set_text(str(default_value))
entry.connect("activate",
lambda ent, dlg, resp: dlg.response(resp),
self, gtk.ResponseType.OK)
self.vbox.pack_end(entry, True, True, 0)
self.vbox.show_all()
self.entry = entry
def set_value(self, text):
self.entry.set_text(text)
def run(self):
result = super(EntryDialog, self).run()
if result == gtk.ResponseType.OK:
text = self.entry.get_text()
else:
text = None
return text

View file

@ -1,13 +1,22 @@
# crussh: a modern cssh replacement crussh: a modern cssh replacement
Available via [radicle](https://radicle.xyz/) at: `rad:z3BhWhapyVBzzuBicAyCcmMhv1yEe` =================================
## What are this? Backstory
---------
For anyone who needs to administrate clusters of many machines, For anyone who needs to administrate clusters of many machines,
[clusterssh](http://sourceforge.net/projects/clusterssh/) has long been a [clusterssh](http://sourceforge.net/projects/clusterssh/) has long been a
fallback for when the rest of your automation tools aren't working. fallback for when the rest of your automation tools aren't working.
crussh aims to be a simple replacement for cssh with the following improvements: However, cssh has a number of deficiencies in modern environments:
- Doesn't play nice with window placement of modern window managers.
- Doesn't play nice with modern toolkits' copy and paste behaviour.
- Gets a bit screwy when there's more terminals than can fit on-screen.
- Doesn't support nice antialiased fonts.
crussh aims to be a simple replacement for cssh that corrects these
problems. It does so with the following features:
- Uses a single window to hold multiple terminals. - Uses a single window to hold multiple terminals.
- Intelligently tiles terminals to fit available window size. - Intelligently tiles terminals to fit available window size.
@ -15,56 +24,35 @@ crussh aims to be a simple replacement for cssh with the following improvements:
- Never resizes a terminal smaller than 80x24 characters. - Never resizes a terminal smaller than 80x24 characters.
- Uses GTK and the VTE widget to provide modern, anti-aliased terminals. - Uses GTK and the VTE widget to provide modern, anti-aliased terminals.
## Install Install
-------
The install process is very simple on most distros: The install process is very simple on most distros:
* Install python3 and python3-gi. - Install python2, python-gtk2, and python-vte.
* Clone and symlink to your bin dir: - Clone and symlink to your bin dir.
```bash git clone https://github.com/unit3/crussh.git
git clone https://github.com/nergdron/crussh.git ln -s crussh/crussh.py ~/bin/crussh
ln -s $(pwd)/crussh/crussh.py ~/bin/crussh - Run "crussh HOST [HOST ...]"
```
Run ```crussh HOST [HOST ...]``` Usage Tips
----------
## Examples Doing a clustered paste isn't completely obvious. The following methods will work:
Basic usage is covered via the builtin help, which you can get by running
```crussh -h```. This section covers some common use cases.
To connect to a list of hosts in a file:
```bash
crussh $(cat hostlist.txt)
```
To use a custom login name, public key, or other SSH client options:
```bash
crussh -l someuser -i ~/.ssh/myotherkey -- host [host ...]
```
To do something other than ssh, such as edit a bunch of files in parallel:
```bash
crussh -e nano *.txt
```
## Usage Tips
Doing a clustered paste isn't completely obvious. The following methods will
work, after making sure you're clicked into the text entry box at the bottom of
the window:
- middle click or shift-insert to paste the X11 selection buffer. - middle click or shift-insert to paste the X11 selection buffer.
- control-shift-v to paste the GTK/GNOME clipboard. - control-shift-v to paste the GTK/GNOME clipboard.
## Bugs & To Do Bugs & TODO
-----------
To see current issues, report problems, and see plans for features, To see current issues, report problems, and see plans for features,
see the [crussh GitHub issues page](https://github.com/nergdron/crussh/issues). see the [crussh GitHub issues page](https://github.com/unit3/crussh/issues).
## Copyright and License Copyright and License
---------------------
crussh is copyright 2012-2019 by Tessa Nordgren <tessa@sudo.ca>. crussh is copyright 2012 by Graeme Humphries <graeme@sudo.ca>.
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by

355
crussh.py
View file

@ -1,31 +1,36 @@
#!/usr/bin/env python3 #!/usr/bin/env python
# A cssh replacement written in Python / GTK. # A cssh replacement written in Python / GTK.
# (c)2012-2019 - Tessa Nordgren <tessa@sudo.ca>. # (c)2012 - Graeme Humphries <graeme@sudo.ca>.
# Released under the GPL, version 3: http://www.gnu.org/licenses/ # Released under the GPL, version 3: http://www.gnu.org/licenses/
import gi # Requires: python-gtk2 python-vte
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk as gtk import sys
from gi.repository import Gdk as gdk
from gi.repository import GLib as glib
from gi.repository.Pango import FontDescription
gi.require_version('Vte', '2.91')
from gi.repository import Vte as vte
import math import math
import yaml import json
import os.path import os.path
from EntryDialog import EntryDialog
from collections import OrderedDict try:
import gtk
except:
print >>sys.stderr, "Missing Python GTK2 bindings."
sys.exit(1)
try:
import vte
except:
print >>sys.stderr, "Missing Python VTE bindings."
sys.exit(1)
# Config Dialog # ### Config Dialog ###
class CruSSHConf: class CruSSHConf:
# State Vars # ### State Vars ###
Config = {} Config = {}
MainWin = gtk.Window() MainWin = gtk.Window()
# Signal Hooks # ### Signal Hooks ###
def save_hook(self, discard, save_func): def save_hook(self, discard, save_func):
self.MainWin.destroy() self.MainWin.destroy()
if save_func is not None: if save_func is not None:
@ -40,65 +45,46 @@ class CruSSHConf:
def height_hook(self, spinbutton): def height_hook(self, spinbutton):
self.Config["min-height"] = spinbutton.get_value_as_int() self.Config["min-height"] = spinbutton.get_value_as_int()
def maximized_hook(self, checkbutton): ### GUI Objects ###
self.Config["start-maximized"] = checkbutton.get_active()
# GUI Objects #
def initGUI(self, save_func=None): def initGUI(self, save_func=None):
self.MainWin.set_modal(True) self.MainWin.set_modal(True)
self.MainWin.props.allow_grow = False
MainBox = gtk.VBox(spacing=5) MainBox = gtk.VBox(spacing=5)
MainBox.props.border_width = 5 MainBox.props.border_width = 5
self.MainWin.add(MainBox) self.MainWin.add(MainBox)
GlobalConfFrame = gtk.Frame(label="Global Options")
GlobalConfTable = gtk.Table(1, 2)
GlobalConfTable.props.border_width = 5
GlobalConfTable.props.row_spacing = 5
GlobalConfTable.props.column_spacing = 5
GlobalConfFrame.add(GlobalConfTable)
MainBox.pack_start(GlobalConfFrame, False, False, 0)
GlobalConfTable.attach(gtk.Label("Start Maximized:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND)
MaximizedConf = gtk.CheckButton()
MaximizedConf.set_active(self.Config["start-maximized"])
MaximizedConf.connect("toggled", self.maximized_hook)
GlobalConfTable.attach(MaximizedConf, 2, 3, 1, 2, gtk.AttachOptions.EXPAND)
TermConfFrame = gtk.Frame(label="Terminal Options") TermConfFrame = gtk.Frame(label="Terminal Options")
TermConfTable = gtk.Table(3, 2) TermConfTable = gtk.Table(3, 2)
TermConfTable.props.border_width = 5 TermConfTable.props.border_width = 5
TermConfTable.props.row_spacing = 5 TermConfTable.props.row_spacing = 5
TermConfTable.props.column_spacing = 5 TermConfTable.props.column_spacing = 5
TermConfFrame.add(TermConfTable) TermConfFrame.add(TermConfTable)
MainBox.pack_start(TermConfFrame, False, False, 0) MainBox.pack_start(TermConfFrame)
TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND) TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.EXPAND)
FontConf = gtk.FontButton(font=self.Config["font"]) FontConf = gtk.FontButton(fontname=self.Config["font"])
FontConf.connect("font-set", self.font_hook) FontConf.connect("font-set", self.font_hook)
TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.AttachOptions.EXPAND) TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.EXPAND)
SizeBox = gtk.HBox() SizeBox = gtk.HBox()
SizeBox.props.spacing = 5 SizeBox.props.spacing = 5
TermConfTable.attach(SizeBox, 1, 3, 2, 3) TermConfTable.attach(SizeBox, 1, 3, 2, 3)
SizeBox.pack_start(gtk.Label("Min Width:"), False, False, 0) SizeBox.pack_start(gtk.Label("Min Width:"), fill=False, expand=False)
WidthEntry = gtk.SpinButton() WidthEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1))
# gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1)
WidthEntry.set_range(1, 9999)
WidthEntry.connect("value-changed", self.width_hook) WidthEntry.connect("value-changed", self.width_hook)
SizeBox.pack_start(WidthEntry, False, False, 0) SizeBox.pack_start(WidthEntry, fill=False, expand=False)
SizeBox.pack_start(gtk.Label("Min Height:"), False, False, 0) SizeBox.pack_start(gtk.Label("Min Height:"), fill=False, expand=False)
HeightEntry = gtk.SpinButton() HeightEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-height"], lower=1, upper=9999, step_incr=1))
HeightEntry.set_range(1, 9999)
HeightEntry.connect("value-changed", self.height_hook) HeightEntry.connect("value-changed", self.height_hook)
SizeBox.pack_start(HeightEntry, False, False, 0) SizeBox.pack_start(HeightEntry, fill=False, expand=False)
ConfirmBox = gtk.HBox(spacing=5) ConfirmBox = gtk.HBox(spacing=5)
CancelButton = gtk.Button(stock=gtk.STOCK_CANCEL) CancelButton = gtk.Button(stock=gtk.STOCK_CANCEL)
ConfirmBox.pack_start(CancelButton, False, False, 0) ConfirmBox.pack_start(CancelButton, fill=False)
SaveButton = gtk.Button(stock=gtk.STOCK_SAVE) SaveButton = gtk.Button(stock=gtk.STOCK_SAVE)
ConfirmBox.pack_start(SaveButton, False, False, 0) ConfirmBox.pack_start(SaveButton, fill=False)
MainBox.pack_start(ConfirmBox, False, False, 0) MainBox.pack_start(ConfirmBox, fill=False, expand=False)
# wire up behaviour # wire up behaviour
CancelButton.connect("clicked", lambda discard: self.MainWin.destroy()) CancelButton.connect("clicked", lambda discard: self.MainWin.destroy())
@ -114,94 +100,35 @@ class CruSSHConf:
self.initGUI(save_func) self.initGUI(save_func)
# Hosts Mask Dialog # ### CruSSH! ###
class HostsMask:
Terminals = {}
MainWin = gtk.Window()
def toggle_func(self, checkitem, host):
self.Terminals[host].copy_input = checkitem.get_active()
def InitGUI(self):
self.MainWin.set_modal(True)
MainBox = gtk.VBox(spacing=5)
MainBox.props.border_width = 5
self.MainWin.add(MainBox)
# determine optimal table dimensions
cols = int(math.sqrt(len(self.Terminals)))
rows = int(math.ceil(len(self.Terminals) / cols))
HostsConfFrame = gtk.Frame(label="Active Terminals")
HostsConfTable = gtk.Table(rows, cols)
HostsConfTable.props.border_width = 5
HostsConfTable.props.row_spacing = 5
HostsConfTable.props.column_spacing = 5
HostsConfFrame.add(HostsConfTable)
i = 0
hosts = self.Terminals.keys()
for host in hosts:
HostTable = gtk.Table(1, 2)
HostTable.props.column_spacing = 2
HostTable.attach(gtk.Label(host), 0, 1, 0, 1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
HostCheckbox = gtk.CheckButton()
HostCheckbox.set_active(self.Terminals[host].copy_input)
HostCheckbox.connect("toggled", self.toggle_func, host)
HostTable.attach(HostCheckbox, 1, 2, 0, 1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
row = i / cols
col = i % cols
HostsConfTable.attach(HostTable, col, col+1, row, row+1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
i += 1
MainBox.pack_start(HostsConfFrame, False, False, 0)
OkButton = gtk.Button(stock=gtk.STOCK_OK)
MainBox.pack_start(OkButton, False, False, 0)
# wire up behaviour
OkButton.connect("clicked", lambda discard: self.MainWin.destroy())
self.MainWin.show_all()
def __init__(self, terminals=None):
if hosts is not None:
self.Terminals = terminals
self.InitGUI()
# CruSSH! #
class CruSSH: class CruSSH:
# Config Vars # ### Config Vars ###
# config defaults # config defaults
Config = { Config = {
"min-width": 80, "min-width": 80,
"min-height": 24, "min-height": 24,
"font": "Ubuntu Mono,monospace Bold 10", "font": "Ubuntu Mono Bold 10"
"start-maximized": True }
}
# State Vars # ### State Vars ###
Terminals = OrderedDict() Terminals = {}
TermMinWidth = 1 TermMinWidth = 1
TermMinHeight = 1 TermMinHeight = 1
# GUI Objects # ### GUI Objects ###
MainWin = gtk.Window() MainWin = gtk.Window()
ScrollWin = gtk.ScrolledWindow() ScrollWin = gtk.ScrolledWindow()
LayoutTable = gtk.Table() LayoutTable = gtk.Table()
EntryBox = gtk.Entry() EntryBox = gtk.Entry()
Clipboard = gtk.Clipboard() Clipboard = gtk.Clipboard()
ActiveHostsMenu = gtk.Menu()
# Methods # ### Methods ###
def reflowTable(self, cols=1, rows=1): def reflowTable(self, cols=1, rows=1):
# empty table and re-size # empty table and re-size
hosts = list(self.Terminals.keys()) hosts = sorted(self.Terminals.keys(), reverse=True)
hosts.reverse()
for host in hosts: for host in hosts:
if self.Terminals[host].get_parent() == self.LayoutTable: if self.Terminals[host].parent == self.LayoutTable:
self.LayoutTable.remove(self.Terminals[host]) self.LayoutTable.remove(self.Terminals[host])
self.LayoutTable.resize(rows, cols) self.LayoutTable.resize(rows, cols)
# layout terminals # layout terminals
@ -222,7 +149,7 @@ class CruSSH:
gtk.main_quit() gtk.main_quit()
# main_quit desn't happen immediately # main_quit desn't happen immediately
return False return False
size = self.MainWin.get_size() size = self.MainWin.allocation
cols = int(math.floor((size.width + self.LayoutTable.props.column_spacing) / float(self.TermMinWidth))) cols = int(math.floor((size.width + self.LayoutTable.props.column_spacing) / float(self.TermMinWidth)))
if cols < 1 or num_terms == 1: if cols < 1 or num_terms == 1:
cols = 1 cols = 1
@ -231,76 +158,35 @@ class CruSSH:
rows = int(math.ceil(num_terms / float(cols))) rows = int(math.ceil(num_terms / float(cols)))
if rows < 1: if rows < 1:
rows = 1 rows = 1
# ensure we evenly distribute terminals per row.
cols = int(math.ceil(num_terms / float(rows)))
if (self.LayoutTable.props.n_columns != cols) or (self.LayoutTable.props.n_rows != rows) or force: if (self.LayoutTable.props.n_columns != cols) or (self.LayoutTable.props.n_rows != rows) or force:
self.reflowTable(cols, rows) self.reflowTable(cols, rows)
self.MainWin.show_all() self.MainWin.show_all()
def addHost(self, host):
def handle_copy_paste(widget, event):
self.EntryBox.props.buffer.delete_text(0, -1)
# check for paste key shortcut (ctl-shift-v)
if (event.type == gdk.EventType.KEY_PRESS) \
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
and (event.keyval == gdk.keyval_from_name('V')):
widget.paste_clipboard()
return True
elif (event.type == gdk.EventType.KEY_PRESS) \
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
and (event.keyval == gdk.keyval_from_name('C')):
widget.copy_clipboard()
return True
terminal = vte.Terminal()
terminal.spawn_sync(
vte.PtyFlags.DEFAULT,
None,
[self.ssh_cmd] + self.ssh_args + [host],
[],
glib.SpawnFlags.DO_NOT_REAP_CHILD,
None,
None,
)
# track whether we mirror output to this terminal
terminal.copy_input = True
# attach copy/paste handler
terminal.connect("key_press_event", handle_copy_paste)
self.Terminals[host] = terminal
# hook terminals so they reflow layout on exit
self.Terminals[host].connect("child-exited", self.removeTerminal)
def configTerminals(self): def configTerminals(self):
for host in self.Terminals: for host in self.Terminals:
terminal = self.Terminals[host] terminal = self.Terminals[host]
# -1 == infinite scrollback # -1 == infinite scrollback
terminal.set_scrollback_lines(-1) terminal.set_scrollback_lines(-1)
terminal.set_size(self.Config["min-width"], self.Config["min-height"]) terminal.set_size(self.Config["min-width"], self.Config["min-height"])
terminal.set_font(FontDescription(self.Config["font"])) terminal.set_font_from_string(self.Config["font"])
self.TermMinWidth = terminal.get_char_width() * self.Config["min-width"] self.TermMinWidth = (terminal.get_char_width() * self.Config["min-width"]) + terminal.get_padding()[0]
self.TermMinHeight = terminal.get_char_height() * self.Config["min-height"] self.TermMinHeight = (terminal.get_char_height() * self.Config["min-height"]) + terminal.get_padding()[1]
def removeTerminal(self, terminal, status): def removeTerminal(self, terminal):
to_del = [] # brute force search since we don't actually know the hostname from the
# terminal object. this is an infrequent operation, so it should be fine.
for host in self.Terminals.keys(): for host in self.Terminals.keys():
if terminal == self.Terminals[host]: if terminal == self.Terminals[host]:
self.LayoutTable.remove(self.Terminals[host]) self.LayoutTable.remove(self.Terminals[host])
print("Disconnected from " + host) del self.Terminals[host]
to_del.append(host)
for host in to_del:
del self.Terminals[host]
self.reflow(force=True) self.reflow(force=True)
def initGUI(self): def initGUI(self):
theme = gtk.IconTheme.get_default() theme = gtk.icon_theme_get_default()
if theme.has_icon("terminal"): if theme.has_icon("terminal"):
icon = theme.lookup_icon("terminal", 128, flags=gtk.IconLookupFlags.USE_BUILTIN) icon = theme.lookup_icon("terminal", 128, flags=gtk.ICON_LOOKUP_USE_BUILTIN)
if icon is not None: if icon is not None:
self.MainWin.set_icon(icon.load_icon()) gtk.window_set_default_icon(icon.load_icon())
self.MainWin.set_title("crussh: " + ' '.join(self.Terminals.keys())) self.MainWin.set_title("crussh: " + ' '.join(self.Terminals.keys()))
self.MainWin.set_role(role="crussh_main_win") self.MainWin.set_role(role="crussh_main_win")
self.MainWin.connect("delete-event", lambda window, event: gtk.main_quit()) self.MainWin.connect("delete-event", lambda window, event: gtk.main_quit())
@ -308,26 +194,11 @@ class CruSSH:
self.MainWin.add(MainVBox) self.MainWin.add(MainVBox)
MainMenuBar = gtk.MenuBar() MainMenuBar = gtk.MenuBar()
MainVBox.pack_start(MainMenuBar, False, False, 0) MainVBox.pack_start(MainMenuBar, fill=True, expand=False)
def add_host_handler(self, base):
diag = EntryDialog(self.get_parent().get_parent(),
gtk.DialogFlags.MODAL | gtk.DialogFlags.DESTROY_WITH_PARENT,
gtk.MessageType.QUESTION, gtk.ButtonsType.OK_CANCEL,
message_format="Hostname to add:")
host = diag.run()
if host is not None and len(host) > 0:
base.addHost(host)
diag.destroy()
base.reflow(force=True)
FileItem = gtk.MenuItem(label="File") FileItem = gtk.MenuItem(label="File")
FileMenu = gtk.Menu() FileMenu = gtk.Menu()
FileItem.set_submenu(FileMenu) FileItem.set_submenu(FileMenu)
AddHostItem = gtk.MenuItem(label="Add Host")
AddHostItem.connect("activate", add_host_handler, self)
FileMenu.append(AddHostItem)
FileMenu.append(gtk.SeparatorMenuItem())
QuitItem = gtk.MenuItem(label="Quit") QuitItem = gtk.MenuItem(label="Quit")
QuitItem.connect("activate", lambda discard: gtk.main_quit()) QuitItem.connect("activate", lambda discard: gtk.main_quit())
FileMenu.append(QuitItem) FileMenu.append(QuitItem)
@ -337,8 +208,16 @@ class CruSSH:
EditMenu = gtk.Menu() EditMenu = gtk.Menu()
EditItem.set_submenu(EditMenu) EditItem.set_submenu(EditMenu)
def toggle_func(checkitem, host):
self.Terminals[host].copy_input = checkitem.get_active()
ActiveHostsItem = gtk.MenuItem(label="Active Hosts") ActiveHostsItem = gtk.MenuItem(label="Active Hosts")
ActiveHostsItem.connect("activate", lambda discard: HostsMask(self.Terminals)) ActiveHostsItem.set_submenu(self.ActiveHostsMenu)
hosts = sorted(self.Terminals.keys(), reverse=True)
for host in hosts:
hostitem = gtk.CheckMenuItem(label=host)
hostitem.set_active(True)
hostitem.connect("toggled", toggle_func, host)
self.ActiveHostsMenu.append(hostitem)
EditMenu.append(ActiveHostsItem) EditMenu.append(ActiveHostsItem)
PrefsItem = gtk.MenuItem(label="Preferences") PrefsItem = gtk.MenuItem(label="Preferences")
@ -347,23 +226,26 @@ class CruSSH:
self.Config = new_config self.Config = new_config
self.reflow(force=True) self.reflow(force=True)
# save to file last, so it doesn't hold up other GUI actions # save to file last, so it doesn't hold up other GUI actions
conf_yaml = yaml.dump(self.Config, sort_keys=True, indent=4) conf_json = json.dumps(self.Config, sort_keys=True, indent=4)
conf_file = open(os.path.expanduser("~/.config/crussh.yml"), 'w') try:
conf_file.write(conf_yaml) conf_file = open(os.path.expanduser("~/.crusshrc"), 'w')
conf_file.close() conf_file.write(conf_json)
conf_file.close()
except:
pass
PrefsItem.connect("activate", lambda discard: CruSSHConf(self.Config, save_func)) PrefsItem.connect("activate", lambda discard: CruSSHConf(self.Config, save_func))
EditMenu.append(PrefsItem) EditMenu.append(PrefsItem)
MainMenuBar.append(EditItem) MainMenuBar.append(EditItem)
self.ScrollWin.props.hscrollbar_policy = gtk.PolicyType.NEVER self.ScrollWin.props.hscrollbar_policy = gtk.POLICY_NEVER
self.ScrollWin.props.vscrollbar_policy = gtk.PolicyType.ALWAYS self.ScrollWin.props.vscrollbar_policy = gtk.POLICY_ALWAYS
self.ScrollWin.props.shadow_type = gtk.ShadowType.ETCHED_IN self.ScrollWin.props.shadow_type = gtk.SHADOW_ETCHED_IN
MainVBox.pack_start(self.ScrollWin, True, True, 0) MainVBox.pack_start(self.ScrollWin)
self.LayoutTable.set_homogeneous(True) self.LayoutTable.set_homogeneous(True)
self.LayoutTable.set_row_spacings(1) self.LayoutTable.set_row_spacings(1)
self.LayoutTable.set_col_spacings(1) self.LayoutTable.set_col_spacings(1)
self.ScrollWin.add(self.LayoutTable) self.ScrollWin.add_with_viewport(self.LayoutTable)
self.ScrollWin.set_size_request(self.TermMinWidth, self.TermMinHeight) self.ScrollWin.set_size_request(self.TermMinWidth, self.TermMinHeight)
# don't display chars while typing. # don't display chars while typing.
@ -374,18 +256,17 @@ class CruSSH:
def feed_paste(widget): def feed_paste(widget):
for host in self.Terminals: for host in self.Terminals:
if self.Terminals[host].copy_input: if self.Terminals[host].copy_input:
self.Terminals[host].paste_clipboard() self.Terminals[host].feed_child(self.Clipboard.wait_for_text())
self.EntryBox.props.buffer.delete_text(0, -1) self.EntryBox.props.buffer.delete_text(0, -1)
# forward key events to all terminals with copy_input set # forward key events to all terminals with copy_input set
def feed_input(widget, event): def feed_input(widget, event):
self.EntryBox.props.buffer.delete_text(0, -1) self.EntryBox.props.buffer.delete_text(0, -1)
# check for paste key shortcut (ctl-shift-v) # check for paste key shortcut (ctl-shift-v)
if (event.type == gtk.gdk.KEY_PRESS) \
if (event.type == gdk.EventType.KEY_PRESS) \ and (event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK) \
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \ and (event.state & gtk.gdk.SHIFT_MASK == gtk.gdk.SHIFT_MASK) \
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \ and (event.keyval == 86):
and (event.get_keyval == gdk.keyval_from_name('V')):
feed_paste(widget) feed_paste(widget)
else: else:
# propagate to every terminal # propagate to every terminal
@ -405,7 +286,7 @@ class CruSSH:
self.EntryBox.connect("key_release_event", feed_input) self.EntryBox.connect("key_release_event", feed_input)
self.EntryBox.connect("paste_clipboard", feed_paste) self.EntryBox.connect("paste_clipboard", feed_paste)
self.EntryBox.connect("button_press_event", click_handler) self.EntryBox.connect("button_press_event", click_handler)
MainVBox.pack_start(self.EntryBox, False, False, 0) MainVBox.pack_start(self.EntryBox, False, False)
# reflow layout on size change. # reflow layout on size change.
self.MainWin.connect("size-allocate", lambda widget, allocation: self.reflow()) self.MainWin.connect("size-allocate", lambda widget, allocation: self.reflow())
@ -413,66 +294,62 @@ class CruSSH:
# give EntryBox default focus on init # give EntryBox default focus on init
self.EntryBox.props.has_focus = True self.EntryBox.props.has_focus = True
def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=[]): def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=None):
self.ssh_cmd = ssh_cmd
self.ssh_args = ssh_args
# load existing config file, if present # load existing config file, if present
try: try:
# merge dicts to allow upgrade from old configs # merge dicts to allow upgrade from old configs
new_config = yaml.safe_load(open(os.path.expanduser('~/.config/crussh.yml'))) new_config = json.load(open(os.path.expanduser('~/.crusshrc')))
if new_config is not None: self.Config.update(new_config)
self.Config.update(new_config) except:
except (FileNotFoundError, yaml.scanner.ScannerError):
pass pass
# init all terminals # init all terminals
if isinstance(hosts, list): for host in hosts:
for host in hosts: terminal = vte.Terminal()
self.addHost(host) # TODO: disable only this terminal widget on child exit
else: # v.connect("child-exited", lambda term: gtk.main_quit())
self.addHost(str(hosts)) cmd_str = ssh_cmd
if ssh_args is not None:
cmd_str += " " + ssh_args
cmd_str += " " + host
cmd = cmd_str.split(' ')
terminal.fork_command(command=cmd[0], argv=cmd)
# track whether we mirror output to this terminal
terminal.copy_input = True
self.Terminals[host] = terminal
# hook terminals so they reflow layout on exit
self.Terminals[host].connect("child-exited", self.removeTerminal)
# configure all terminals # configure all terminals
self.configTerminals() self.configTerminals()
# reflow after reconfig for font size changes # reflow after reconfig for font size changes
self.initGUI() self.initGUI()
if self.Config["start-maximized"]:
self.MainWin.maximize()
self.reflow(force=True) self.reflow(force=True)
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
# Parse CLI Args # ### Parse CLI Args ###
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Connect to multiple hosts in parallel.", description="Connect to multiple hosts in parallel.",
usage="%(prog)s [OPTIONS] [--] HOST [HOST ...]", usage="%(prog)s [OPTIONS] [--] HOST [HOST ...]",
epilog="* NOTE: options before '--' will be passed directly to the SSH client.") epilog="* NOTE: You can pass options to ssh if you add '--' before your list of hosts")
parser.add_argument("--ssh", "-s", dest='ssh', default="/usr/bin/ssh", parser.add_argument("--ssh", dest='ssh', default="/usr/bin/ssh",
help="specify the SSH executable to use (default: %(default)s)") help="specify the SSH executable to use (default: %(default)s)")
parser.add_argument("--hosts-file", "-f", dest='hosts_file', default="",
help="A file containing a list of hosts to connect to.")
(args, hosts) = parser.parse_known_args() (args, hosts) = parser.parse_known_args()
# load hosts from file, if available
if args.hosts_file != "":
# hostnames are assumed to be whitespace separated
extra_hosts = open(args.hosts_file, 'r').read().split()
hosts.extend(extra_hosts)
if len(hosts) == 0: if len(hosts) == 0:
parser.print_usage() parser.print_usage()
parser.exit(2) parser.exit(2)
try: try:
offset = hosts.index("--") offset = hosts.index("--")
ssh_args = hosts[0:offset] except:
except ValueError: ssh_args = None
offset = -1 else:
ssh_args = [] ssh_args = " ".join(hosts[0:offset])
hosts = hosts[offset + 1:]
hosts = hosts[offset + 1:] ### Start Execution ###
# Start Execution #
crussh = CruSSH(hosts, args.ssh, ssh_args) crussh = CruSSH(hosts, args.ssh, ssh_args)
gtk.main() gtk.main()