1669 lines
61 KiB
Python
Executable File
1669 lines
61 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# This file is part of I38.
|
|
|
|
# I38 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation,
|
|
# either version 3 of the License, or (at your option) any later version.
|
|
|
|
# I38 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
|
# PURPOSE. See the GNU General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU General Public License along with I38. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import gi
|
|
gi.require_version('Gtk', '3.0')
|
|
gi.require_version('Atk', '1.0')
|
|
from gi.repository import Gtk, Gdk, GLib, Atk
|
|
import subprocess
|
|
import shutil
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
class PassManager(Gtk.Window):
|
|
def __init__(self):
|
|
super().__init__(title="I38 Password Manager")
|
|
self.set_default_size(600, 500)
|
|
self.set_border_width(10)
|
|
|
|
# Check dependencies
|
|
if not shutil.which('pass'):
|
|
self.show_error_and_exit("pass is not installed. Please install pass first.")
|
|
return
|
|
|
|
# Initialize data structures
|
|
self.passwordStore = Gtk.TreeStore(str, str, bool, bool) # name, fullPath, isFolder, hasMetadata
|
|
self.filteredStore = None
|
|
self.searchText = ""
|
|
self.clipboardTimer = None
|
|
self.isGitRepo = self.detect_git_repo()
|
|
|
|
# Check if password store is initialized
|
|
passwordStoreDir = Path.home() / '.password-store'
|
|
if not passwordStoreDir.exists():
|
|
self.show_init_dialog()
|
|
return
|
|
|
|
# Build UI
|
|
self.notebook = Gtk.Notebook()
|
|
self.add(self.notebook)
|
|
|
|
self.build_browse_tab()
|
|
self.build_add_tab()
|
|
self.build_organize_tab()
|
|
if self.isGitRepo:
|
|
self.build_git_tab()
|
|
|
|
# Set up keyboard shortcuts
|
|
self.connect("key-press-event", self.on_key_press)
|
|
self.connect("delete-event", self.on_delete_event)
|
|
|
|
# Set up accessibility
|
|
self.setup_accessibility()
|
|
|
|
# Populate password list
|
|
self.populate_password_list()
|
|
|
|
# Auto-pull from remote if git is configured
|
|
if self.isGitRepo and self.has_git_remote():
|
|
GLib.idle_add(self.auto_pull_on_startup)
|
|
|
|
self.show_all()
|
|
|
|
def show_error_and_exit(self, message):
|
|
"""Show error dialog and exit"""
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.ERROR,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text=message
|
|
)
|
|
dialog.run()
|
|
dialog.destroy()
|
|
Gtk.main_quit()
|
|
|
|
def show_init_dialog(self):
|
|
"""Show dialog when password store is not initialized"""
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.QUESTION,
|
|
buttons=Gtk.ButtonsType.OK_CANCEL,
|
|
text="Password store not initialized"
|
|
)
|
|
dialog.format_secondary_text(
|
|
"Enter your GPG key ID to initialize the password store.\n"
|
|
"This is typically your email address or GPG fingerprint."
|
|
)
|
|
|
|
# Add entry field for GPG ID
|
|
contentArea = dialog.get_content_area()
|
|
gpgIdEntry = Gtk.Entry()
|
|
gpgIdEntry.set_placeholder_text("your-email@example.com or GPG ID")
|
|
contentArea.pack_start(gpgIdEntry, False, False, 5)
|
|
dialog.show_all()
|
|
|
|
response = dialog.run()
|
|
gpgId = gpgIdEntry.get_text().strip()
|
|
dialog.destroy()
|
|
|
|
if response == Gtk.ResponseType.OK and gpgId:
|
|
# Initialize pass with the provided GPG ID
|
|
result = subprocess.run(
|
|
['pass', 'init', gpgId],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
# Now build the UI since pass is initialized
|
|
self.notebook = Gtk.Notebook()
|
|
self.add(self.notebook)
|
|
|
|
self.build_browse_tab()
|
|
self.build_add_tab()
|
|
self.build_organize_tab()
|
|
self.isGitRepo = self.detect_git_repo()
|
|
if self.isGitRepo:
|
|
self.build_git_tab()
|
|
|
|
self.connect("key-press-event", self.on_key_press)
|
|
self.connect("delete-event", self.on_delete_event)
|
|
|
|
self.setup_accessibility()
|
|
self.populate_password_list()
|
|
self.show_all()
|
|
else:
|
|
self.show_error_and_exit(f"Failed to initialize pass: {result.stderr}")
|
|
else:
|
|
self.destroy()
|
|
Gtk.main_quit()
|
|
|
|
def detect_git_repo(self):
|
|
"""Check if password store is a git repository"""
|
|
gitDir = Path.home() / '.password-store' / '.git'
|
|
return gitDir.exists()
|
|
|
|
def has_git_remote(self):
|
|
"""Check if git repository has a remote configured"""
|
|
if not self.isGitRepo:
|
|
return False
|
|
|
|
result = subprocess.run(
|
|
['git', '-C', str(Path.home() / '.password-store'), 'remote'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
return result.returncode == 0 and result.stdout.strip() != ""
|
|
|
|
def validate_git_url(self, url):
|
|
"""Validate git URL format"""
|
|
if not url or not url.strip():
|
|
return False
|
|
|
|
url = url.strip()
|
|
|
|
# Common git URL patterns
|
|
patterns = [
|
|
r'^git@[\w\.-]+:[\w\-/\.]+\.git$', # git@host:path/repo.git
|
|
r'^git@[\w\.-]+:[\w\-/\.]+$', # git@host:path/repo
|
|
r'^https?://[\w\.-]+/[\w\-/\.]+\.git$', # https://host/path/repo.git
|
|
r'^https?://[\w\.-]+/[\w\-/\.]+$', # https://host/path/repo
|
|
r'^ssh://[\w@\.-]+/[\w\-/\.~]+$', # ssh://user@host/path
|
|
r'^[\w]+@[\w\.-]+:[\w\-/\.~]+$', # user@host:path
|
|
]
|
|
|
|
for pattern in patterns:
|
|
if re.match(pattern, url):
|
|
return True
|
|
|
|
return False
|
|
|
|
def test_git_url_connectivity(self, url):
|
|
"""Test if git URL is accessible"""
|
|
result = subprocess.run(
|
|
['git', 'ls-remote', url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10
|
|
)
|
|
|
|
return result.returncode == 0
|
|
|
|
def build_browse_tab(self):
|
|
"""Build the browse and view tab"""
|
|
browseBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
self.notebook.append_page(browseBox, Gtk.Label(label="Browse"))
|
|
|
|
# Git sync info banner (only show if git is not initialized)
|
|
if not self.isGitRepo:
|
|
infoBanner = Gtk.InfoBar()
|
|
infoBanner.set_message_type(Gtk.MessageType.INFO)
|
|
contentArea = infoBanner.get_content_area()
|
|
|
|
infoLabel = Gtk.Label(label="Git sync is not enabled. Passwords are stored locally only.")
|
|
contentArea.pack_start(infoLabel, False, False, 0)
|
|
|
|
setupButton = Gtk.Button(label="Set Up Git Sync")
|
|
setupButton.connect("clicked", self.on_setup_git_sync_clicked)
|
|
infoBanner.add_action_widget(setupButton, Gtk.ResponseType.NONE)
|
|
|
|
browseBox.pack_start(infoBanner, False, False, 0)
|
|
|
|
# Search box
|
|
searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
searchLabel = Gtk.Label(label="_Search:")
|
|
searchLabel.set_use_underline(True)
|
|
self.searchEntry = Gtk.Entry()
|
|
self.searchEntry.set_placeholder_text("Type to search passwords...")
|
|
self.searchEntry.connect("changed", self.on_search_changed)
|
|
searchLabel.set_mnemonic_widget(self.searchEntry)
|
|
searchBox.pack_start(searchLabel, False, False, 0)
|
|
searchBox.pack_start(self.searchEntry, True, True, 0)
|
|
browseBox.pack_start(searchBox, False, False, 0)
|
|
|
|
# Password tree view in scrolled window
|
|
scrolledWindow = Gtk.ScrolledWindow()
|
|
scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
browseBox.pack_start(scrolledWindow, True, True, 0)
|
|
|
|
self.passwordTreeView = Gtk.TreeView()
|
|
self.passwordTreeView.set_headers_visible(True)
|
|
self.passwordTreeView.connect("row-activated", self.on_password_activated)
|
|
|
|
# Add column for password name
|
|
renderer = Gtk.CellRendererText()
|
|
column = Gtk.TreeViewColumn("Password", renderer, text=0)
|
|
column.set_sort_column_id(0)
|
|
self.passwordTreeView.append_column(column)
|
|
|
|
scrolledWindow.add(self.passwordTreeView)
|
|
|
|
# Metadata display
|
|
metadataFrame = Gtk.Frame(label="Metadata")
|
|
browseBox.pack_start(metadataFrame, False, False, 0)
|
|
|
|
metadataScrolled = Gtk.ScrolledWindow()
|
|
metadataScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
metadataScrolled.set_size_request(-1, 100)
|
|
metadataFrame.add(metadataScrolled)
|
|
|
|
self.metadataTextBuffer = Gtk.TextBuffer()
|
|
self.metadataTextView = Gtk.TextView.new_with_buffer(self.metadataTextBuffer)
|
|
self.metadataTextView.set_editable(False)
|
|
self.metadataTextView.set_wrap_mode(Gtk.WrapMode.WORD)
|
|
metadataScrolled.add(self.metadataTextView)
|
|
|
|
# Button box
|
|
buttonBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
browseBox.pack_start(buttonBox, False, False, 0)
|
|
|
|
self.copyButton = Gtk.Button(label="Copy Password (Ctrl+C)")
|
|
self.copyButton.connect("clicked", self.on_copy_clicked)
|
|
buttonBox.pack_start(self.copyButton, True, True, 0)
|
|
|
|
self.metadataButton = Gtk.Button(label="Show Metadata (Ctrl+M)")
|
|
self.metadataButton.connect("clicked", self.on_metadata_clicked)
|
|
buttonBox.pack_start(self.metadataButton, True, True, 0)
|
|
|
|
self.editButton = Gtk.Button(label="Edit (Ctrl+E)")
|
|
self.editButton.connect("clicked", self.on_edit_clicked)
|
|
buttonBox.pack_start(self.editButton, True, True, 0)
|
|
|
|
def build_add_tab(self):
|
|
"""Build the add password tab"""
|
|
addBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
self.notebook.append_page(addBox, Gtk.Label(label="Add"))
|
|
|
|
# Radio buttons for mode selection
|
|
modeBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
|
addBox.pack_start(modeBox, False, False, 0)
|
|
|
|
self.manualRadio = Gtk.RadioButton(label="Manual Entry")
|
|
self.generateRadio = Gtk.RadioButton.new_from_widget(self.manualRadio)
|
|
self.generateRadio.set_label("Generate Password")
|
|
|
|
modeBox.pack_start(self.manualRadio, False, False, 0)
|
|
modeBox.pack_start(self.generateRadio, False, False, 0)
|
|
|
|
# Manual entry panel
|
|
self.manualPanel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
|
addBox.pack_start(self.manualPanel, True, True, 0)
|
|
|
|
# Password name
|
|
nameBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
nameLabel = Gtk.Label(label="_Name:")
|
|
nameLabel.set_use_underline(True)
|
|
nameLabel.set_size_request(100, -1)
|
|
nameLabel.set_xalign(0)
|
|
self.manualNameEntry = Gtk.Entry()
|
|
self.manualNameEntry.set_placeholder_text("e.g., web/github or email/work")
|
|
nameLabel.set_mnemonic_widget(self.manualNameEntry)
|
|
nameBox.pack_start(nameLabel, False, False, 0)
|
|
nameBox.pack_start(self.manualNameEntry, True, True, 0)
|
|
self.manualPanel.pack_start(nameBox, False, False, 0)
|
|
|
|
# Password entry
|
|
passwordBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
passwordLabel = Gtk.Label(label="_Password:")
|
|
passwordLabel.set_use_underline(True)
|
|
passwordLabel.set_size_request(100, -1)
|
|
passwordLabel.set_xalign(0)
|
|
self.manualPasswordEntry = Gtk.Entry()
|
|
self.manualPasswordEntry.set_visibility(False)
|
|
passwordLabel.set_mnemonic_widget(self.manualPasswordEntry)
|
|
passwordBox.pack_start(passwordLabel, False, False, 0)
|
|
passwordBox.pack_start(self.manualPasswordEntry, True, True, 0)
|
|
self.manualPanel.pack_start(passwordBox, False, False, 0)
|
|
|
|
# Confirm password
|
|
confirmBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
confirmLabel = Gtk.Label(label="_Confirm Password:")
|
|
confirmLabel.set_use_underline(True)
|
|
confirmLabel.set_size_request(100, -1)
|
|
confirmLabel.set_xalign(0)
|
|
self.manualConfirmEntry = Gtk.Entry()
|
|
self.manualConfirmEntry.set_visibility(False)
|
|
confirmLabel.set_mnemonic_widget(self.manualConfirmEntry)
|
|
confirmBox.pack_start(confirmLabel, False, False, 0)
|
|
confirmBox.pack_start(self.manualConfirmEntry, True, True, 0)
|
|
self.manualPanel.pack_start(confirmBox, False, False, 0)
|
|
|
|
# Show password checkbox
|
|
self.showPasswordCheck = Gtk.CheckButton(label="Show password")
|
|
self.showPasswordCheck.connect("toggled", self.on_show_password_toggled)
|
|
self.manualPanel.pack_start(self.showPasswordCheck, False, False, 0)
|
|
|
|
# Metadata
|
|
metadataLabel = Gtk.Label(label="_Metadata (optional):")
|
|
metadataLabel.set_use_underline(True)
|
|
metadataLabel.set_xalign(0)
|
|
self.manualPanel.pack_start(metadataLabel, False, False, 0)
|
|
|
|
metadataScrolled = Gtk.ScrolledWindow()
|
|
metadataScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
metadataScrolled.set_size_request(-1, 100)
|
|
self.manualMetadataBuffer = Gtk.TextBuffer()
|
|
self.manualMetadataView = Gtk.TextView.new_with_buffer(self.manualMetadataBuffer)
|
|
self.manualMetadataView.set_wrap_mode(Gtk.WrapMode.WORD)
|
|
self.manualMetadataView.set_accepts_tab(False)
|
|
metadataLabel.set_mnemonic_widget(self.manualMetadataView)
|
|
metadataScrolled.add(self.manualMetadataView)
|
|
self.manualPanel.pack_start(metadataScrolled, True, True, 0)
|
|
|
|
# Generate panel
|
|
self.generatePanel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
|
addBox.pack_start(self.generatePanel, True, True, 0)
|
|
|
|
# Password name for generation
|
|
genNameBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
genNameLabel = Gtk.Label(label="_Name:")
|
|
genNameLabel.set_use_underline(True)
|
|
genNameLabel.set_size_request(100, -1)
|
|
genNameLabel.set_xalign(0)
|
|
self.generateNameEntry = Gtk.Entry()
|
|
self.generateNameEntry.set_placeholder_text("e.g., web/github or email/work")
|
|
genNameLabel.set_mnemonic_widget(self.generateNameEntry)
|
|
genNameBox.pack_start(genNameLabel, False, False, 0)
|
|
genNameBox.pack_start(self.generateNameEntry, True, True, 0)
|
|
self.generatePanel.pack_start(genNameBox, False, False, 0)
|
|
|
|
# Length spinner
|
|
lengthBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
lengthLabel = Gtk.Label(label="_Length:")
|
|
lengthLabel.set_use_underline(True)
|
|
lengthLabel.set_size_request(100, -1)
|
|
lengthLabel.set_xalign(0)
|
|
adjustment = Gtk.Adjustment(value=20, lower=8, upper=128, step_increment=1, page_increment=10)
|
|
self.lengthSpin = Gtk.SpinButton()
|
|
self.lengthSpin.set_adjustment(adjustment)
|
|
lengthLabel.set_mnemonic_widget(self.lengthSpin)
|
|
lengthBox.pack_start(lengthLabel, False, False, 0)
|
|
lengthBox.pack_start(self.lengthSpin, False, False, 0)
|
|
self.generatePanel.pack_start(lengthBox, False, False, 0)
|
|
|
|
# Include symbols checkbox
|
|
self.symbolsCheck = Gtk.CheckButton(label="Include symbols")
|
|
self.symbolsCheck.set_active(True)
|
|
self.generatePanel.pack_start(self.symbolsCheck, False, False, 0)
|
|
|
|
# Metadata for generated password
|
|
genMetadataLabel = Gtk.Label(label="_Metadata (optional):")
|
|
genMetadataLabel.set_use_underline(True)
|
|
genMetadataLabel.set_xalign(0)
|
|
self.generatePanel.pack_start(genMetadataLabel, False, False, 0)
|
|
|
|
genMetadataScrolled = Gtk.ScrolledWindow()
|
|
genMetadataScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
genMetadataScrolled.set_size_request(-1, 100)
|
|
self.generateMetadataBuffer = Gtk.TextBuffer()
|
|
self.generateMetadataView = Gtk.TextView.new_with_buffer(self.generateMetadataBuffer)
|
|
self.generateMetadataView.set_wrap_mode(Gtk.WrapMode.WORD)
|
|
self.generateMetadataView.set_accepts_tab(False)
|
|
genMetadataLabel.set_mnemonic_widget(self.generateMetadataView)
|
|
genMetadataScrolled.add(self.generateMetadataView)
|
|
self.generatePanel.pack_start(genMetadataScrolled, True, True, 0)
|
|
|
|
# Save button
|
|
saveButton = Gtk.Button(label="Save Password (Ctrl+S)")
|
|
saveButton.connect("clicked", self.on_save_clicked)
|
|
addBox.pack_start(saveButton, False, False, 0)
|
|
|
|
# Connect radio buttons
|
|
self.manualRadio.connect("toggled", self.on_mode_toggled)
|
|
self.manualRadio.set_active(True) # Start with manual mode
|
|
|
|
def build_organize_tab(self):
|
|
"""Build the organize tab"""
|
|
organizeBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
self.notebook.append_page(organizeBox, Gtk.Label(label="Organize"))
|
|
|
|
# Description label
|
|
descLabel = Gtk.Label(label="Select a password to move, copy, or delete. Use move to reorganize (e.g., work/git to personal/git).")
|
|
descLabel.set_line_wrap(True)
|
|
descLabel.set_xalign(0)
|
|
organizeBox.pack_start(descLabel, False, False, 0)
|
|
|
|
# Search box
|
|
organizeSearchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
organizeSearchLabel = Gtk.Label(label="_Search:")
|
|
organizeSearchLabel.set_use_underline(True)
|
|
self.organizeSearchEntry = Gtk.Entry()
|
|
self.organizeSearchEntry.set_placeholder_text("Type to search passwords...")
|
|
self.organizeSearchEntry.connect("changed", self.on_search_changed)
|
|
organizeSearchLabel.set_mnemonic_widget(self.organizeSearchEntry)
|
|
organizeSearchBox.pack_start(organizeSearchLabel, False, False, 0)
|
|
organizeSearchBox.pack_start(self.organizeSearchEntry, True, True, 0)
|
|
organizeBox.pack_start(organizeSearchBox, False, False, 0)
|
|
|
|
# Password tree view
|
|
scrolledWindow = Gtk.ScrolledWindow()
|
|
scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
organizeBox.pack_start(scrolledWindow, True, True, 0)
|
|
|
|
self.organizeTreeView = Gtk.TreeView()
|
|
self.organizeTreeView.set_headers_visible(True)
|
|
|
|
renderer = Gtk.CellRendererText()
|
|
column = Gtk.TreeViewColumn("Password", renderer, text=0)
|
|
column.set_sort_column_id(0)
|
|
self.organizeTreeView.append_column(column)
|
|
|
|
scrolledWindow.add(self.organizeTreeView)
|
|
|
|
# Operation selection
|
|
operationBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
|
organizeBox.pack_start(operationBox, False, False, 0)
|
|
|
|
operationLabel = Gtk.Label(label="Operation:")
|
|
operationBox.pack_start(operationLabel, False, False, 0)
|
|
|
|
self.moveRadio = Gtk.RadioButton(label="Move/Rename")
|
|
self.copyRadio = Gtk.RadioButton.new_from_widget(self.moveRadio)
|
|
self.copyRadio.set_label("Copy")
|
|
self.deleteRadio = Gtk.RadioButton.new_from_widget(self.moveRadio)
|
|
self.deleteRadio.set_label("Delete")
|
|
|
|
operationBox.pack_start(self.moveRadio, False, False, 0)
|
|
operationBox.pack_start(self.copyRadio, False, False, 0)
|
|
operationBox.pack_start(self.deleteRadio, False, False, 0)
|
|
|
|
# New path entry (for move/copy)
|
|
pathBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
organizeBox.pack_start(pathBox, False, False, 0)
|
|
|
|
pathLabel = Gtk.Label(label="_New path:")
|
|
pathLabel.set_use_underline(True)
|
|
pathLabel.set_size_request(100, -1)
|
|
pathLabel.set_xalign(0)
|
|
self.newPathEntry = Gtk.Entry()
|
|
self.newPathEntry.set_placeholder_text("e.g., web/gitlab or work/email")
|
|
pathLabel.set_mnemonic_widget(self.newPathEntry)
|
|
pathBox.pack_start(pathLabel, False, False, 0)
|
|
pathBox.pack_start(self.newPathEntry, True, True, 0)
|
|
|
|
# Execute button
|
|
self.executeButton = Gtk.Button(label="Execute Operation")
|
|
self.executeButton.connect("clicked", self.on_execute_clicked)
|
|
organizeBox.pack_start(self.executeButton, False, False, 0)
|
|
|
|
# Connect radio buttons
|
|
self.moveRadio.connect("toggled", self.on_operation_toggled)
|
|
self.deleteRadio.connect("toggled", self.on_operation_toggled)
|
|
self.moveRadio.set_active(True)
|
|
|
|
def build_git_tab(self):
|
|
"""Build the git integration tab"""
|
|
gitBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
self.notebook.append_page(gitBox, Gtk.Label(label="Git"))
|
|
|
|
# Status label
|
|
self.gitStatusLabel = Gtk.Label(label="Git status: Checking...")
|
|
self.gitStatusLabel.set_xalign(0)
|
|
gitBox.pack_start(self.gitStatusLabel, False, False, 0)
|
|
|
|
# Button box for sync operations
|
|
buttonBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
gitBox.pack_start(buttonBox, False, False, 0)
|
|
|
|
pullButton = Gtk.Button(label="Pull")
|
|
pullButton.connect("clicked", self.on_git_pull_clicked)
|
|
buttonBox.pack_start(pullButton, True, True, 0)
|
|
|
|
pushButton = Gtk.Button(label="Push")
|
|
pushButton.connect("clicked", self.on_git_push_clicked)
|
|
buttonBox.pack_start(pushButton, True, True, 0)
|
|
|
|
refreshButton = Gtk.Button(label="Refresh")
|
|
refreshButton.connect("clicked", self.on_git_refresh_clicked)
|
|
buttonBox.pack_start(refreshButton, True, True, 0)
|
|
|
|
# Button box for configuration
|
|
configBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
gitBox.pack_start(configBox, False, False, 0)
|
|
|
|
configRemoteButton = Gtk.Button(label="Configure Remote")
|
|
configRemoteButton.connect("clicked", self.on_git_configure_remote_clicked)
|
|
configBox.pack_start(configRemoteButton, True, True, 0)
|
|
|
|
# Log display
|
|
logFrame = Gtk.Frame(label="Commit History")
|
|
gitBox.pack_start(logFrame, True, True, 0)
|
|
|
|
logScrolled = Gtk.ScrolledWindow()
|
|
logScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
logFrame.add(logScrolled)
|
|
|
|
self.gitLogBuffer = Gtk.TextBuffer()
|
|
self.gitLogView = Gtk.TextView.new_with_buffer(self.gitLogBuffer)
|
|
self.gitLogView.set_editable(False)
|
|
self.gitLogView.set_wrap_mode(Gtk.WrapMode.WORD)
|
|
logScrolled.add(self.gitLogView)
|
|
|
|
# Update git status
|
|
self.update_git_status()
|
|
|
|
def setup_accessibility(self):
|
|
"""Configure accessibility properties"""
|
|
windowAccessible = self.get_accessible()
|
|
windowAccessible.set_name("I38 Password Manager")
|
|
windowAccessible.set_description(
|
|
"Password manager for pass utility. "
|
|
"Use Alt+1/2/3/4 to switch tabs. "
|
|
"Press / to search."
|
|
)
|
|
|
|
if hasattr(self, 'passwordTreeView'):
|
|
treeAccessible = self.passwordTreeView.get_accessible()
|
|
treeAccessible.set_name("Password list")
|
|
treeAccessible.set_role(Atk.Role.TREE)
|
|
|
|
if hasattr(self, 'organizeTreeView'):
|
|
organizeTreeAccessible = self.organizeTreeView.get_accessible()
|
|
organizeTreeAccessible.set_name("Password list for organizing")
|
|
organizeTreeAccessible.set_role(Atk.Role.TREE)
|
|
|
|
if hasattr(self, 'searchEntry'):
|
|
searchAccessible = self.searchEntry.get_accessible()
|
|
searchAccessible.set_name("Password search")
|
|
|
|
if hasattr(self, 'organizeSearchEntry'):
|
|
organizeSearchAccessible = self.organizeSearchEntry.get_accessible()
|
|
organizeSearchAccessible.set_name("Password search for organizing")
|
|
|
|
def populate_password_list(self):
|
|
"""Build password tree from ~/.password-store directory"""
|
|
self.passwordStore.clear()
|
|
|
|
passwordStoreDir = Path.home() / '.password-store'
|
|
if not passwordStoreDir.exists():
|
|
return
|
|
|
|
# Track iterators by folder path
|
|
iterators = {}
|
|
|
|
# Find all .gpg files
|
|
gpgFiles = sorted(passwordStoreDir.rglob('*.gpg'))
|
|
|
|
for gpgFile in gpgFiles:
|
|
# Skip git directory
|
|
if '.git' in gpgFile.parts:
|
|
continue
|
|
|
|
relativePath = gpgFile.relative_to(passwordStoreDir)
|
|
pathStr = str(relativePath.with_suffix('')) # Remove .gpg
|
|
|
|
parts = pathStr.split('/')
|
|
|
|
# Create folder entries as needed
|
|
for i in range(len(parts) - 1):
|
|
folderPath = '/'.join(parts[:i+1])
|
|
if folderPath not in iterators:
|
|
parentPath = '/'.join(parts[:i]) if i > 0 else None
|
|
parent = iterators.get(parentPath)
|
|
|
|
iterator = self.passwordStore.append(
|
|
parent,
|
|
[parts[i], folderPath, True, False] # isFolder=True
|
|
)
|
|
iterators[folderPath] = iterator
|
|
|
|
# Add password entry
|
|
parent = iterators.get('/'.join(parts[:-1])) if len(parts) > 1 else None
|
|
self.passwordStore.append(
|
|
parent,
|
|
[parts[-1], pathStr, False, False] # isFolder=False
|
|
)
|
|
|
|
# Set up filtered view
|
|
self.filteredStore = self.passwordStore.filter_new()
|
|
self.filteredStore.set_visible_func(self.filter_passwords)
|
|
self.passwordTreeView.set_model(self.filteredStore)
|
|
self.organizeTreeView.set_model(self.filteredStore)
|
|
|
|
def filter_passwords(self, model, iterator, data):
|
|
"""Filter function for password list"""
|
|
if not self.searchText:
|
|
return True
|
|
|
|
name = model.get_value(iterator, 0)
|
|
fullPath = model.get_value(iterator, 1)
|
|
|
|
searchLower = self.searchText.lower()
|
|
return (searchLower in name.lower() or
|
|
searchLower in fullPath.lower())
|
|
|
|
def on_search_changed(self, entry):
|
|
"""Handle search changes"""
|
|
self.searchText = entry.get_text()
|
|
if self.filteredStore:
|
|
self.filteredStore.refilter()
|
|
|
|
if self.searchText:
|
|
self.passwordTreeView.expand_all()
|
|
if hasattr(self, 'organizeTreeView'):
|
|
self.organizeTreeView.expand_all()
|
|
else:
|
|
self.passwordTreeView.collapse_all()
|
|
if hasattr(self, 'organizeTreeView'):
|
|
self.organizeTreeView.collapse_all()
|
|
|
|
def get_selected_password(self, treeView):
|
|
"""Get currently selected password path"""
|
|
selection = treeView.get_selection()
|
|
model, iterator = selection.get_selected()
|
|
|
|
if iterator is None:
|
|
return None, None
|
|
|
|
isFolder = model.get_value(iterator, 2)
|
|
if isFolder:
|
|
self.show_error("Please select a password, not a folder")
|
|
return None, None
|
|
|
|
fullPath = model.get_value(iterator, 1)
|
|
name = model.get_value(iterator, 0)
|
|
|
|
return fullPath, name
|
|
|
|
def copy_to_clipboard(self, passwordName):
|
|
"""Copy password using pass --clip"""
|
|
if not passwordName:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
['pass', 'show', '--clip', passwordName],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(800, 0.1)
|
|
else:
|
|
self.show_error(f"Copy failed: {result.stderr}")
|
|
|
|
def show_metadata(self, passwordName):
|
|
"""Display non-password lines in TextView"""
|
|
if not passwordName:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
['pass', 'show', passwordName],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
self.show_error("Failed to read password")
|
|
return
|
|
|
|
lines = result.stdout.strip().split('\n')
|
|
if len(lines) > 1:
|
|
metadata = '\n'.join(lines[1:]) # Skip first line (password)
|
|
self.metadataTextBuffer.set_text(metadata)
|
|
else:
|
|
self.metadataTextBuffer.set_text("(No metadata)")
|
|
|
|
def save_manual_password(self, passwordName, password, metadata):
|
|
"""Insert password with optional metadata"""
|
|
if not passwordName or not password:
|
|
self.show_error("Password name and password required")
|
|
return False
|
|
|
|
# Build full entry (password + metadata)
|
|
fullEntry = password
|
|
if metadata.strip():
|
|
fullEntry += '\n' + metadata.strip()
|
|
|
|
# Use pass insert --multiline
|
|
process = subprocess.Popen(
|
|
['pass', 'insert', '--multiline', '--force', passwordName],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
stdout, stderr = process.communicate(input=fullEntry)
|
|
|
|
if process.returncode == 0:
|
|
self.play_sound_effect(1000, 0.1)
|
|
self.populate_password_list()
|
|
self.notebook.set_current_page(0) # Switch to browse tab
|
|
return True
|
|
else:
|
|
self.show_error(f"Save failed: {stderr}")
|
|
return False
|
|
|
|
def generate_password(self, passwordName, length, includeSymbols, metadata):
|
|
"""Generate random password"""
|
|
if not passwordName:
|
|
self.show_error("Password name required")
|
|
return False
|
|
|
|
cmd = ['pass', 'generate', '--force']
|
|
if not includeSymbols:
|
|
cmd.append('--no-symbols')
|
|
cmd.extend([passwordName, str(length)])
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
self.show_error(f"Generation failed: {result.stderr}")
|
|
return False
|
|
|
|
# If metadata exists, append it
|
|
if metadata.strip():
|
|
# Get generated password
|
|
showResult = subprocess.run(
|
|
['pass', 'show', passwordName],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
password = showResult.stdout.strip()
|
|
|
|
# Reinsert with metadata
|
|
fullEntry = password + '\n' + metadata.strip()
|
|
process = subprocess.Popen(
|
|
['pass', 'insert', '--multiline', '--force', passwordName],
|
|
stdin=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
process.communicate(input=fullEntry)
|
|
|
|
self.play_sound_effect(1000, 0.1)
|
|
self.populate_password_list()
|
|
self.notebook.set_current_page(0)
|
|
return True
|
|
|
|
def edit_password(self, passwordName):
|
|
"""Edit existing password in dialog"""
|
|
if not passwordName:
|
|
return
|
|
|
|
# Get current content
|
|
result = subprocess.run(
|
|
['pass', 'show', passwordName],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
self.show_error("Failed to read password")
|
|
return
|
|
|
|
lines = result.stdout.strip().split('\n')
|
|
password = lines[0] if lines else ""
|
|
metadata = '\n'.join(lines[1:]) if len(lines) > 1 else ""
|
|
|
|
# Show edit dialog
|
|
dialog = Gtk.Dialog(
|
|
title=f"Edit: {passwordName}",
|
|
parent=self,
|
|
flags=0
|
|
)
|
|
dialog.add_buttons(
|
|
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_SAVE, Gtk.ResponseType.OK
|
|
)
|
|
dialog.set_default_size(400, 300)
|
|
|
|
contentArea = dialog.get_content_area()
|
|
contentArea.set_spacing(5)
|
|
contentArea.set_border_width(10)
|
|
|
|
# Password entry
|
|
passwordLabel = Gtk.Label(label="Password:")
|
|
passwordLabel.set_xalign(0)
|
|
contentArea.pack_start(passwordLabel, False, False, 0)
|
|
|
|
passwordEntry = Gtk.Entry()
|
|
passwordEntry.set_text(password)
|
|
passwordEntry.set_visibility(False)
|
|
contentArea.pack_start(passwordEntry, False, False, 0)
|
|
|
|
# Show password checkbox
|
|
showCheck = Gtk.CheckButton(label="Show password")
|
|
showCheck.connect("toggled", lambda w: passwordEntry.set_visibility(w.get_active()))
|
|
contentArea.pack_start(showCheck, False, False, 0)
|
|
|
|
# Metadata TextView
|
|
metadataLabel = Gtk.Label(label="Metadata:")
|
|
metadataLabel.set_xalign(0)
|
|
contentArea.pack_start(metadataLabel, False, False, 0)
|
|
|
|
metadataScrolled = Gtk.ScrolledWindow()
|
|
metadataScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
|
|
metadataBuffer = Gtk.TextBuffer()
|
|
metadataBuffer.set_text(metadata)
|
|
metadataView = Gtk.TextView.new_with_buffer(metadataBuffer)
|
|
metadataView.set_wrap_mode(Gtk.WrapMode.WORD)
|
|
metadataScrolled.add(metadataView)
|
|
contentArea.pack_start(metadataScrolled, True, True, 0)
|
|
|
|
dialog.show_all()
|
|
response = dialog.run()
|
|
|
|
if response == Gtk.ResponseType.OK:
|
|
newPassword = passwordEntry.get_text()
|
|
start, end = metadataBuffer.get_bounds()
|
|
newMetadata = metadataBuffer.get_text(start, end, False)
|
|
|
|
# Save
|
|
self.save_manual_password(passwordName, newPassword, newMetadata)
|
|
|
|
dialog.destroy()
|
|
|
|
def move_password(self, oldPath, newPath):
|
|
"""Rename or move password"""
|
|
if not oldPath or not newPath:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
['pass', 'mv', '--force', oldPath, newPath],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(900, 0.1)
|
|
self.populate_password_list()
|
|
else:
|
|
self.show_error(f"Move failed: {result.stderr}")
|
|
|
|
def copy_password_entry(self, oldPath, newPath):
|
|
"""Copy password entry"""
|
|
if not oldPath or not newPath:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
['pass', 'cp', '--force', oldPath, newPath],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(900, 0.1)
|
|
self.populate_password_list()
|
|
else:
|
|
self.show_error(f"Copy failed: {result.stderr}")
|
|
|
|
def delete_password(self, passwordPath):
|
|
"""Delete password with confirmation"""
|
|
if not passwordPath:
|
|
return
|
|
|
|
# Confirmation dialog
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.QUESTION,
|
|
buttons=Gtk.ButtonsType.YES_NO,
|
|
text=f"Delete {passwordPath}?"
|
|
)
|
|
dialog.format_secondary_text(
|
|
"This action cannot be undone (unless using git)."
|
|
)
|
|
|
|
response = dialog.run()
|
|
dialog.destroy()
|
|
|
|
if response != Gtk.ResponseType.YES:
|
|
return
|
|
|
|
# Execute deletion
|
|
result = subprocess.run(
|
|
['pass', 'rm', '--force', passwordPath],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(700, 0.1)
|
|
self.populate_password_list()
|
|
else:
|
|
self.show_error(f"Delete failed: {result.stderr}")
|
|
|
|
def update_git_status(self):
|
|
"""Update git status display"""
|
|
if not self.isGitRepo:
|
|
return
|
|
|
|
passwordStoreDir = str(Path.home() / '.password-store')
|
|
|
|
# Get current branch
|
|
result = subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'branch', '--show-current'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
branch = result.stdout.strip() if result.returncode == 0 else "unknown"
|
|
|
|
# Get status
|
|
result = subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'status', '--porcelain', '--branch'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
statusText = f"Branch: {branch}"
|
|
|
|
# Check for uncommitted changes
|
|
if result.returncode == 0:
|
|
lines = result.stdout.strip().split('\n')
|
|
if len(lines) > 1:
|
|
statusText += f" ({len(lines) - 1} changes)"
|
|
|
|
# Check ahead/behind status if remote exists
|
|
if self.has_git_remote():
|
|
# Fetch to get latest remote status (quietly)
|
|
subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'fetch'],
|
|
capture_output=True,
|
|
stderr=subprocess.DEVNULL
|
|
)
|
|
|
|
# Get ahead/behind counts
|
|
result = subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'rev-list', '--left-right', '--count', f'{branch}...@{{u}}'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
counts = result.stdout.strip().split()
|
|
if len(counts) == 2:
|
|
ahead = int(counts[0])
|
|
behind = int(counts[1])
|
|
|
|
if ahead > 0 and behind > 0:
|
|
statusText += f" (↑{ahead} ↓{behind})"
|
|
elif ahead > 0:
|
|
statusText += f" (↑{ahead} ahead)"
|
|
elif behind > 0:
|
|
statusText += f" (↓{behind} behind)"
|
|
else:
|
|
statusText += " (up to date)"
|
|
|
|
self.gitStatusLabel.set_text(statusText)
|
|
|
|
# Update log
|
|
self.update_git_log()
|
|
|
|
def update_git_log(self):
|
|
"""Update git commit log"""
|
|
if not self.isGitRepo:
|
|
return
|
|
|
|
result = subprocess.run(
|
|
['git', '-C', str(Path.home() / '.password-store'), 'log', '--oneline', '-20'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.gitLogBuffer.set_text(result.stdout)
|
|
else:
|
|
self.gitLogBuffer.set_text("Failed to retrieve log")
|
|
|
|
def auto_pull_on_startup(self):
|
|
"""Automatically pull from remote on startup"""
|
|
result = subprocess.run(
|
|
['pass', 'git', 'pull'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
# Update UI if pull was successful
|
|
self.update_git_status()
|
|
self.populate_password_list()
|
|
# Silently fail if pull fails - user can manually pull later
|
|
|
|
return False # Don't repeat this idle callback
|
|
|
|
def play_sound_effect(self, frequency=800, duration=0.1):
|
|
"""Play sound effect"""
|
|
if not shutil.which('play'):
|
|
return
|
|
|
|
try:
|
|
subprocess.run(
|
|
['play', '-qnG', 'synth', str(duration), 'sin', str(frequency)],
|
|
timeout=1,
|
|
check=False
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def show_error(self, message):
|
|
"""Show error dialog"""
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.ERROR,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text="Error"
|
|
)
|
|
dialog.format_secondary_text(message)
|
|
dialog.run()
|
|
dialog.destroy()
|
|
|
|
# Event handlers
|
|
def on_delete_event(self, widget, event):
|
|
"""Handle window close event"""
|
|
self.cleanup_and_quit()
|
|
return False
|
|
|
|
def cleanup_and_quit(self):
|
|
"""Clean up resources and quit"""
|
|
# Stop any pending clipboard timer
|
|
if self.clipboardTimer:
|
|
GLib.source_remove(self.clipboardTimer)
|
|
self.clipboardTimer = None
|
|
|
|
# Destroy the window
|
|
self.destroy()
|
|
|
|
# Quit the main loop
|
|
Gtk.main_quit()
|
|
|
|
def on_key_press(self, widget, event):
|
|
"""Handle keyboard shortcuts"""
|
|
keyval = event.keyval
|
|
ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
|
|
alt = event.state & Gdk.ModifierType.MOD1_MASK
|
|
|
|
# Escape: Clear search or close
|
|
if keyval == Gdk.KEY_Escape:
|
|
if hasattr(self, 'searchEntry') and self.searchEntry.get_text():
|
|
self.searchEntry.set_text("")
|
|
return True
|
|
self.cleanup_and_quit()
|
|
return True
|
|
|
|
# / or Ctrl+F: Focus search
|
|
if keyval == Gdk.KEY_slash or (ctrl and keyval == Gdk.KEY_f):
|
|
if hasattr(self, 'searchEntry'):
|
|
self.searchEntry.grab_focus()
|
|
return True
|
|
|
|
# Ctrl+C: Copy password
|
|
if ctrl and keyval == Gdk.KEY_c:
|
|
fullPath, name = self.get_selected_password(self.passwordTreeView)
|
|
if fullPath:
|
|
self.copy_to_clipboard(fullPath)
|
|
return True
|
|
|
|
# Ctrl+M: Show metadata
|
|
if ctrl and keyval == Gdk.KEY_m:
|
|
fullPath, name = self.get_selected_password(self.passwordTreeView)
|
|
if fullPath:
|
|
self.show_metadata(fullPath)
|
|
return True
|
|
|
|
# Ctrl+E: Edit password
|
|
if ctrl and keyval == Gdk.KEY_e:
|
|
fullPath, name = self.get_selected_password(self.passwordTreeView)
|
|
if fullPath:
|
|
self.edit_password(fullPath)
|
|
return True
|
|
|
|
# Delete: Delete password
|
|
if keyval == Gdk.KEY_Delete:
|
|
fullPath, name = self.get_selected_password(self.organizeTreeView)
|
|
if fullPath:
|
|
self.delete_password(fullPath)
|
|
return True
|
|
|
|
# Ctrl+S: Save (in Add tab)
|
|
if ctrl and keyval == Gdk.KEY_s:
|
|
if self.notebook.get_current_page() == 1: # Add tab
|
|
self.on_save_clicked(None)
|
|
return True
|
|
|
|
# Ctrl+Q: Quit
|
|
if ctrl and keyval == Gdk.KEY_q:
|
|
self.cleanup_and_quit()
|
|
return True
|
|
|
|
# Alt+1/2/3/4: Switch tabs
|
|
if alt and keyval in [Gdk.KEY_1, Gdk.KEY_2, Gdk.KEY_3, Gdk.KEY_4]:
|
|
tabIndex = int(chr(keyval)) - 1
|
|
if tabIndex < self.notebook.get_n_pages():
|
|
self.notebook.set_current_page(tabIndex)
|
|
return True
|
|
|
|
return False
|
|
|
|
def on_password_activated(self, treeView, path, column):
|
|
"""Handle double-click on password"""
|
|
fullPath, name = self.get_selected_password(treeView)
|
|
if fullPath:
|
|
self.copy_to_clipboard(fullPath)
|
|
|
|
def on_copy_clicked(self, button):
|
|
"""Handle copy button click"""
|
|
fullPath, name = self.get_selected_password(self.passwordTreeView)
|
|
if fullPath:
|
|
self.copy_to_clipboard(fullPath)
|
|
|
|
def on_metadata_clicked(self, button):
|
|
"""Handle metadata button click"""
|
|
fullPath, name = self.get_selected_password(self.passwordTreeView)
|
|
if fullPath:
|
|
self.show_metadata(fullPath)
|
|
|
|
def on_edit_clicked(self, button):
|
|
"""Handle edit button click"""
|
|
fullPath, name = self.get_selected_password(self.passwordTreeView)
|
|
if fullPath:
|
|
self.edit_password(fullPath)
|
|
|
|
def on_mode_toggled(self, button):
|
|
"""Handle manual/generate radio toggle"""
|
|
if self.manualRadio.get_active():
|
|
self.manualPanel.set_visible(True)
|
|
self.generatePanel.set_visible(False)
|
|
else:
|
|
self.manualPanel.set_visible(False)
|
|
self.generatePanel.set_visible(True)
|
|
|
|
def on_show_password_toggled(self, button):
|
|
"""Handle show password checkbox"""
|
|
visible = button.get_active()
|
|
self.manualPasswordEntry.set_visibility(visible)
|
|
self.manualConfirmEntry.set_visibility(visible)
|
|
|
|
def on_save_clicked(self, button):
|
|
"""Handle save button click"""
|
|
if self.manualRadio.get_active():
|
|
# Manual entry
|
|
passwordName = self.manualNameEntry.get_text().strip()
|
|
password = self.manualPasswordEntry.get_text()
|
|
confirm = self.manualConfirmEntry.get_text()
|
|
|
|
if password != confirm:
|
|
self.show_error("Passwords do not match")
|
|
return
|
|
|
|
start, end = self.manualMetadataBuffer.get_bounds()
|
|
metadata = self.manualMetadataBuffer.get_text(start, end, False)
|
|
|
|
if self.save_manual_password(passwordName, password, metadata):
|
|
# Clear fields
|
|
self.manualNameEntry.set_text("")
|
|
self.manualPasswordEntry.set_text("")
|
|
self.manualConfirmEntry.set_text("")
|
|
self.manualMetadataBuffer.set_text("")
|
|
else:
|
|
# Generate password
|
|
passwordName = self.generateNameEntry.get_text().strip()
|
|
length = int(self.lengthSpin.get_value())
|
|
includeSymbols = self.symbolsCheck.get_active()
|
|
|
|
start, end = self.generateMetadataBuffer.get_bounds()
|
|
metadata = self.generateMetadataBuffer.get_text(start, end, False)
|
|
|
|
if self.generate_password(passwordName, length, includeSymbols, metadata):
|
|
# Clear fields
|
|
self.generateNameEntry.set_text("")
|
|
self.generateMetadataBuffer.set_text("")
|
|
|
|
def on_operation_toggled(self, button):
|
|
"""Handle operation radio toggle"""
|
|
isDelete = self.deleteRadio.get_active()
|
|
self.newPathEntry.set_sensitive(not isDelete)
|
|
|
|
def on_execute_clicked(self, button):
|
|
"""Handle execute operation button"""
|
|
fullPath, name = self.get_selected_password(self.organizeTreeView)
|
|
if not fullPath:
|
|
self.show_error("Please select a password")
|
|
return
|
|
|
|
if self.moveRadio.get_active():
|
|
newPath = self.newPathEntry.get_text().strip()
|
|
if not newPath:
|
|
self.show_error("Please enter new path")
|
|
return
|
|
self.move_password(fullPath, newPath)
|
|
self.newPathEntry.set_text("")
|
|
elif self.copyRadio.get_active():
|
|
newPath = self.newPathEntry.get_text().strip()
|
|
if not newPath:
|
|
self.show_error("Please enter new path")
|
|
return
|
|
self.copy_password_entry(fullPath, newPath)
|
|
self.newPathEntry.set_text("")
|
|
else: # Delete
|
|
self.delete_password(fullPath)
|
|
|
|
def on_git_pull_clicked(self, button):
|
|
"""Handle git pull button"""
|
|
result = subprocess.run(
|
|
['pass', 'git', 'pull'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(1000, 0.1)
|
|
self.update_git_status()
|
|
self.populate_password_list()
|
|
else:
|
|
self.show_error(f"Pull failed: {result.stderr}")
|
|
|
|
def on_git_push_clicked(self, button):
|
|
"""Handle git push button"""
|
|
result = subprocess.run(
|
|
['pass', 'git', 'push'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(1000, 0.1)
|
|
self.update_git_status()
|
|
else:
|
|
self.show_error(f"Push failed: {result.stderr}")
|
|
|
|
def on_git_refresh_clicked(self, button):
|
|
"""Handle git refresh button"""
|
|
self.update_git_status()
|
|
|
|
def on_git_configure_remote_clicked(self, button):
|
|
"""Handle configure remote button"""
|
|
passwordStoreDir = str(Path.home() / '.password-store')
|
|
|
|
# Get current remote URL if exists
|
|
result = subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'remote', 'get-url', 'origin'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
currentRemote = result.stdout.strip() if result.returncode == 0 else ""
|
|
|
|
# Show dialog
|
|
dialog = Gtk.Dialog(
|
|
title="Configure Git Remote",
|
|
parent=self,
|
|
flags=0
|
|
)
|
|
dialog.add_buttons(
|
|
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_SAVE, Gtk.ResponseType.OK
|
|
)
|
|
dialog.set_default_size(500, 150)
|
|
|
|
contentArea = dialog.get_content_area()
|
|
contentArea.set_spacing(10)
|
|
contentArea.set_border_width(10)
|
|
|
|
# Instructions
|
|
infoLabel = Gtk.Label(label="Enter the URL of your password store git repository:")
|
|
infoLabel.set_line_wrap(True)
|
|
infoLabel.set_xalign(0)
|
|
contentArea.pack_start(infoLabel, False, False, 0)
|
|
|
|
# Remote URL entry
|
|
urlBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
urlLabel = Gtk.Label(label="_Remote URL:")
|
|
urlLabel.set_use_underline(True)
|
|
urlLabel.set_size_request(100, -1)
|
|
urlLabel.set_xalign(0)
|
|
urlEntry = Gtk.Entry()
|
|
urlEntry.set_text(currentRemote)
|
|
urlEntry.set_placeholder_text("git@example.com:username/pass-store.git")
|
|
urlLabel.set_mnemonic_widget(urlEntry)
|
|
urlBox.pack_start(urlLabel, False, False, 0)
|
|
urlBox.pack_start(urlEntry, True, True, 0)
|
|
contentArea.pack_start(urlBox, False, False, 0)
|
|
|
|
# Example label
|
|
exampleLabel = Gtk.Label(label="Examples:\n • git@github.com:user/pass-store.git\n • https://github.com/user/pass-store.git\n • user@server.com:~/pass-store")
|
|
exampleLabel.set_xalign(0)
|
|
exampleLabel.set_markup("<small>Examples:\n • git@github.com:user/pass-store.git\n • https://github.com/user/pass-store.git\n • user@server.com:~/pass-store</small>")
|
|
contentArea.pack_start(exampleLabel, False, False, 0)
|
|
|
|
dialog.show_all()
|
|
response = dialog.run()
|
|
|
|
if response == Gtk.ResponseType.OK:
|
|
remoteUrl = urlEntry.get_text().strip()
|
|
if remoteUrl:
|
|
# Remove existing remote if present
|
|
subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'remote', 'remove', 'origin'],
|
|
capture_output=True,
|
|
stderr=subprocess.DEVNULL
|
|
)
|
|
|
|
# Add new remote
|
|
result = subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'remote', 'add', 'origin', remoteUrl],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
self.play_sound_effect(1000, 0.1)
|
|
self.update_git_status()
|
|
|
|
# Show success message
|
|
successDialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.INFO,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text="Remote configured successfully"
|
|
)
|
|
successDialog.format_secondary_text(
|
|
f"Git remote 'origin' set to:\n{remoteUrl}\n\n"
|
|
"You can now use Pull and Push buttons to sync."
|
|
)
|
|
successDialog.run()
|
|
successDialog.destroy()
|
|
else:
|
|
self.show_error(f"Failed to configure remote: {result.stderr}")
|
|
|
|
dialog.destroy()
|
|
|
|
def on_setup_git_sync_clicked(self, button):
|
|
"""Handle Set Up Git Sync button click"""
|
|
# Show explanation dialog
|
|
dialog = Gtk.Dialog(
|
|
title="Set Up Git Sync",
|
|
parent=self,
|
|
flags=0
|
|
)
|
|
dialog.add_buttons(
|
|
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
"Continue", Gtk.ResponseType.OK
|
|
)
|
|
dialog.set_default_size(550, 300)
|
|
|
|
contentArea = dialog.get_content_area()
|
|
contentArea.set_spacing(10)
|
|
contentArea.set_border_width(10)
|
|
|
|
# Explanation
|
|
explanationText = (
|
|
"Git sync allows you to back up and synchronize your passwords across multiple devices.\n\n"
|
|
"How it works:\n"
|
|
"• Your passwords will be stored in a git repository\n"
|
|
"• Each change is automatically committed\n"
|
|
"• You can push/pull to sync with a remote server\n"
|
|
"• All passwords remain encrypted with your GPG key\n\n"
|
|
"You'll need:\n"
|
|
"• A git repository URL (GitHub, GitLab, self-hosted, etc.)\n"
|
|
"• SSH keys or HTTPS credentials configured for access"
|
|
)
|
|
|
|
explanationLabel = Gtk.Label(label=explanationText)
|
|
explanationLabel.set_line_wrap(True)
|
|
explanationLabel.set_xalign(0)
|
|
contentArea.pack_start(explanationLabel, False, False, 0)
|
|
|
|
dialog.show_all()
|
|
response = dialog.run()
|
|
dialog.destroy()
|
|
|
|
if response != Gtk.ResponseType.OK:
|
|
return
|
|
|
|
# Show URL configuration dialog
|
|
self.show_git_url_dialog()
|
|
|
|
def show_git_url_dialog(self):
|
|
"""Show dialog to configure git remote URL"""
|
|
dialog = Gtk.Dialog(
|
|
title="Configure Git Repository",
|
|
parent=self,
|
|
flags=0
|
|
)
|
|
dialog.add_buttons(
|
|
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
"Set Up", Gtk.ResponseType.OK
|
|
)
|
|
dialog.set_default_size(550, 250)
|
|
|
|
contentArea = dialog.get_content_area()
|
|
contentArea.set_spacing(10)
|
|
contentArea.set_border_width(10)
|
|
|
|
# Instructions
|
|
infoLabel = Gtk.Label(label="Enter your git repository URL:")
|
|
infoLabel.set_xalign(0)
|
|
contentArea.pack_start(infoLabel, False, False, 0)
|
|
|
|
# URL entry
|
|
urlBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
urlLabel = Gtk.Label(label="_Repository URL:")
|
|
urlLabel.set_use_underline(True)
|
|
urlLabel.set_size_request(120, -1)
|
|
urlLabel.set_xalign(0)
|
|
urlEntry = Gtk.Entry()
|
|
urlEntry.set_placeholder_text("git@github.com:username/pass-store.git")
|
|
urlLabel.set_mnemonic_widget(urlEntry)
|
|
urlBox.pack_start(urlLabel, False, False, 0)
|
|
urlBox.pack_start(urlEntry, True, True, 0)
|
|
contentArea.pack_start(urlBox, False, False, 0)
|
|
|
|
# Test connectivity checkbox
|
|
testCheck = Gtk.CheckButton(label="Test connectivity before setup")
|
|
testCheck.set_active(True)
|
|
contentArea.pack_start(testCheck, False, False, 0)
|
|
|
|
# Examples
|
|
exampleLabel = Gtk.Label()
|
|
exampleLabel.set_markup(
|
|
"<small><b>Examples:</b>\n"
|
|
" • git@github.com:username/pass-store.git\n"
|
|
" • https://github.com/username/pass-store.git\n"
|
|
" • ssh://user@server.com/~/pass-store.git\n"
|
|
" • user@server.com:pass-store</small>"
|
|
)
|
|
exampleLabel.set_xalign(0)
|
|
contentArea.pack_start(exampleLabel, False, False, 0)
|
|
|
|
# Status label for validation feedback
|
|
statusLabel = Gtk.Label()
|
|
statusLabel.set_xalign(0)
|
|
statusLabel.set_line_wrap(True)
|
|
contentArea.pack_start(statusLabel, False, False, 0)
|
|
|
|
# Make status label accessible as a live region
|
|
statusAccessible = statusLabel.get_accessible()
|
|
statusAccessible.set_name("Validation status")
|
|
|
|
dialog.show_all()
|
|
statusLabel.hide()
|
|
|
|
# Helper function to show error and announce to screen reader
|
|
def show_status_message(message, messageType='error'):
|
|
"""Show status message and make it accessible"""
|
|
if messageType == 'error':
|
|
statusLabel.set_markup(f"<span foreground='red'>{message}</span>")
|
|
elif messageType == 'info':
|
|
statusLabel.set_markup(f"<span foreground='blue'>{message}</span>")
|
|
elif messageType == 'success':
|
|
statusLabel.set_markup(f"<span foreground='green'>{message}</span>")
|
|
else:
|
|
statusLabel.set_text(message)
|
|
|
|
statusLabel.show()
|
|
|
|
# Use Orca's notification system by showing a simple dialog
|
|
# This ensures the message is read aloud
|
|
if messageType == 'error':
|
|
# For errors, show an accessible message dialog briefly
|
|
errorDialog = Gtk.MessageDialog(
|
|
transient_for=dialog,
|
|
flags=Gtk.DialogFlags.MODAL,
|
|
message_type=Gtk.MessageType.ERROR,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text=message
|
|
)
|
|
errorDialog.run()
|
|
errorDialog.destroy()
|
|
urlEntry.grab_focus()
|
|
|
|
while True:
|
|
response = dialog.run()
|
|
|
|
if response != Gtk.ResponseType.OK:
|
|
dialog.destroy()
|
|
return
|
|
|
|
remoteUrl = urlEntry.get_text().strip()
|
|
|
|
# Validate URL format
|
|
if not self.validate_git_url(remoteUrl):
|
|
show_status_message("Invalid URL format. Please check the examples above.", 'error')
|
|
continue
|
|
|
|
# Test connectivity if requested
|
|
if testCheck.get_active():
|
|
# Show testing message
|
|
testingDialog = Gtk.MessageDialog(
|
|
transient_for=dialog,
|
|
flags=Gtk.DialogFlags.MODAL,
|
|
message_type=Gtk.MessageType.INFO,
|
|
buttons=Gtk.ButtonsType.NONE,
|
|
text="Testing connectivity..."
|
|
)
|
|
testingDialog.show()
|
|
|
|
# Process events to update UI
|
|
while Gtk.events_pending():
|
|
Gtk.main_iteration()
|
|
|
|
try:
|
|
connectionSuccess = self.test_git_url_connectivity(remoteUrl)
|
|
testingDialog.destroy()
|
|
|
|
if not connectionSuccess:
|
|
show_status_message("Cannot connect to repository. Check URL and network access.", 'error')
|
|
continue
|
|
except subprocess.TimeoutExpired:
|
|
testingDialog.destroy()
|
|
show_status_message("Connection timeout. Check URL and network access.", 'error')
|
|
continue
|
|
except Exception as e:
|
|
testingDialog.destroy()
|
|
show_status_message(f"Error: {str(e)}", 'error')
|
|
continue
|
|
|
|
# Show success message
|
|
successDialog = Gtk.MessageDialog(
|
|
transient_for=dialog,
|
|
flags=Gtk.DialogFlags.MODAL,
|
|
message_type=Gtk.MessageType.INFO,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text="Connection successful!"
|
|
)
|
|
successDialog.format_secondary_text("The repository is accessible and ready to use.")
|
|
successDialog.run()
|
|
successDialog.destroy()
|
|
|
|
# URL is valid, proceed with setup
|
|
dialog.destroy()
|
|
self.initialize_git_sync(remoteUrl)
|
|
break
|
|
|
|
def initialize_git_sync(self, remoteUrl):
|
|
"""Initialize git repository and set up remote"""
|
|
passwordStoreDir = str(Path.home() / '.password-store')
|
|
|
|
# Initialize git
|
|
result = subprocess.run(
|
|
['pass', 'git', 'init'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
self.show_error(f"Failed to initialize git: {result.stderr}")
|
|
return
|
|
|
|
# Add remote
|
|
result = subprocess.run(
|
|
['git', '-C', passwordStoreDir, 'remote', 'add', 'origin', remoteUrl],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
self.show_error(f"Failed to add remote: {result.stderr}")
|
|
return
|
|
|
|
# Ask if user wants to push now
|
|
pushDialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
flags=0,
|
|
message_type=Gtk.MessageType.QUESTION,
|
|
buttons=Gtk.ButtonsType.YES_NO,
|
|
text="Git sync set up successfully!"
|
|
)
|
|
pushDialog.format_secondary_text(
|
|
f"Repository initialized and remote configured:\n{remoteUrl}\n\n"
|
|
"Would you like to push your passwords to the remote now?"
|
|
)
|
|
|
|
response = pushDialog.run()
|
|
pushDialog.destroy()
|
|
|
|
if response == Gtk.ResponseType.YES:
|
|
# Push to remote
|
|
result = subprocess.run(
|
|
['pass', 'git', 'push', '-u', 'origin', 'master'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
# Try 'main' branch if 'master' fails
|
|
result = subprocess.run(
|
|
['pass', 'git', 'push', '-u', 'origin', 'main'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
self.show_error(f"Push failed: {result.stderr}\n\nYou can push manually later from the Git tab.")
|
|
else:
|
|
self.play_sound_effect(1000, 0.1)
|
|
|
|
# Update the UI to reflect git is now initialized
|
|
self.isGitRepo = True
|
|
self.rebuild_ui_with_git()
|
|
|
|
def rebuild_ui_with_git(self):
|
|
"""Rebuild the UI to include the Git tab"""
|
|
# Clear existing notebook
|
|
for i in range(self.notebook.get_n_pages()):
|
|
self.notebook.remove_page(0)
|
|
|
|
# Rebuild all tabs
|
|
self.build_browse_tab()
|
|
self.build_add_tab()
|
|
self.build_organize_tab()
|
|
self.build_git_tab()
|
|
|
|
# Reconnect signals
|
|
self.setup_accessibility()
|
|
|
|
# Show all and switch to Git tab
|
|
self.show_all()
|
|
self.notebook.set_current_page(3) # Git tab
|
|
|
|
# Update git status
|
|
self.update_git_status()
|
|
|
|
if __name__ == "__main__":
|
|
win = PassManager()
|
|
Gtk.main()
|