This probably will be reiterate later, as password store need to be updated as well (i.e. removing changes need to be committed in git).
540 lines
18 KiB
Python
Executable File
540 lines
18 KiB
Python
Executable File
#!/usr/bin/env python
|
|
import os
|
|
import signal
|
|
import shutil
|
|
import subprocess
|
|
|
|
import gi
|
|
gi.require_version('Gdk', '3.0')
|
|
gi.require_version('Gtk', '3.0')
|
|
gi.require_version('Pango', '1.0')
|
|
from gi.repository import GLib
|
|
from gi.repository import Gdk
|
|
from gi.repository import Gtk
|
|
from gi.repository import Pango
|
|
import yaml
|
|
|
|
|
|
XDG_CONF_DIR = os.getenv('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
|
|
|
|
|
|
class GTKPass(Gtk.Window):
|
|
|
|
def __init__(self):
|
|
Gtk.Window.__init__(self, title="GTKPass")
|
|
|
|
self.passs = PassStore()
|
|
self.passs.gather_pass_tree()
|
|
self.conf = self.passs.conf
|
|
self._border = 5
|
|
self._expand = False
|
|
self._selected = None
|
|
self.make_ui()
|
|
|
|
def make_ui(self):
|
|
if (self.conf.get('ui', {}).get('width') and
|
|
self.conf.get('ui', {}).get('height')):
|
|
self.set_size_request(self.conf['ui']['width'],
|
|
self.conf['ui']['height'])
|
|
self.set_resizable(True)
|
|
self.set_border_width(self._border)
|
|
|
|
self.tree_store = Gtk.TreeStore(bool, str, Pango.Weight, str, str,
|
|
bool)
|
|
self.add_nodes(self.passs.data, None)
|
|
|
|
# clipboard
|
|
self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
|
|
|
# attach keyboard events
|
|
self.connect("key-press-event", self.on_key_press_event)
|
|
|
|
# main box
|
|
mainbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
|
spacing=self._border)
|
|
mainbox.set_homogeneous(False)
|
|
self.add(mainbox)
|
|
|
|
# add toolbar
|
|
toolbar = self.create_toolbar()
|
|
mainbox.pack_start(child=toolbar, expand=False, fill=False, padding=0)
|
|
|
|
# pane
|
|
pane = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
|
|
mainbox.pack_start(child=pane, expand=True, fill=True, padding=0)
|
|
|
|
# box for search entry and treeview
|
|
lbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
|
spacing=self._border)
|
|
lbox.set_homogeneous(False)
|
|
pane.pack1(child=lbox, resize=True, shrink=False)
|
|
|
|
# search box
|
|
self.search = Gtk.SearchEntry()
|
|
self.search.set_placeholder_text("Search password")
|
|
self.search.connect("changed", self.refresh)
|
|
lbox.pack_start(child=self.search, expand=False, fill=False, padding=0)
|
|
|
|
# treeview with filtering
|
|
self.ts_filter = self.tree_store.filter_new()
|
|
self.ts_filter.set_visible_column(0)
|
|
self.treeview = Gtk.TreeView(model=self.ts_filter)
|
|
self.treeview.set_activate_on_single_click(True)
|
|
self.treeview.set_headers_visible(False)
|
|
self.treeview.connect("key-release-event", self.on_treeview_keypress)
|
|
self.treeview.connect('row-activated', self.on_row_activated)
|
|
|
|
icon_renderer = Gtk.CellRendererPixbuf()
|
|
text_renderer = Gtk.CellRendererText()
|
|
column = Gtk.TreeViewColumn()
|
|
column.pack_start(icon_renderer, False)
|
|
column.pack_start(text_renderer, False)
|
|
column.add_attribute(text_renderer, "text", 1)
|
|
column.add_attribute(text_renderer, "weight", 2)
|
|
column.add_attribute(icon_renderer, "icon_name", 3)
|
|
self.treeview.append_column(column)
|
|
selection = self.treeview.get_selection()
|
|
selection.connect('changed', self.on_selected)
|
|
|
|
# scrollview to hold treeview
|
|
tv_sw = Gtk.ScrolledWindow()
|
|
tv_sw.add(self.treeview)
|
|
lbox.pack_start(child=tv_sw, expand=True, fill=True, padding=0)
|
|
|
|
# display things
|
|
rbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
|
spacing=self._border)
|
|
rbox.set_homogeneous(False)
|
|
|
|
# entry preview
|
|
self.grid = Gtk.Grid()
|
|
self.grid.set_column_homogeneous(False)
|
|
self.grid.set_row_homogeneous(False)
|
|
self.grid.set_row_spacing(10)
|
|
self.grid.set_column_spacing(10)
|
|
|
|
self.label = Gtk.Label()
|
|
self.label.set_use_markup(True)
|
|
self.label.set_halign(Gtk.Align.START)
|
|
|
|
self.grid.attach(self.label, 0, 0, 2, 1)
|
|
for row, label in enumerate(['Username:', 'Password:',
|
|
'URL:', 'Notes:'], start=1):
|
|
label = Gtk.Label(label=label)
|
|
label.set_halign(Gtk.Align.END)
|
|
self.grid.attach(label, 0, row, 1, 1)
|
|
|
|
sw = Gtk.ScrolledWindow()
|
|
self.textview = Gtk.TextView()
|
|
self.textview.set_editable(False)
|
|
self.textview.set_hexpand(True)
|
|
self.textview.set_vexpand(True)
|
|
sw.add(self.textview)
|
|
self.grid.attach(sw, 1, 4, 1, 1)
|
|
self.user = Gtk.Entry()
|
|
self.url = Gtk.Entry()
|
|
self.password = Gtk.Entry()
|
|
self.password.set_visibility(False)
|
|
self.password.set_icon_from_icon_name(1, 'view-reveal-symbolic')
|
|
self.password.set_icon_activatable(1, True)
|
|
self.password.connect('icon-press', lambda obj, icon, ev:
|
|
obj.set_visibility(not obj.get_visibility()))
|
|
for widget in (self.user, self.password, self.url):
|
|
widget.set_editable(False)
|
|
|
|
self.grid.attach(self.user, 1, 1, 1, 1)
|
|
self.grid.attach(self.password, 1, 2, 1, 1)
|
|
self.grid.attach(self.url, 1, 3, 1, 1)
|
|
pane.pack2(child=self.grid, resize=True, shrink=False)
|
|
|
|
# set split in ratio 40/60
|
|
pane.set_position(int(4 * self.get_size()[0]/10))
|
|
|
|
self.search.grab_focus()
|
|
self.show_all()
|
|
self._set_visible(self.grid, False)
|
|
self.refresh()
|
|
|
|
def _set_visible(self, obj, set_visible=True):
|
|
for child in obj.get_children():
|
|
if hasattr(child, 'get_children'):
|
|
self._set_visible(child, set_visible)
|
|
else:
|
|
child.show() if set_visible else child.hide()
|
|
self.textview.show() if set_visible else self.textview.hide()
|
|
|
|
def recreate_tree_store(self):
|
|
self.passs.gather_pass_tree()
|
|
self.tree_store.clear()
|
|
self.add_nodes(self.passs.data, None)
|
|
|
|
def create_toolbar(self):
|
|
toolbar = Gtk.Toolbar()
|
|
|
|
b_new = Gtk.ToolButton()
|
|
b_new.set_icon_name("document-new-symbolic")
|
|
toolbar.insert(b_new, 0)
|
|
|
|
b_dir = Gtk.ToolButton()
|
|
b_dir.set_icon_name("folder-new-symbolic")
|
|
b_dir.connect("clicked", self.on_new_dir)
|
|
toolbar.insert(b_dir, 1)
|
|
|
|
b_edit = Gtk.ToolButton()
|
|
b_edit.set_icon_name("document-edit-symbolic")
|
|
toolbar.insert(b_edit, 2)
|
|
|
|
b_del = Gtk.ToolButton()
|
|
b_del.set_icon_name("edit-delete-symbolic")
|
|
toolbar.insert(b_del, 3)
|
|
|
|
b_gitpush = Gtk.ToolButton()
|
|
b_gitpush.set_icon_name("go-up-symbolic")
|
|
toolbar.insert(b_gitpush, 4)
|
|
|
|
b_gitpull = Gtk.ToolButton()
|
|
b_gitpull.set_icon_name("go-down-symbolic")
|
|
toolbar.insert(b_gitpull, 5)
|
|
|
|
return toolbar
|
|
|
|
def add_nodes(self, data, parent):
|
|
"Create the tree nodes from a hierarchical data structure"
|
|
for obj in data.sorted_children:
|
|
if isinstance(obj, Tree):
|
|
child = self.tree_store.append(parent,
|
|
[True, obj.name,
|
|
Pango.Weight.NORMAL,
|
|
"folder",
|
|
obj.path,
|
|
False])
|
|
self.add_nodes(obj, child)
|
|
else:
|
|
self.tree_store.append(parent, [True, obj.name,
|
|
Pango.Weight.NORMAL,
|
|
"application-x-generic",
|
|
obj.path,
|
|
True])
|
|
|
|
def refresh(self, _widget=None):
|
|
query = self.search.get_text().lower()
|
|
if query == "":
|
|
self.tree_store.foreach(self.reset_row, True)
|
|
if self._expand:
|
|
self.treeview.expand_all()
|
|
else:
|
|
self.treeview.collapse_all()
|
|
else:
|
|
self.tree_store.foreach(self.reset_row, False)
|
|
self.tree_store.foreach(self.show_matches, query, True)
|
|
self.treeview.expand_all()
|
|
self.ts_filter.refilter()
|
|
|
|
def reset_row(self, model, path, iter, make_visible):
|
|
self.tree_store.set_value(iter, 2, Pango.Weight.NORMAL)
|
|
self.tree_store.set_value(iter, 0, make_visible)
|
|
|
|
def make_path_visible(self, model, iter):
|
|
while iter:
|
|
self.tree_store.set_value(iter, 0, True)
|
|
iter = model.iter_parent(iter)
|
|
|
|
def make_subtree_visible(self, model, iter):
|
|
for i in range(model.iter_n_children(iter)):
|
|
subtree = model.iter_nth_child(iter, i)
|
|
if model.get_value(subtree, 0):
|
|
continue
|
|
self.tree_store.set_value(subtree, 0, True)
|
|
self.make_subtree_visible(model, subtree)
|
|
|
|
def show_matches(self, model, path, iter, query,
|
|
show_subtrees_of_matches):
|
|
text = model.get_value(iter, 1).lower()
|
|
if query in text:
|
|
# Highlight direct match with bold
|
|
self.tree_store.set_value(iter, 2, Pango.Weight.BOLD)
|
|
# Propagate visibility change up
|
|
self.make_path_visible(model, iter)
|
|
if show_subtrees_of_matches:
|
|
# Propagate visibility change down
|
|
self.make_subtree_visible(model, iter)
|
|
return
|
|
|
|
def on_row_activated(self, treeview, treepath, treeview_col):
|
|
selection = treeview.get_selection()
|
|
|
|
if not selection:
|
|
return
|
|
|
|
model, treeiter = selection.get_selected()
|
|
|
|
if (self._selected is not None and
|
|
self._selected == model[treeiter][4]):
|
|
self._selected = None
|
|
selection.unselect_all()
|
|
else:
|
|
self._selected = model[treeiter][4]
|
|
|
|
def on_selected(self, selection):
|
|
model, treeiter = selection.get_selected()
|
|
|
|
self.label.set_label('')
|
|
self.password.set_text('')
|
|
self.user.set_text('')
|
|
self.url.set_text('')
|
|
self.textview.get_buffer().set_text('')
|
|
|
|
if not (treeiter and model[treeiter] and model[treeiter][5]):
|
|
self._set_visible(self.grid, False)
|
|
return
|
|
|
|
success, data = self.passs.get_pass(model[treeiter][4])
|
|
if not success:
|
|
self.label.set_label(f'<span foreground="red" size="x-large">'
|
|
f'There is an error:\n{data}</span>')
|
|
self.label.set_visible(True)
|
|
return
|
|
|
|
self.label.set_label(f'<span size="x-large">{model[treeiter][4]}'
|
|
f'</span>')
|
|
output = data.split('\n')
|
|
|
|
for count, line in enumerate(output):
|
|
if count == 0:
|
|
self.password.set_text(line.strip())
|
|
continue
|
|
if (output[count].lower().startswith('user:') or
|
|
output[count].lower().startswith('username:')):
|
|
self.user.set_text(line.split(':')[1].strip())
|
|
continue
|
|
if output[count].lower().startswith('url:'):
|
|
self.url.set_text(':'.join(line.split(':')[1:]).strip())
|
|
continue
|
|
if output[count].lower().startswith('notes:'):
|
|
self.textview.get_buffer().set_text("\n".join(output[count:])
|
|
[6:].strip())
|
|
break
|
|
self._set_visible(self.grid, True)
|
|
|
|
def on_treeview_keypress(self, treeview, event):
|
|
# expand current branch on right cursor key or enter/return
|
|
if (event.keyval in (Gdk.KEY_Right, Gdk.KEY_Return) and
|
|
treeview.get_cursor()[0]):
|
|
treeview.expand_row(treeview.get_cursor()[0], False)
|
|
# collapse row under cursor on left cursor key
|
|
if event.keyval == Gdk.KEY_Left and treeview.get_cursor()[0]:
|
|
treeview.collapse_row(treeview.get_cursor()[0])
|
|
|
|
def on_new_dir(self, button):
|
|
if self._selected is None:
|
|
print('none selected')
|
|
path = ''
|
|
else:
|
|
print(f'{self._selected} selected')
|
|
path = self._selected
|
|
|
|
dialog = NewDirDialog(self, path)
|
|
response = dialog.run()
|
|
dirname = dialog.get_dirname()
|
|
dialog.destroy()
|
|
|
|
if response != Gtk.ResponseType.OK:
|
|
return
|
|
|
|
result, msg = self.passs.new_dir(os.path.join(path, dirname))
|
|
if not result:
|
|
dialog = Gtk.MessageDialog(transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.INFO,
|
|
buttons=Gtk.ButtonsType.CLOSE,
|
|
text='There was an error')
|
|
dialog.format_secondary_text(msg)
|
|
dialog.run()
|
|
dialog.destroy()
|
|
|
|
self.recreate_tree_store()
|
|
self.refresh()
|
|
|
|
def on_key_press_event(self, widget, event):
|
|
ctrl = (event.state & Gdk.ModifierType.CONTROL_MASK)
|
|
if ctrl and event.keyval == Gdk.KEY_b:
|
|
if self.user.get_text != '':
|
|
self.clipboard.set_text(self.user.get_text(), -1)
|
|
elif ctrl and event.keyval == Gdk.KEY_c:
|
|
if self.password.get_text != '':
|
|
self.clipboard.set_text(self.password.get_text(), -1)
|
|
# TODO: clear clipboard after a minute or so.
|
|
|
|
|
|
class NewDirDialog(Gtk.Dialog):
|
|
def __init__(self, parent, path):
|
|
super().__init__(title="Enter new directory", transient_for=parent,
|
|
flags=0)
|
|
self.set_modal(True)
|
|
self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
|
|
|
label = Gtk.Label(label=f"Create new directory under "
|
|
f"{'/' if not path else path} path")
|
|
|
|
box = self.get_content_area()
|
|
box.add(label)
|
|
self.entry = Gtk.Entry()
|
|
self.entry.connect("key-release-event", self.on_release_key)
|
|
box.add(self.entry)
|
|
self.show_all()
|
|
|
|
def on_release_key(self, entry, event):
|
|
if event.keyval == Gdk.KEY_Return:
|
|
self.response(Gtk.ResponseType.OK)
|
|
|
|
def get_dirname(self):
|
|
return self.entry.get_text()
|
|
|
|
|
|
class Leaf:
|
|
"""A simple class to hold Leaf data"""
|
|
def __init__(self, name, path):
|
|
self.name = name
|
|
self.path = path
|
|
|
|
def __repr__(self):
|
|
return f"Leaf: {self.name}"
|
|
|
|
|
|
class Tree:
|
|
"""A class to hold and manipulate leafs/other branches"""
|
|
def __init__(self, name=None, path=None):
|
|
self.name = name
|
|
self.children = []
|
|
self.path = path
|
|
|
|
def __repr__(self):
|
|
return f"Tree: {self.name}"
|
|
|
|
@property
|
|
def sorted_children(self):
|
|
files = {}
|
|
dirs = {}
|
|
for i in self.children:
|
|
if isinstance(i, Leaf):
|
|
files[i.name] = i
|
|
else:
|
|
dirs[i.name] = i
|
|
return ([dirs[x] for x in sorted(dirs)] +
|
|
[files[x] for x in sorted(files)])
|
|
|
|
|
|
class PassStore:
|
|
"""Password store GUI app"""
|
|
NON_EMPTY = 1
|
|
SUCCESS = 0
|
|
ERROR = 2
|
|
|
|
def __init__(self):
|
|
self.store_path = self._get_store_path()
|
|
self.data = Tree()
|
|
self.conf = {}
|
|
self._read_config()
|
|
|
|
def _get_store_path(self):
|
|
path = os.environ.get('$PASSWORD_STORE_DIR')
|
|
if path:
|
|
_check_pass_store(path)
|
|
return path
|
|
|
|
path = os.path.expanduser('~/.password-store')
|
|
_check_pass_store(path)
|
|
return path
|
|
|
|
def gather_pass_tree(self):
|
|
self._gather_pass_tree(self.data, self.store_path, '')
|
|
|
|
def get_pass(self, path):
|
|
proc = subprocess.run(['pass', path], capture_output=True,
|
|
encoding='utf-8')
|
|
if proc.returncode == 0:
|
|
return True, proc.stdout
|
|
else:
|
|
return False, proc.stderr
|
|
|
|
def new_dir(self, dirname):
|
|
path = os.path.join(self.store_path, dirname)
|
|
try:
|
|
os.mkdir(os.path.join('/root', path), mode=500)
|
|
return True, ''
|
|
except IOError as exc:
|
|
return False, str(exc)
|
|
|
|
def delete(self, item, recursively=False):
|
|
path = os.path.join(self.store_path, item)
|
|
|
|
if os.path.exists(path) and os.path.isdir(path) and recursively:
|
|
try:
|
|
shutil.rmtree(path)
|
|
except IOError as exc:
|
|
return self.ERROR, str(exc)
|
|
elif os.path.exists(path) and os.path.isdir(path):
|
|
_, files, dirs = next(os.walk(path))
|
|
if files or dirs:
|
|
return self.NON_EMPTY, ""
|
|
try:
|
|
shutil.rmtree(path)
|
|
except IOError as exc:
|
|
return self.ERROR, str(exc)
|
|
elif not os.path.exists and os.path.isfile(path + '.gpg'):
|
|
try:
|
|
os.unlink(path + '.gpg')
|
|
except IOError as exc:
|
|
return self.ERROR, str(exc)
|
|
|
|
return self.SUCCESS, ''
|
|
|
|
def _gather_pass_tree(self, model, root, dirname):
|
|
fullpath = os.path.join(root, dirname)
|
|
ps_path = fullpath[len(self.store_path)+1:]
|
|
|
|
root, dirs, files = next(os.walk(fullpath))
|
|
for fname in files:
|
|
if (fname in ['.gitattributes', '.gpg-id'] or
|
|
not fname.lower().endswith('.gpg')):
|
|
continue
|
|
fname = fname[:-4] # chop off extension
|
|
model.children.append(Leaf(fname, os.path.join(ps_path, fname)))
|
|
|
|
for dname in dirs:
|
|
if dname == '.git':
|
|
continue
|
|
t = Tree(dname, os.path.join(ps_path, dname))
|
|
model.children.append(t)
|
|
self._gather_pass_tree(t, root, dname)
|
|
|
|
def _read_config(self):
|
|
conf = os.path.join(XDG_CONF_DIR, 'gtkpass.yaml')
|
|
|
|
try:
|
|
with open(conf) as fobj:
|
|
self.conf = yaml.safe_load(fobj)
|
|
except OSError as e:
|
|
print('Warning: There was an error on loading configuration '
|
|
'file:', e)
|
|
pass
|
|
|
|
|
|
def _check_pass_store(path):
|
|
if not os.path.exists(path) or not os.path.isdir(path):
|
|
raise IOError("Path for password store `%s' either doesn't exists or "
|
|
"is not a directory", path)
|
|
|
|
|
|
def main():
|
|
app = GTKPass()
|
|
app.connect("delete-event", Gtk.main_quit)
|
|
|
|
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit)
|
|
Gtk.main()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|