From 0e2d9ca73678521648f5f1c78f7b76c3aedf3e85 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 6 Dec 2025 04:28:54 -0500 Subject: [PATCH] Password manager using pass added to pannel bound to m. Needs testing but seems to work well. --- scripts/passmanager.py | 1153 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1153 insertions(+) create mode 100755 scripts/passmanager.py diff --git a/scripts/passmanager.py b/scripts/passmanager.py new file mode 100755 index 0000000..80a3305 --- /dev/null +++ b/scripts/passmanager.py @@ -0,0 +1,1153 @@ +#!/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 +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() + + 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 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")) + + # 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 + 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) + + # 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 + + # Get current branch + result = subprocess.run( + ['git', '-C', str(Path.home() / '.password-store'), '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', str(Path.home() / '.password-store'), 'status', '--porcelain', '--branch'], + capture_output=True, + text=True + ) + + statusText = f"Branch: {branch}" + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + if len(lines) > 1: + statusText += f" ({len(lines) - 1} changes)" + + 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 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() + +if __name__ == "__main__": + win = PassManager() + Gtk.main()