Compare commits

..

26 commits
1.0 ... main

Author SHA1 Message Date
c5f1eed9cc fixed brace types in README link 2024-03-05 02:44:57 -08:00
97345e9e04 added radicle info 2024-03-05 02:40:11 -08:00
07b0d31543 finally fixed crussh up 2021-09-15 15:54:49 -07:00
a84fa83cf2 Merge pull request 'Switch to ordered list of hosts. I don't want crussh to tweak the host order.' (#1) from github/fork/jkx/master into main
lost track of this in the migration off github, but it's a good improvement, merging in.
2020-07-01 22:47:34 +00:00
58a8402a91 merged from main 2020-07-01 15:46:16 -07:00
237fb2c6c4 Merge pull request #32 from nergdron/fix
fixed, and upgraded to python 3!
2020-03-29 23:58:20 -07:00
7cdcc7c6c1 fixed, and upgraded to python 3! 2019-06-02 05:51:06 -07:00
8e5b2c53ad minor doc update 2019-03-28 20:07:29 +00:00
b4394c67ed documentation updates 2019-03-28 20:07:08 +00:00
jkx
c98742a9cc Switch to ordered list of hosts. I don't want crussh to tweak the host
order.
2017-09-25 11:06:38 +02:00
972bef1dbf Added short flag for ssh override. 2017-08-14 14:26:59 -07:00
7cae48d0d6 Fixed to work on recent Ubuntu releases, and to update copyright. 2017-08-09 14:30:59 -07:00
be08ef4b9c Name change. 2016-04-29 11:57:17 -07:00
Graeme Nordgren
90332d5041 Name changes. 2014-05-23 15:46:13 -07:00
Graeme Humphries
7aa0249136 Fixes issue #25. 2014-05-23 14:32:24 -07:00
Graeme Humphries
e45854e9a9 Fixes and closes issue 17. 2013-08-26 15:13:56 -07:00
Graeme Humphries
3b31f57972 Ignore python artifacts. 2013-07-19 14:59:43 -07:00
Graeme Humphries
3c91fcf821 Fixes and closes #19. 2013-07-19 14:46:53 -07:00
Graeme Humphries
3e5ab6f290 Fix for issue #22. 2013-07-19 13:55:00 -07:00
Graeme Humphries
4abcdfcdcb Adds functionality to load hostnames from file, for issue#17. 2013-03-15 12:22:28 -07:00
Graeme Humphries
65893e806c Fixes copy/paste issue#18. 2013-03-15 12:10:04 -07:00
Graeme Humphries
b248a1db49 Fixed bug where terminal being removed didn't remove it from edit menu list of terminals. 2013-03-15 11:50:10 -07:00
Graeme Humphries
725ceb4a1a Added explict notification on CLI of individual session disconnects. 2012-12-10 15:10:05 -08:00
Graeme Humphries
61479f398e Minor formatting change. 2012-12-10 12:20:06 -08:00
Graeme Humphries
3a9a913207 Minor formatting change. 2012-12-10 12:16:24 -08:00
Graeme Humphries
d2ecac2d6b Minor formatting change. 2012-12-10 12:15:33 -08:00
4 changed files with 324 additions and 145 deletions

2
.gitignore vendored Normal file
View file

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

42
EntryDialog.py Normal file
View file

