Files
I38/scripts/passmanager.py
2025-12-06 05:03:45 -05:00

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()