import json import subprocess import threading import time from pathlib import Path from typing import Dict, List, Optional from fabric.utils import get_relative_path from modules.launcher.plugin_base import PluginBase from modules.launcher.result import Result from services.auth import ( generate_totp, get_time_remaining_with_blink, parse_otpauth_uri, scan_qr_and_add_account, validate_base32_secret, ) class OTPPlugin(PluginBase): """Plugin for managing TOTP (Time-based One-Time Password) codes.""" def __init__(self): super().__init__() self.display_name = "OTP Manager" self.description = "Manage TOTP codes and 2FA authentication" self.secrets_file = Path( get_relative_path("../../../config/assets/accounts.json") ) self.secrets: Dict[str, Dict] = {} self.last_update = 0 # Threading for auto-refresh self.refresh_thread = None self.stop_refresh = threading.Event() def initialize(self): """Initialize the OTP plugin.""" self.set_triggers(["otp"]) self._load_secrets() self._ensure_config_file() self._start_refresh_thread() def cleanup(self): """Cleanup the OTP plugin.""" if self.refresh_thread and self.refresh_thread.is_alive(): self.stop_refresh.set() self.refresh_thread.join(timeout=1) def _load_secrets(self): """Load secrets from JSON file.""" try: if self.secrets_file.exists(): with open(self.secrets_file, "r", encoding="utf-8") as f: self.secrets = json.load(f) else: self.secrets = {} except Exception as e: print(f"Error loading OTP secrets: {e}") self.secrets = {} def _save_secrets(self): """Save secrets to JSON file.""" try: self.secrets_file.parent.mkdir(parents=True, exist_ok=True) with open(self.secrets_file, "w", encoding="utf-8") as f: json.dump(self.secrets, f, indent=2) except Exception as e: print(f"Error saving OTP secrets: {e}") def _ensure_config_file(self): """Ensure the config file exists.""" if not self.secrets_file.exists(): self.secrets_file.parent.mkdir(parents=True, exist_ok=True) with open(self.secrets_file, "w", encoding="utf-8") as f: json.dump({}, f, indent=2) def _start_refresh_thread(self): """Start background thread for auto-refreshing tokens.""" def refresh_loop(): while not self.stop_refresh.wait(5): current_time = time.time() if current_time - self.last_update >= 5: self.last_update = current_time # Only refresh if we have secrets and launcher is likely active if self.secrets: try: self._selective_force_refresh() except Exception: pass self.refresh_thread = threading.Thread(target=refresh_loop, daemon=True) self.refresh_thread.start() def _selective_force_refresh(self): """Update time display in existing OTP result items.""" try: import gc from gi.repository import GLib def do_update(): try: for obj in gc.get_objects(): if ( hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher" and hasattr(obj, "results_box") and hasattr(obj, "visible") and obj.visible and hasattr(obj, "results") and obj.results ): has_otp_results = any( result.data and result.data.get("type") == "totp" for result in obj.results if hasattr(result, "data") and result.data ) if has_otp_results: self._update_existing_result_labels(obj.results_box) return False except Exception: pass return False GLib.idle_add(do_update) except Exception: pass def _update_existing_result_labels(self, results_box): """Update subtitle labels in existing ResultItem widgets.""" try: time_display = self._get_time_remaining_with_blink() for child in results_box.get_children(): if ( hasattr(child, "__class__") and child.__class__.__name__ == "ResultItem" and hasattr(child, "result") and hasattr(child.result, "data") and child.result.data and child.result.data.get("type") == "totp" ): self._update_result_item_content(child, time_display) except Exception as e: print(f"Error updating result labels: {e}") def _update_result_item_content(self, result_item, time_display): """Update both the title (OTP code) and subtitle (time display) of a specific ResultItem.""" try: account_name = result_item.result.data.get("account", "") if not account_name or account_name not in self.secrets: return account_data = self.secrets[account_name] secret = account_data.get("secret", "") issuer = account_data.get("issuer", "") display_name = f"{issuer} - {account_name}" if issuer else account_name current_totp_code = self._generate_totp(secret) if not current_totp_code: return old_code = result_item.result.data.get("code", "") if current_totp_code != old_code: result_item.result.data["code"] = current_totp_code self._find_and_update_title_label(result_item, current_totp_code) result_item.result.action = ( lambda code=current_totp_code: self._copy_to_clipboard(code) ) new_subtitle_markup = f"{display_name} • {time_display} remaining" self._find_and_update_subtitle_label(result_item, new_subtitle_markup) except Exception as e: print(f"Error updating result item: {e}") def _find_and_update_title_label(self, result_item, new_title): """Find the title label widget and update its text.""" def find_title_label(widget): if hasattr(widget, "get_name") and widget.get_name() == "result-item-title": return widget if hasattr(widget, "get_children"): for child in widget.get_children(): found = find_title_label(child) if found: return found return None title_label = find_title_label(result_item) if title_label and hasattr(title_label, "set_label"): title_label.set_label(new_title) def _find_and_update_subtitle_label(self, result_item, new_markup): """Find the subtitle label widget and update its markup.""" def find_subtitle_label(widget): if ( hasattr(widget, "get_name") and widget.get_name() == "result-item-subtitle" ): return widget if hasattr(widget, "get_children"): for child in widget.get_children(): found = find_subtitle_label(child) if found: return found return None subtitle_label = find_subtitle_label(result_item) if subtitle_label and hasattr(subtitle_label, "set_markup"): subtitle_label.set_markup(new_markup) def _copy_to_clipboard(self, text: str): """Copy text to clipboard.""" try: try: subprocess.run(["wl-copy"], input=text.encode(), check=True) except subprocess.SubprocessError: subprocess.run( ["xclip", "-selection", "clipboard"], input=text.encode(), check=True, ) except Exception as e: print(f"Failed to copy to clipboard: {e}") def _trigger_refresh(self): """Trigger launcher refresh to return to default OTP view.""" try: from gi.repository import GLib # Use a small delay to ensure the action completes first def trigger_refresh(): try: # Try to access the launcher through the fabric Application from fabric import Application app = Application.get_default() if app and hasattr(app, "launcher"): launcher = app.launcher if launcher and hasattr(launcher, "search_entry"): # Clear the search entry and set it to just "otp " launcher.search_entry.set_text("otp ") # Position cursor at the end launcher.search_entry.set_position(-1) # Trigger the search to show default OTP view if hasattr(launcher, "_perform_search"): launcher._perform_search("otp ") return False # Fallback: try to find launcher instance through other means import gc for obj in gc.get_objects(): if ( hasattr(obj, "__class__") and obj.__class__.__name__ == "Launcher" ): if hasattr(obj, "search_entry") and hasattr( obj, "_perform_search" ): obj.search_entry.set_text("otp ") obj.search_entry.set_position(-1) obj._perform_search("otp ") return False except Exception as e: print(f"Error forcing launcher refresh: {e}") return False # Don't repeat # Use a small delay to ensure the action completes first GLib.timeout_add(50, trigger_refresh) except Exception as e: print(f"Could not trigger refresh: {e}") def _remove_account_and_refresh(self, account_name: str): """Remove an account and trigger refresh to return to default OTP view.""" try: if account_name in self.secrets: # Remove the account del self.secrets[account_name] self._save_secrets() # Trigger refresh to return to default OTP view self._trigger_refresh() except Exception as e: print(f"Error removing account {account_name}: {e}") def _generate_totp(self, secret: str) -> Optional[str]: """Generate TOTP code from secret.""" return generate_totp(secret) def _get_time_remaining_with_blink(self) -> str: """Get time remaining with blinking effect.""" return get_time_remaining_with_blink() def query(self, query_string: str) -> List[Result]: """Process OTP queries.""" query = query_string.strip() if not query: return self._list_otp_codes() query_lower = query.lower() if query_lower.startswith("add "): add_content = query[4:].strip() # Handle both formats: with ``` and without ``` if "```" in add_content: # Old format: add account```secret``` parts = add_content.split("```", 1) if len(parts) == 2: account_name = parts[0].strip() secret_or_uri = parts[1].strip() return self._handle_direct_add(account_name, secret_or_uri) elif " " in add_content: # New format: add account secret parts = add_content.split(" ", 1) if len(parts) == 2: account_name = parts[0].strip() secret_or_uri = parts[1].strip() return self._handle_direct_add(account_name, secret_or_uri) return self._handle_add_command(add_content) elif query_lower == "remove" or query_lower.startswith("remove "): # Handle both "remove" and "remove accountname" if query_lower == "remove": remove_content = "" else: remove_content = query[7:].strip() return self._handle_remove_command(remove_content) elif query_lower == "qr" or query_lower.startswith("qr "): # Handle QR scanning command if query_lower == "qr": qr_content = "" else: qr_content = query[3:].strip() return self._handle_qr_command(qr_content) else: return self._search_accounts(query) def _handle_direct_add(self, account_name: str, secret_or_uri: str) -> List[Result]: """Handle direct addition of OTP account.""" if not account_name or not secret_or_uri: return [ Result( title="Invalid format", subtitle="Usage: add or add ``````", icon_name="info", action=lambda: None, relevance=1.0, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ) ] try: if secret_or_uri.startswith("otpauth://"): return self._handle_otpauth_uri(account_name, secret_or_uri) else: return self._handle_base32_secret(account_name, secret_or_uri) except Exception as e: print(f"OTP Debug: Error in _handle_direct_add: {e}") return [ Result( title="Error adding account", subtitle=f"Debug: {str(e)}", icon_name="cancel", action=lambda: None, relevance=0.5, plugin_name=self.display_name, data={"type": "error", "keep_launcher_open": True}, ) ] def _list_otp_codes(self) -> List[Result]: """List all OTP codes with current tokens.""" results = [] if not self.secrets: results.append( Result( title="No OTP accounts configured", subtitle="Use 'add ' to add your first account", icon_name="gtk-authentication-symbolic", action=lambda: None, relevance=1.0, plugin_name=self.display_name, data={"type": "empty", "keep_launcher_open": True}, ) ) results.append( Result( title="Available commands:", subtitle="add | remove | qr ", icon_name="info", action=lambda: None, relevance=0.9, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ) ) return results time_display = self._get_time_remaining_with_blink() for account_name, account_data in self.secrets.items(): secret = account_data.get("secret", "") issuer = account_data.get("issuer", "") totp_code = self._generate_totp(secret) if totp_code: display_name = f"{issuer} - {account_name}" if issuer else account_name results.append( Result( title=f"{totp_code}", subtitle_markup=f"{display_name} • { time_display } remaining • Shift+Enter: remove", icon_name="gtk-authentication-symbolic", action=lambda code=totp_code: self._copy_to_clipboard(code), relevance=1.0, plugin_name=self.display_name, data={ "type": "totp", "account": account_name, "code": totp_code, "alt_action": lambda acc=account_name: self._remove_account_and_refresh( acc ), }, ) ) else: results.append( Result( title=f"Error: {account_name}", subtitle="Invalid secret or configuration", icon_name="dialog-cancel-symbolic", action=lambda: None, relevance=0.5, plugin_name=self.display_name, data={ "type": "error", "account": account_name, "keep_launcher_open": True, }, ) ) return results def _search_accounts(self, query: str) -> List[Result]: """Search accounts by name or issuer.""" results = [] query_lower = query.lower() for account_name, account_data in self.secrets.items(): issuer = account_data.get("issuer", "").lower() account_lower = account_name.lower() if query_lower in account_lower or query_lower in issuer: secret = account_data.get("secret", "") totp_code = self._generate_totp(secret) if totp_code: display_name = ( f"{account_data.get('issuer', '')} - {account_name}" if account_data.get("issuer") else account_name ) time_display = self._get_time_remaining_with_blink() results.append( Result( title=f"{totp_code}", subtitle_markup=f"{display_name} • { time_display } remaining • Shift+Enter: remove", icon_name="gtk-authentication-symbolic", action=lambda code=totp_code: self._copy_to_clipboard(code), relevance=1.0, plugin_name=self.display_name, data={ "type": "totp", "account": account_name, "code": totp_code, "alt_action": lambda acc=account_name: self._remove_account_and_refresh( acc ), }, ) ) if not results: results.append( Result( title=f"No accounts found for '{query}'", subtitle="Use 'add ' to create new account", icon_name="edit-find-symbolic", action=lambda: None, relevance=0.5, plugin_name=self.display_name, data={"type": "no_results", "keep_launcher_open": True}, ) ) results.append( Result( title="Available commands:", subtitle="add | remove | qr ", icon_name="info", action=lambda: None, relevance=0.4, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ) ) return results def _handle_add_command(self, account_name: str) -> List[Result]: """Handle manual addition of OTP secret.""" if not account_name: return [ Result( title="Enter account name", subtitle="Usage: add ", icon_name="dialog-question-symbolic", action=lambda: None, relevance=1.0, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ) ] return [ Result( title=f"To add '{account_name}':", subtitle=f"Type: add {account_name} ", icon_name="info", action=lambda: None, relevance=1.0, plugin_name=self.display_name, data={ "type": "instruction", "account": account_name, "keep_launcher_open": True, }, ), Result( title="Base32 Secret Format:", subtitle="Example: add gmail JBSWY3DPEHPK3PXP", icon_name="info", action=lambda: None, relevance=0.9, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ), Result( title="otpauth URI Format:", subtitle="Example: add github otpauth://totp/GitHub:user?secret=JBSWY3DPEHPK3PXP", icon_name="info", action=lambda: None, relevance=0.9, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ), Result( title="Remove Account:", subtitle="Example: remove gmail", icon_name="trash", action=lambda: None, relevance=0.8, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ), ] def _handle_qr_command(self, account_name: str) -> List[Result]: """Handle QR scanning command.""" if not account_name: return [ Result( title="Scan QR Code", subtitle="Click to scan QR code from screen", icon_name="view-barcode-qr-symbolic", action=lambda: self._scan_qr_and_add_account(""), relevance=1.0, plugin_name=self.display_name, data={"type": "qr_scan"}, ), Result( title="QR Scan Instructions:", subtitle="Use 'qr ' to specify account name", icon_name="info", action=lambda: None, relevance=0.9, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ), ] else: return [ Result( title=f"Scan QR Code for '{account_name}'", subtitle="Click to scan QR code from screen", icon_name="view-barcode-qr-symbolic", action=lambda name=account_name: self._scan_qr_and_add_account( name ), relevance=1.0, plugin_name=self.display_name, data={"type": "qr_scan"}, ), ] def _scan_qr_and_add_account(self, account_name: str): """Scan QR code and add OTP account.""" print(f"QR scan action called for account: '{account_name}'") print("Starting QR scan process asynchronously...") # Run QR scanning asynchronously so launcher closes immediately import threading thread = threading.Thread(target=self._scan_qr_async, args=(account_name,)) thread.daemon = True thread.start() def _scan_qr_async(self, account_name: str): """Async QR scanning process.""" result = scan_qr_and_add_account(account_name, str(self.secrets_file)) if result["success"]: print(result["message"]) # Reload secrets from file self._load_secrets() # Trigger refresh to show the new account self._trigger_refresh() else: print(f"QR scan failed: {result['error']}") def _handle_remove_command(self, account_name: str) -> List[Result]: """Handle removal of OTP account.""" if not account_name: # Show all available accounts for removal results = [] if not self.secrets: results.append( Result( title="No OTP accounts to remove", subtitle="Use 'add ' to add accounts first", icon_name="info", action=lambda: None, relevance=1.0, plugin_name=self.display_name, data={"type": "empty", "keep_launcher_open": True}, ) ) else: results.append( Result( title="Select account to remove:", subtitle="Type: remove to remove an account", icon_name="user-trash-symbolic", action=lambda: None, relevance=1.0, plugin_name=self.display_name, data={"type": "help", "keep_launcher_open": True}, ) ) # Get time display for consistency with main OTP view time_display = self._get_time_remaining_with_blink() # Show all accounts with their current OTP codes and remove actions for acc_name, account_data in self.secrets.items(): secret = account_data.get("secret", "") issuer = account_data.get("issuer", "") display_name = f"{issuer} - {acc_name}" if issuer else acc_name # Generate current TOTP code totp_code = self._generate_totp(secret) if totp_code: results.append( Result( title=f"{totp_code}", subtitle_markup=f"Press Enter to remove • { time_display } remaining", icon_name="user-trash-symbolic", action=lambda acc=acc_name: self._remove_account_and_refresh( acc ), relevance=0.9, plugin_name=self.display_name, data={ "type": "remove_instruction", "account": acc_name, "code": totp_code, "keep_launcher_open": True, }, ) ) else: results.append( Result( title=f"Error: {acc_name}", subtitle="Press Enter to remove (Invalid secret)", icon_name="user-trash-symbolic", action=lambda acc=acc_name: self._remove_account_and_refresh( acc ), relevance=0.8, plugin_name=self.display_name, data={ "type": "remove_instruction", "account": acc_name, "keep_launcher_open": True, }, ) ) return results # Check if account exists if account_name not in self.secrets: return [ Result( title=f"Account '{account_name}' not found", subtitle="Use 'remove' to see all available accounts", icon_name="dialog-cancel-symbolic", action=lambda: None, relevance=0.5, plugin_name=self.display_name, data={"type": "error", "keep_launcher_open": True}, ) ] # Get account info for confirmation account_data = self.secrets[account_name] issuer = account_data.get("issuer", "") display_name = f"{issuer} - {account_name}" if issuer else account_name # Show confirmation for removal return [ Result( title=f"Remove '{display_name}'?", subtitle="Press Enter to confirm removal", icon_name="user-trash-symbolic", action=lambda acc=account_name: self._remove_account_and_refresh(acc), relevance=1.0, plugin_name=self.display_name, data={ "type": "remove_confirm", "account": account_name, "keep_launcher_open": True, }, ) ] def _handle_base32_secret(self, account_name: str, secret: str) -> List[Result]: """Handle raw Base32 secret.""" result = validate_base32_secret(secret) if not result["success"]: return [ Result( title="Invalid Base32 secret", subtitle=result["error"], icon_name="dialog-cancel-symbolic", action=lambda: None, relevance=0.5, plugin_name=self.display_name, data={"type": "error", "keep_launcher_open": True}, ) ] self.secrets[account_name] = { "secret": result["secret"], "issuer": "", "algorithm": "SHA1", "digits": 6, "period": 30, } self._save_secrets() return [ Result( title=f"✓ Added '{account_name}'", subtitle=f"OTP account added successfully (secret: { result['secret'][:4] }...)", icon_name="emblem-ok-symbolic", action=lambda: self._trigger_refresh(), relevance=1.0, plugin_name=self.display_name, data={"type": "success", "keep_launcher_open": True}, ) ] def _handle_otpauth_uri(self, account_name: str, uri: str) -> List[Result]: """Handle otpauth:// URI.""" result = parse_otpauth_uri(uri, account_name) if not result["success"]: return [ Result( title="Error parsing otpauth URI", subtitle=result["error"], icon_name="dialog-cancel-symbolic", action=lambda: None, relevance=0.5, plugin_name=self.display_name, data={"type": "error", "keep_launcher_open": True}, ) ] self.secrets[result["account_name"]] = { "secret": result["secret"], "issuer": result["issuer"], "algorithm": result["algorithm"], "digits": result["digits"], "period": result["period"], } self._save_secrets() display_name = ( f"{result['issuer']} - {result['account_name']}" if result["issuer"] else result["account_name"] ) return [ Result( title=f"✓ Added '{display_name}'", subtitle="OTP account added from URI", icon_name="emblem-ok-symbolic", action=lambda: self._trigger_refresh(), relevance=1.0, plugin_name=self.display_name, data={"type": "success", "keep_launcher_open": True}, ) ]