@ -0,0 +1,42 @@
#!/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,22 +1,13 @@
crussh: a modern cssh replacement # crussh: a modern cssh replacement
================================= Available via [radicle](https://radicle.xyz/) at: `rad:z3BhWhapyVBzzuBicAyCcmMhv1yEe`
Backstory ## What are this?
---------
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.
However, cssh has a number of deficiencies in modern environments: crussh aims to be a simple replacement for cssh with the following improvements:
- 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.
@ -24,35 +15,56 @@ problems. It does so with the following features:
- 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 python2, python-gtk2, and python-vte. * Install python3 and python3-gi.
- Clone and symlink to your bin dir. * Clone and symlink to your bin dir:
git clone https://github.com/unit3/crussh.git ```bash
ln -s crussh/crussh.py ~/bin/crussh git clone https://github.com/nergdron/crussh.git
- Run "crussh HOST [HOST ...]" ln -s $(pwd)/crussh/crussh.py ~/bin/crussh
```
Usage Tips Run ```crussh HOST [HOST ...]```
----------
Doing a clustered paste isn't completely obvious. The following methods will work: ## Examples
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 & TODO ## Bugs & To Do
-----------
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/unit3/crussh/issues). see the [crussh GitHub issues page](https://github.com/nergdron/crussh/issues).
Copyright and License ## Copyright and License
---------------------
crussh is copyright 2012 by Graeme Humphries <graeme@sudo.ca>. crussh is copyright 2012-2019 by Tessa Nordgren <tessa@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

341
crussh.py
View file

@ -1,36 +1,31 @@
#!/usr/bin/env python #!/usr/bin/env python3
# A cssh replacement written in Python / GTK. # A cssh replacement written in Python / GTK.
# (c)2012 - Graeme Humphries <graeme@sudo.ca>. # (c)2012-2019 - Tessa Nordgren <tessa@sudo.ca>.
# Released under the GPL, version 3: http://www.gnu.org/licenses/ # Released under the GPL, version 3: http://www.gnu.org/licenses/
# Requires: python-gtk2 python-vte import gi
gi.require_version("Gtk", "3.0")
import sys from gi.repository import Gtk as gtk
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 json import yaml
import os.path import os.path
from EntryDialog import EntryDialog
try: from collections import OrderedDict
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:
@ -45,46 +40,65 @@ 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()
### GUI Objects ### def maximized_hook(self, checkbutton):
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) MainBox.pack_start(TermConfFrame, False, False, 0)
TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.EXPAND) TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND)
FontConf = gtk.FontButton(fontname=self.Config["font"]) FontConf = gtk.FontButton(font=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.EXPAND) TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.AttachOptions.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:"), fill=False, expand=False) SizeBox.pack_start(gtk.Label("Min Width:"), False, False, 0)
WidthEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1)) WidthEntry = gtk.SpinButton()
# 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, fill=False, expand=False) SizeBox.pack_start(WidthEntry, False, False, 0)
SizeBox.pack_start(gtk.Label("Min Height:"), fill=False, expand=False) SizeBox.pack_start(gtk.Label("Min Height:"), False, False, 0)
HeightEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-height"], lower=1, upper=9999, step_incr=1)) HeightEntry = gtk.SpinButton()
HeightEntry.set_range(1, 9999)
HeightEntry.connect("value-changed", self.height_hook) HeightEntry.connect("value-changed", self.height_hook)
SizeBox.pack_start(HeightEntry, fill=False, expand=False) SizeBox.pack_start(HeightEntry, False, False, 0)
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, fill=False) ConfirmBox.pack_start(CancelButton, False, False, 0)
SaveButton = gtk.Button(stock=gtk.STOCK_SAVE) SaveButton = gtk.Button(stock=gtk.STOCK_SAVE)
ConfirmBox.pack_start(SaveButton, fill=False) ConfirmBox.pack_start(SaveButton, False, False, 0)
MainBox.pack_start(ConfirmBox, fill=False, expand=False) MainBox.pack_start(ConfirmBox, False, False, 0)
# wire up behaviour # wire up behaviour
CancelButton.connect("clicked", lambda discard: self.MainWin.destroy()) CancelButton.connect("clicked", lambda discard: self.MainWin.destroy())
@ -100,35 +114,94 @@ class CruSSHConf:
self.initGUI(save_func) self.initGUI(save_func)
### CruSSH! ### # Hosts Mask Dialog #
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 Bold 10" "font": "Ubuntu Mono,monospace Bold 10",
"start-maximized": True
} }
### State Vars ### # State Vars #
Terminals = {} Terminals = OrderedDict()
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 = sorted(self.Terminals.keys(), reverse=True) hosts = list(self.Terminals.keys())
hosts.reverse()
for host in hosts: for host in hosts:
if self.Terminals[host].parent == self.LayoutTable: if self.Terminals[host].get_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
@ -149,7 +222,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.allocation size = self.MainWin.get_size()
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
@ -158,35 +231,76 @@ 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_from_string(self.Config["font"]) terminal.set_font(FontDescription(self.Config["font"]))
self.TermMinWidth = (terminal.get_char_width() * self.Config["min-width"]) + terminal.get_padding()[0] self.TermMinWidth = terminal.get_char_width() * self.Config["min-width"]
self.TermMinHeight = (terminal.get_char_height() * self.Config["min-height"]) + terminal.get_padding()[1] self.TermMinHeight = terminal.get_char_height() * self.Config["min-height"]
def removeTerminal(self, terminal): def removeTerminal(self, terminal, status):
# brute force search since we don't actually know the hostname from the to_del = []
# 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)
to_del.append(host)
for host in to_del:
del self.Terminals[host] del self.Terminals[host]
self.reflow(force=True) self.reflow(force=True)
def initGUI(self): def initGUI(self):
theme = gtk.icon_theme_get_default() theme = gtk.IconTheme.get_default()
if theme.has_icon("terminal"): if theme.has_icon("terminal"):
icon = theme.lookup_icon("terminal", 128, flags=gtk.ICON_LOOKUP_USE_BUILTIN) icon = theme.lookup_icon("terminal", 128, flags=gtk.IconLookupFlags.USE_BUILTIN)
if icon is not None: if icon is not None:
gtk.window_set_default_icon(icon.load_icon()) self.MainWin.set_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())
@ -194,11 +308,26 @@ class CruSSH:
self.MainWin.add(MainVBox) self.MainWin.add(MainVBox)
MainMenuBar = gtk.MenuBar() MainMenuBar = gtk.MenuBar()
MainVBox.pack_start(MainMenuBar, fill=True, expand=False) MainVBox.pack_start(MainMenuBar, False, False, 0)
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)
@ -208,16 +337,8 @@ 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.set_submenu(self.ActiveHostsMenu) ActiveHostsItem.connect("activate", lambda discard: HostsMask(self.Terminals))
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")
@ -226,26 +347,23 @@ 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_json = json.dumps(self.Config, sort_keys=True, indent=4) conf_yaml = yaml.dump(self.Config, sort_keys=True, indent=4)
try: conf_file = open(os.path.expanduser("~/.config/crussh.yml"), 'w')
conf_file = open(os.path.expanduser("~/.crusshrc"), 'w') conf_file.write(conf_yaml)
conf_file.write(conf_json)
conf_file.close() 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.POLICY_NEVER self.ScrollWin.props.hscrollbar_policy = gtk.PolicyType.NEVER
self.ScrollWin.props.vscrollbar_policy = gtk.POLICY_ALWAYS self.ScrollWin.props.vscrollbar_policy = gtk.PolicyType.ALWAYS
self.ScrollWin.props.shadow_type = gtk.SHADOW_ETCHED_IN self.ScrollWin.props.shadow_type = gtk.ShadowType.ETCHED_IN
MainVBox.pack_start(self.ScrollWin) MainVBox.pack_start(self.ScrollWin, True, True, 0)
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_with_viewport(self.LayoutTable) self.ScrollWin.add(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.
@ -256,17 +374,18 @@ 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].feed_child(self.Clipboard.wait_for_text()) self.Terminals[host].paste_clipboard()
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) \
and (event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK) \ if (event.type == gdk.EventType.KEY_PRESS) \
and (event.state & gtk.gdk.SHIFT_MASK == gtk.gdk.SHIFT_MASK) \ and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
and (event.keyval == 86): and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
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
@ -286,7 +405,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) MainVBox.pack_start(self.EntryBox, False, False, 0)
# 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())
@ -294,62 +413,66 @@ 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=None): def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=[]):
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 = json.load(open(os.path.expanduser('~/.crusshrc'))) new_config = yaml.safe_load(open(os.path.expanduser('~/.config/crussh.yml')))
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: You can pass options to ssh if you add '--' before your list of hosts") epilog="* NOTE: options before '--' will be passed directly to the SSH client.")
parser.add_argument("--ssh", dest='ssh', default="/usr/bin/ssh", parser.add_argument("--ssh", "-s", 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("--")
except: ssh_args = hosts[0:offset]
ssh_args = None except ValueError:
else: offset = -1
ssh_args = " ".join(hosts[0:offset]) ssh_args = []
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()