From 1faa691e193790ecb84d810710a7d9dd5e82fef9 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 6 Dec 2025 05:03:45 -0500 Subject: [PATCH] Added a dialog to configure git sync with pass. --- scripts/passmanager.py | 521 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 518 insertions(+), 3 deletions(-) diff --git a/scripts/passmanager.py b/scripts/passmanager.py index 80a3305..5576c99 100755 --- a/scripts/passmanager.py +++ b/scripts/passmanager.py @@ -17,6 +17,7 @@ from gi.repository import Gtk, Gdk, GLib, Atk import subprocess import shutil import os +import re from pathlib import Path class PassManager(Gtk.Window): @@ -63,6 +64,10 @@ class PassManager(Gtk.Window): # 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): @@ -140,11 +145,73 @@ class PassManager(Gtk.Window): 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:") @@ -437,7 +504,7 @@ class PassManager(Gtk.Window): self.gitStatusLabel.set_xalign(0) gitBox.pack_start(self.gitStatusLabel, False, False, 0) - # Button box + # Button box for sync operations buttonBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) gitBox.pack_start(buttonBox, False, False, 0) @@ -453,6 +520,14 @@ class PassManager(Gtk.Window): 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) @@ -856,9 +931,11 @@ class PassManager(Gtk.Window): if not self.isGitRepo: return + passwordStoreDir = str(Path.home() / '.password-store') + # Get current branch result = subprocess.run( - ['git', '-C', str(Path.home() / '.password-store'), 'branch', '--show-current'], + ['git', '-C', passwordStoreDir, 'branch', '--show-current'], capture_output=True, text=True ) @@ -866,17 +943,50 @@ class PassManager(Gtk.Window): # Get status result = subprocess.run( - ['git', '-C', str(Path.home() / '.password-store'), 'status', '--porcelain', '--branch'], + ['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 @@ -898,6 +1008,22 @@ class PassManager(Gtk.Window): 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'): @@ -1148,6 +1274,395 @@ class PassManager(Gtk.Window): """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()