#!/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 . 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("Examples:\n • git@github.com:user/pass-store.git\n • https://github.com/user/pass-store.git\n • user@server.com:~/pass-store") 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( "Examples:\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" ) 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"{message}") elif messageType == 'info': statusLabel.set_markup(f"{message}") elif messageType == 'success': statusLabel.set_markup(f"{message}") 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()