Files
2026-07-12 18:56:10 +05:45

615 lines
21 KiB
Python

from collections.abc import Iterator
from pathlib import Path
from fabric.utils import Gdk, GLib, idle_add, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from config.data import plugins
from modules.launcher.base import LauncherPlugin
from services import PasswordManager
from utils.functions import copy_text, trigger_paste_shortcut
class PasswordManagerPlugin(LauncherPlugin):
@property
def name(self) -> str:
return "passwords"
@property
def icon(self) -> str:
return "dialog-password-symbolic"
@property
def keywords(self) -> list[str]:
return ["pass"]
def __init__(self, handler):
super().__init__(handler)
self.passwords_dir = Path(plugins) / "passwords"
self.passwords_dir.mkdir(parents=True, exist_ok=True)
self.passwords_file_path = self.passwords_dir / "passwords.json"
self.key_file_path = self.passwords_dir / "key.key"
self.salt_file_path = self.passwords_dir / "salt.key"
self._query_handler: int = 0
self.current_slots: list = []
self.passwords: dict = {}
self.password_manager = None
self.master_password_set = False
self._awaiting_master_password: bool = False
def on_search(self, text: str) -> None:
self.query_passwords(text)
def stop(self):
if self._query_handler:
remove_handler(self._query_handler)
self._query_handler = 0
self.current_slots.clear()
@property
def is_password_mode(self) -> bool:
return self._awaiting_master_password
def _is_unlocked(self) -> bool:
return self.password_manager is not None and self.master_password_set
def is_waiting_for_password(self) -> bool:
return self._awaiting_master_password
def _get_password_manager(self, master_password: str = None):
if self.password_manager is None and master_password:
self.password_manager = PasswordManager(master_password)
self.password_manager.passwords_file = self.passwords_file_path
self.password_manager.key_file = self.key_file_path
self.password_manager.salt_file = self.salt_file_path
self.password_manager._initialize_password_file()
self.password_manager._setup_encryption()
self.master_password_set = True
return self.password_manager
def _check_master_password_exists(self) -> bool:
return self.key_file_path.exists()
def _initialize_password_manager(self, password: str) -> bool:
try:
self.password_manager = PasswordManager(password)
self.password_manager.passwords_file = self.passwords_file_path
self.password_manager.key_file = self.key_file_path
self.password_manager.salt_file = self.salt_file_path
self.password_manager._initialize_password_file()
self.password_manager._setup_encryption()
self.master_password_set = True
return True
except Exception:
return False
def _verify_master_password(self, password: str) -> bool:
try:
temp_pm = PasswordManager(password)
temp_pm.passwords_file = self.passwords_file_path
temp_pm.key_file = self.key_file_path
temp_pm.salt_file = self.salt_file_path
temp_pm._initialize_password_file()
temp_pm._setup_encryption()
return temp_pm.verify_master_password(password)
except Exception:
return False
def query_passwords(self, text: str) -> None:
self.handler.start("passwords")
self.current_slots.clear()
text_stripped = text.strip()
commands = {
"lock": self._handle_lock_command,
"help": lambda t: self._show_command_help(),
"add": self._handle_add_command,
"remove": self._handle_remove_command,
"update": self._handle_update_command,
}
for prefix, handler in commands.items():
if text_stripped.startswith(prefix):
if (
prefix in ("add", "remove", "update")
and not self._is_unlocked()
and self._check_master_password_exists()
):
self._prompt_master_password()
return
if prefix in ("add", "remove", "update") and not self._is_unlocked():
self._show_master_password_required()
return
handler(text)
return
if not self._is_unlocked():
if self._check_master_password_exists():
self._prompt_master_password()
else:
self._prompt_master_password_for_setup()
return
self._load_and_display_passwords(text)
def _load_and_display_passwords(self, text: str) -> None:
pm = self._get_password_manager()
if not pm:
self._show_master_password_required()
return
result = pm.list_passwords(decrypt_passwords=False)
if not result["success"]:
self._show_error_message(f"Failed to load passwords: {result['error']}")
return
self.passwords = result["data"]
# Remove the master password hash and salt from display
self.passwords.pop("_master_password_hash", None)
self.passwords.pop("_master_password_salt", None)
if not self.passwords:
self._show_no_passwords()
return
filtered_passwords = self._filter_passwords(text)
if not filtered_passwords:
self._show_no_results()
return
self._query_handler = idle_add(
self._bake_next_password_slot, iter(filtered_passwords), pin=True
)
def _filter_passwords(self, text: str) -> Iterator[tuple[str, dict]]:
if not text.strip():
return iter(self.passwords.items())
text_lower = text.lower()
return iter(
(name, data)
for name, data in self.passwords.items()
if (
text_lower in name.lower()
or text_lower in data.get("username", "").lower()
)
)
def _show_no_passwords(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot password-no-passwords",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No passwords found",
style_classes="password-no-passwords-text",
h_align="center",
),
],
),
tooltip_text="Use 'add name username password' to add passwords",
),
"passwords",
)
self.handler.done()
def _show_no_results(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot password-no-results",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No passwords match your search",
style_classes="password-no-results-text",
h_align="center",
),
],
),
),
"passwords",
)
self.handler.done()
def _bake_next_password_slot(self, iterator: Iterator[tuple[str, dict]]) -> bool:
try:
password_name, password_data = next(iterator)
except StopIteration:
idle_add(self.handler.done)
return False
self._create_password_slot(password_name, password_data)
return True
def _create_password_slot(self, password_name: str, password_data: dict) -> None:
username = password_data.get("username", "")
name_label = Label(
label=password_name,
style_classes="password-name",
h_align="start",
max_chars_width=25,
ellipsization="end",
)
username_label = Label(
label=username if username else "No username",
style_classes="password-username",
h_align="start",
max_chars_width=30,
ellipsization="end",
)
children = [name_label, username_label]
slot_widget = Button(
style_classes="app-slot password-slot",
child=Box(
orientation="v",
spacing=4,
children=children,
),
tooltip_text="Click to copy password",
on_clicked=lambda *_: self._copy_password(password_name),
)
slot_widget.connect(
"key-press-event",
lambda widget, event, pwd_name=password_name: self._on_slot_key_press(
widget, event, pwd_name
),
)
slot_info = {
"widget": slot_widget,
"name_label": name_label,
"username_label": username_label,
"password_name": password_name,
"password_data": password_data,
}
self.current_slots.append(slot_info)
self.handler.slot_ready(slot_widget, "passwords")
def _copy_password(self, password_name: str) -> None:
pm = self._get_password_manager()
if not pm:
self._show_master_password_required()
return
result = pm.get_password(password_name, decrypt=True)
if not result["success"]:
self._show_error_message(f"Failed to get password: {result['error']}")
return
password = result["data"]["password"]
copy_text(password)
self.handler.launched()
GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
def _on_slot_key_press(self, widget, event, password_name: str) -> bool:
if not event:
return False
return (
(event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
and event.state & Gdk.ModifierType.SHIFT_MASK
and (self._handle_remove_command(f"remove {password_name};") or True)
)
def _prompt_master_password(self) -> None:
self._awaiting_master_password = True
self.handler.start("passwords")
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self.handler.slot_ready(
Button(
style_classes="app-slot password-help",
child=Box(
orientation="v",
spacing=8,
children=[
Label(
label="Enter master password",
style_classes="password-help-text",
h_align="center",
),
Label(
label="Enter=Submit • Empty=Cancel",
style_classes="password-help-subtext",
h_align="center",
),
],
),
),
"passwords",
)
self.handler.done()
def _prompt_master_password_for_setup(self) -> None:
self._awaiting_master_password = True
self.handler.start("passwords")
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self.handler.slot_ready(
Button(
style_classes="app-slot password-help",
child=Box(
orientation="v",
spacing=8,
children=[
Label(
label="Set master password",
style_classes="password-help-text",
h_align="center",
),
Label(
label="Enter=Submit • Empty=Cancel",
style_classes="password-help-subtext",
h_align="center",
),
],
),
),
"passwords",
)
self.handler.done()
def submit_password(self, password: str) -> None:
if not self._awaiting_master_password:
return
if not password:
self.cancel_password()
return
# Check if this is setup (no existing master password) or unlock (existing master password)
if not self._check_master_password_exists():
# Setup mode: initialize new master password
if not self._initialize_password_manager(password):
self._show_error_message("Failed to initialize password manager!")
return
else:
# Unlock mode: verify existing master password
if not self._verify_master_password(password):
self._show_error_message("Invalid master password!")
return
if not self._initialize_password_manager(password):
self._show_error_message("Failed to initialize password manager!")
return
self._awaiting_master_password = False
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
self._refresh_password_list()
def cancel_password(self) -> None:
if not self._awaiting_master_password:
return
self._awaiting_master_password = False
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
self._show_master_password_required()
def reset_prompt_state(self) -> None:
self._awaiting_master_password = False
def _handle_lock_command(self, text: str) -> None:
self.password_manager = None
self.master_password_set = False
self.current_slots.clear()
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self.handler.launched()
def _handle_add_command(self, text: str) -> None:
if not text.strip().endswith(";"):
self._show_error_message(
"Add command must end with ';'\n\nUsage: add name username password;"
)
return
password_text = text.strip()[:-1] # Remove semicolon
parts = password_text.split(
None, 3
) # Split into max 4 parts: ["add", "name", "username", "password with spaces"]
if len(parts) < 4:
self._show_command_help("add")
return
name = parts[1]
username = parts[2]
password = parts[
3
] # This will contain the entire password, even if it has spaces
pm = self._get_password_manager()
if not pm:
self._show_master_password_required()
return
result = pm.add_password(name, username, password)
if result["success"]:
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_password_list()
else:
self._show_error_message(f"Error adding password: {result['error']}")
def _handle_remove_command(self, text: str) -> None:
if text.strip() == "remove":
self._load_and_display_passwords("")
return
if not text.strip().endswith(";"):
self._show_error_message(
"Remove command must end with ';'\n\nUsage: remove name;"
)
return
password_text = text.strip()[:-1]
parts = password_text.split()
if len(parts) < 2:
self._show_command_help("remove")
return
password_name = parts[1]
pm = self._get_password_manager()
if not pm:
self._show_master_password_required()
return
result = pm.delete_password(password_name)
if not result["success"]:
self._show_error_message(f"Error removing password: {result['error']}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_password_list()
def _handle_update_command(self, text: str) -> None:
if text.strip() == "update":
self._load_and_display_passwords("")
return
if not text.strip().endswith(";"):
self._show_error_message(
"Update command must end with ';'\n\nUsage: update name field new_value;"
)
return
password_text = text.strip()[:-1]
parts = password_text.split()
if len(parts) < 4:
self._show_command_help("update")
return
password_name = parts[1]
field = parts[2]
if field not in ["username", "password"]:
self._show_error_message("Invalid field. Use: username or password")
return
new_value = " ".join(parts[3:])
pm = self._get_password_manager()
if not pm:
self._show_master_password_required()
return
result = pm.update_password(password_name, **{field: new_value})
if not result["success"]:
self._show_error_message(f"Error updating password: {result['error']}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_password_list()
def _show_master_password_required(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot password-master-required",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="Master password required!\n\nYou'll be prompted to set a master password on first use, or to unlock if already set.",
style_classes="password-master-required-text",
h_align="center",
),
],
),
tooltip_text="Set a master password to encrypt your passwords",
),
"passwords",
)
self.handler.done()
def _show_command_help(self, command: str = None) -> None:
help_texts = {
"add": "Usage: add name username password;\nExample: add gmail user@gmail.com mypass;\nNote: Must end with semicolon (;)",
"remove": "Usage: remove name;\nExample: remove gmail;\nNote: Must end with semicolon (;)",
"update": "Usage: update name field new_value;\nFields: username, password\nExample: update gmail password newpass123;\nNote: Must end with semicolon (;)",
"lock": "Usage: lock\nExample: lock\n\nThis locks your password vault for security.\nYou'll need to enter the master password in the prompt when requested.",
}
help_text = help_texts.get(
command,
"""Available commands:
• lock (LOCK VAULT FOR SECURITY!)
• add name username password;
• remove name;
• update name field new_value;
• help""",
)
self.handler.slot_ready(
Button(
style_classes="app-slot password-help",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=help_text,
style_classes="password-help-text",
h_align="center",
),
],
),
),
"passwords",
)
self.handler.done()
def _show_error_message(self, message: str) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot password-error",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=message,
style_classes="password-error-text",
h_align="center",
),
],
),
on_clicked=lambda *_: self.handler.launched(),
),
"passwords",
)
self.handler.done()
def _refresh_password_list(self) -> None:
self.current_slots.clear()
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
idle_add(self._show_password_list)
def _show_password_list(self) -> None:
self.handler.start("passwords")
self._load_and_display_passwords("")
def handle_external(self, command: str, args: str) -> None:
# Map external commands if needed
pass