"""System tray application for Vault. Main entry point for the GUI application. """ import signal import subprocess import sys from pathlib import Path from loguru import logger from PySide6.QtCore import QTimer from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon from src.core.sync_manager import SyncStatus from src.core.vault import Vault, VaultError, VaultState from src.ui.dialogs.new_vault import NewVaultDialog from src.ui.dialogs.open_vault import OpenVaultDialog from src.ui.notifications import NotificationManager class VaultTrayApp: """System tray application for managing Vault.""" def __init__(self) -> None: self._app = QApplication(sys.argv) self._app.setQuitOnLastWindowClosed(False) self._app.setApplicationName("Vault") self._vault = Vault( on_state_change=self._on_vault_state_change, on_sync_event=lambda e: self._on_sync_event(e), ) self._notifications = NotificationManager() self._space_warned = False self._tray = QSystemTrayIcon() self._setup_tray() # Status check timer self._status_timer = QTimer() self._status_timer.timeout.connect(self._update_status) self._status_timer.start(5000) # Check every 5 seconds # Replica availability check timer self._replica_timer = QTimer() self._replica_timer.timeout.connect(self._check_replica_availability) self._replica_timer.start(30000) # Check every 30 seconds # Setup signal handlers for graceful shutdown self._setup_signal_handlers() def _setup_signal_handlers(self) -> None: """Setup signal handlers for graceful shutdown.""" def signal_handler(signum: int, frame: object) -> None: logger.info(f"Received signal {signum}, shutting down gracefully...") self._quit() signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Use a timer to allow signal processing in Qt event loop self._signal_timer = QTimer() self._signal_timer.timeout.connect(lambda: None) # Keep event loop responsive self._signal_timer.start(500) def _setup_tray(self) -> None: """Set up tray icon and menu.""" self._update_icon() self._tray.setToolTip("Vault - Resilientní úložiště") menu = QMenu() # Status header self._status_action = menu.addAction("Žádný vault otevřen") self._status_action.setEnabled(False) menu.addSeparator() # Open folder action self._open_folder_action = menu.addAction("Otevřít složku") self._open_folder_action.triggered.connect(self._open_folder) self._open_folder_action.setEnabled(False) menu.addSeparator() # Vault management menu.addAction("Vytvořit nový vault...").triggered.connect(self._new_vault) menu.addAction("Otevřít vault...").triggered.connect(self._open_vault) self._close_action = menu.addAction("Zavřít vault") self._close_action.triggered.connect(self._close_vault) self._close_action.setEnabled(False) menu.addSeparator() # Replica management self._add_replica_action = menu.addAction("Přidat repliku...") self._add_replica_action.triggered.connect(self._add_replica) self._add_replica_action.setEnabled(False) self._manage_replicas_action = menu.addAction("Spravovat repliky...") self._manage_replicas_action.triggered.connect(self._manage_replicas) self._manage_replicas_action.setEnabled(False) menu.addSeparator() # Sync action self._sync_action = menu.addAction("Synchronizovat") self._sync_action.triggered.connect(self._manual_sync) self._sync_action.setEnabled(False) # Resize action self._resize_action = menu.addAction("Zvětšit vault...") self._resize_action.triggered.connect(self._resize_vault) self._resize_action.setEnabled(False) menu.addSeparator() # Quit menu.addAction("Ukončit").triggered.connect(self._quit) self._tray.setContextMenu(menu) self._tray.activated.connect(self._on_tray_activated) self._tray.show() def _update_icon(self) -> None: """Update tray icon based on vault state.""" # Use built-in icons for now if not self._vault.is_open: # Gray - no vault open icon = QIcon.fromTheme("folder-grey", QIcon.fromTheme("folder")) elif self._vault.sync_status == SyncStatus.SYNCING: # Blue - syncing icon = QIcon.fromTheme("folder-sync", QIcon.fromTheme("folder-download")) elif self._vault.sync_status == SyncStatus.ERROR: # Red - error icon = QIcon.fromTheme("folder-important", QIcon.fromTheme("dialog-error")) elif self._vault.get_unavailable_replicas(): # Yellow - some replicas unavailable icon = QIcon.fromTheme("folder-yellow", QIcon.fromTheme("folder-visiting")) else: # Green - all good icon = QIcon.fromTheme("folder-green", QIcon.fromTheme("folder-open")) self._tray.setIcon(icon) def _update_status(self) -> None: """Update status display.""" self._update_icon() if not self._vault.is_open: self._status_action.setText("Žádný vault otevřen") self._open_folder_action.setEnabled(False) self._close_action.setEnabled(False) self._add_replica_action.setEnabled(False) self._manage_replicas_action.setEnabled(False) self._sync_action.setEnabled(False) self._resize_action.setEnabled(False) else: manifest = self._vault.manifest name = manifest.vault_name if manifest else "Vault" replicas = self._vault.replica_count unavailable = self._vault.get_unavailable_replicas() if unavailable: online = replicas total = replicas + len(unavailable) status_text = f"{name} ({online}/{total} replik online)" else: status_text = f"{name} ({replicas} replik{'a' if replicas == 1 else 'y' if replicas < 5 else ''})" if self._vault.sync_status == SyncStatus.SYNCING: status_text += " - synchronizace..." self._status_action.setText(status_text) self._open_folder_action.setEnabled(True) self._close_action.setEnabled(True) self._add_replica_action.setEnabled(True) self._manage_replicas_action.setEnabled(True) self._sync_action.setEnabled(True) self._resize_action.setEnabled(True) # Check space warning if self._vault.check_space_warning(90.0): space_info = self._vault.get_space_info() if space_info: free_mb = space_info["free"] // (1024 * 1024) if not hasattr(self, "_space_warned") or not self._space_warned: self._notifications.notify( "Vault téměř plný", f"Zbývá pouze {free_mb} MB volného místa", critical=True, ) self._space_warned = True else: self._space_warned = False def _check_replica_availability(self) -> None: """Check for replica availability and reconnect if possible.""" if not self._vault.is_open: return unavailable = self._vault.get_unavailable_replicas() if unavailable: # Try to reconnect reconnected = self._vault.reconnect_unavailable_replicas() if reconnected > 0: self._notifications.notify( "Repliky připojeny", f"Připojeno {reconnected} replik{'a' if reconnected == 1 else 'y' if reconnected < 5 else ''}", ) self._update_status() def _on_vault_state_change(self, state: VaultState) -> None: """Handle vault state change.""" logger.debug(f"Vault state changed: {state}") self._update_status() if state == VaultState.OPEN: self._notifications.notify( "Vault otevřen", f"Mount point: {self._vault.mount_point}", ) elif state == VaultState.CLOSED: self._notifications.notify("Vault zavřen", "") elif state == VaultState.ERROR: self._notifications.notify("Chyba", "Nastala chyba při práci s vault", critical=True) def _on_sync_event(self, event: object) -> None: """Handle sync event.""" self._update_icon() def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: """Handle tray icon activation.""" if reason == QSystemTrayIcon.ActivationReason.DoubleClick: self._open_folder() def _open_folder(self) -> None: """Open vault mount point in file manager.""" if not self._vault.is_open or not self._vault.mount_point: return try: # Try xdg-open (Linux) subprocess.Popen(["xdg-open", str(self._vault.mount_point)]) except Exception as e: logger.error(f"Failed to open folder: {e}") self._notifications.notify("Chyba", f"Nepodařilo se otevřít složku: {e}", critical=True) def _new_vault(self) -> None: """Show new vault dialog.""" dialog = NewVaultDialog() if dialog.exec(): result = dialog.get_result() try: from src.core.image_manager import create_sparse_image image_path = Path(result["path"]) create_sparse_image(image_path, result["size_mb"]) mount = self._vault.open(image_path) # Update manifest with user-provided name if self._vault.manifest: self._vault.manifest.vault_name = result["name"] self._vault.manifest.image_size_mb = result["size_mb"] self._vault.manifest.save(mount) self._update_status() self._open_folder() except Exception as e: logger.error(f"Failed to create vault: {e}") self._notifications.notify("Chyba", f"Nepodařilo se vytvořit vault: {e}", critical=True) def _open_vault(self) -> None: """Show open vault dialog.""" dialog = OpenVaultDialog() if dialog.exec(): vault_path = dialog.get_selected_path() if vault_path: try: self._vault.open(Path(vault_path)) self._update_status() self._open_folder() except VaultError as e: logger.error(f"Failed to open vault: {e}") self._notifications.notify("Chyba", f"Nepodařilo se otevřít vault: {e}", critical=True) def _close_vault(self) -> None: """Close current vault.""" if self._vault.is_open: self._vault.close() self._update_status() def _add_replica(self) -> None: """Show add replica dialog.""" if not self._vault.is_open: return from PySide6.QtWidgets import QFileDialog path, _ = QFileDialog.getSaveFileName( None, "Vyberte umístění pro novou repliku", "", "Vault soubory (*.vault)", ) if path: if not path.endswith(".vault"): path += ".vault" try: self._vault.add_replica(Path(path)) self._notifications.notify("Replika přidána", f"Nová replika: {path}") self._update_status() except VaultError as e: logger.error(f"Failed to add replica: {e}") self._notifications.notify("Chyba", f"Nepodařilo se přidat repliku: {e}", critical=True) def _manage_replicas(self) -> None: """Show replica management dialog.""" if not self._vault.is_open: return from src.ui.dialogs.manage_replicas import ManageReplicasDialog dialog = ManageReplicasDialog(self._vault) dialog.exec() self._update_status() def _manual_sync(self) -> None: """Trigger manual synchronization.""" if self._vault.is_open: try: self._vault.sync() self._notifications.notify("Synchronizace dokončena", "") except VaultError as e: logger.error(f"Sync failed: {e}") self._notifications.notify("Chyba synchronizace", str(e), critical=True) def _resize_vault(self) -> None: """Show resize vault dialog.""" if not self._vault.is_open: return from src.ui.dialogs.resize_vault import ResizeVaultDialog dialog = ResizeVaultDialog(self._vault) if dialog.exec(): new_size = dialog.get_new_size() if new_size: try: self._vault.resize(new_size) self._notifications.notify( "Vault zvětšen", f"Nová velikost: {new_size} MB", ) self._update_status() except VaultError as e: logger.error(f"Resize failed: {e}") self._notifications.notify("Chyba", f"Nepodařilo se zvětšit vault: {e}", critical=True) def _quit(self) -> None: """Quit the application.""" if self._vault.is_open: self._vault.close() self._tray.hide() self._app.quit() def run(self) -> int: """Run the application.""" logger.info("Vault tray application starting...") return self._app.exec() def main() -> int: """Main entry point for tray application.""" app = VaultTrayApp() return app.run() if __name__ == "__main__": sys.exit(main())