#!/usr/bin/env python3 from views import ActiveView, InactiveView from data_loader import load_customers from models import Customer from PIL import Image, ImageDraw import pystray import threading import sys from gi.repository import Gtk, Gdk, GLib import gi gi.require_version('Gtk', '3.0') class VPNManagerWindow: def __init__(self): self.customers = load_customers() self.filtered_customers = self.customers.copy() self.current_location = None # Track user's current location # Create main window self.window = Gtk.Window() self.window.set_title("VPN Manager") self.window.set_default_size(1200, 750) self.window.connect("delete-event", self.quit_app_from_close) self.window.connect("window-state-event", self.on_window_state_event) # Set up minimal CSS for GNOME-style cards self.setup_css() # Create UI self.setup_ui() self.setup_system_tray() # Start hidden self.window.hide() def setup_css(self): """Minimal CSS for GNOME-style cards""" css_provider = Gtk.CssProvider() css = """ .card { background: @theme_base_color; border-radius: 8px; border: 1px solid @borders; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 16px; margin: 6px; } """ css_provider.load_from_data(css.encode()) # Apply CSS to default screen screen = Gdk.Screen.get_default() style_context = Gtk.StyleContext() style_context.add_provider_for_screen( screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) def setup_ui(self): # Use HeaderBar for native GNOME look header_bar = Gtk.HeaderBar() header_bar.set_show_close_button(True) header_bar.set_title("VPN Manager") header_bar.set_subtitle("Connection Manager") self.window.set_titlebar(header_bar) # Main container with proper spacing main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) main_vbox.set_margin_start(12) main_vbox.set_margin_end(12) main_vbox.set_margin_top(12) main_vbox.set_margin_bottom(12) self.window.add(main_vbox) # Current location display self.current_location_label = Gtk.Label() self.current_location_label.set_markup("Current location: Not set") self.current_location_label.set_halign(Gtk.Align.CENTER) self.current_location_label.set_margin_bottom(8) main_vbox.pack_start(self.current_location_label, False, False, 0) # Search bar with SearchEntry self.search_entry = Gtk.SearchEntry() self.search_entry.set_placeholder_text( "Search customers, locations, or hosts... (* for all)") self.search_entry.connect("search-changed", self.filter_customers) main_vbox.pack_start(self.search_entry, False, False, 0) # Create a stack to switch between views self.view_stack = Gtk.Stack() self.view_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) self.view_stack.set_transition_duration(200) main_vbox.pack_start(self.view_stack, True, True, 0) # Get callbacks for views callbacks = self.get_callbacks() # Create active view (shown by default) self.active_view = ActiveView(callbacks) self.view_stack.add_named(self.active_view.widget, "active") # Create inactive view (shown when searching) self.inactive_view = InactiveView(callbacks) self.view_stack.add_named(self.inactive_view.widget, "inactive") # Render initial data self.render_customers() def setup_system_tray(self): # Create a simple icon for the system tray def create_icon(): # Create a simple network icon width = height = 64 image = Image.new('RGBA', (width, height), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) # Draw a simple network/VPN icon # Outer circle draw.ellipse([8, 8, 56, 56], outline=(50, 150, 50), width=4) # Inner dot draw.ellipse([26, 26, 38, 38], fill=(50, 150, 50)) # Connection lines draw.line([32, 16, 32, 24], fill=(50, 150, 50), width=3) draw.line([32, 40, 32, 48], fill=(50, 150, 50), width=3) draw.line([16, 32, 24, 32], fill=(50, 150, 50), width=3) draw.line([40, 32, 48, 32], fill=(50, 150, 50), width=3) return image # Simple approach: Create tray icon with direct action and minimal menu self.tray_icon = pystray.Icon( "VPN Manager", create_icon(), "VPN Manager - Double-click to open" ) # Set direct click action self.tray_icon.default_action = self.show_window_from_tray # Also provide a right-click menu menu = pystray.Menu( pystray.MenuItem("Open VPN Manager", self.show_window_from_tray, default=True), pystray.MenuItem("Quit", self.quit_app) ) self.tray_icon.menu = menu # Start tray icon in separate thread threading.Thread(target=self.tray_icon.run, daemon=True).start() def get_callbacks(self): """Return callback functions for widget interactions""" return { 'toggle_connection': self.toggle_connection, 'set_location_active': self.set_location_active, 'deactivate_location': self.deactivate_location, 'set_current_location': self.set_current_location, 'open_service': self.open_service, 'open_customer_service': self.open_customer_service } def render_customers(self): # Check if we're in search mode search_term = self.search_entry.get_text().strip() is_searching = bool(search_term) # Separate customers with active and inactive locations customers_with_active = [] customers_with_inactive = [] for customer in self.filtered_customers: active_locations = customer.get_active_locations() inactive_locations = customer.get_inactive_locations() # Prepare active locations (shown when not searching) if active_locations: customer_data = Customer(name=customer.name) customer_data.services = customer.services customer_data.locations = active_locations customers_with_active.append(customer_data) # Prepare inactive locations (shown when searching) if inactive_locations: customer_data = Customer(name=customer.name) customer_data.services = customer.services customer_data.locations = inactive_locations customers_with_inactive.append(customer_data) # Update views based on mode if is_searching: # Search mode: Switch to inactive view and update it self.view_stack.set_visible_child_name("inactive") self.inactive_view.update(customers_with_inactive, search_term) else: # Normal mode: Switch to active view and update it self.view_stack.set_visible_child_name("active") self.active_view.update(customers_with_active) self.window.show_all() def set_location_active(self, location, customer_name): for customer in self.customers: if customer.name == customer_name: target_location = customer.get_location_by_name(location.name) if target_location: target_location.active = True print( f"Mock: Setting {customer.name} - {target_location.name} as active") break # Clear search and return to active view self.search_entry.set_text("") self.render_customers() def deactivate_location(self, location, customer_name): for customer in self.customers: if customer.name == customer_name: target_location = customer.get_location_by_name(location.name) if target_location: target_location.active = False target_location.connected = False # Disconnect when deactivating print( f"Mock: Deactivating {customer.name} - {target_location.name}") break self.render_customers() def set_current_location(self, location, customer_name): """Set the user's current location.""" for customer in self.customers: if customer.name == customer_name: target_location = customer.get_location_by_name(location.name) if target_location: self.current_location = (customer.name, target_location.name) print(f"Current location set to: {customer.name} - {target_location.name}") self.update_current_location_display() break def update_current_location_display(self): """Update the current location display label.""" if self.current_location: customer_name, location_name = self.current_location self.current_location_label.set_markup( f"📍 Current location: {customer_name} - {location_name}" ) else: self.current_location_label.set_markup("Current location: Not set") def filter_customers(self, entry): search_term = entry.get_text().strip() # Check for wildcard - show all customers if search_term == "*": self.filtered_customers = self.customers.copy() elif search_term: # Normal search logic search_term_lower = search_term.lower() self.filtered_customers = [] for customer in self.customers: # Check if search term matches customer name if search_term_lower in customer.name.lower(): self.filtered_customers.append(customer) continue # Check customer services if any(search_term_lower in service.name.lower() or search_term_lower in service.url.lower() or search_term_lower in service.service_type.lower() for service in customer.services): self.filtered_customers.append(customer) continue # Check locations and their hosts for location in customer.locations: # Check location name if search_term_lower in location.name.lower(): self.filtered_customers.append(customer) break # Check hosts and their services in this location def search_hosts(hosts): for host in hosts: if (search_term_lower in host.name.lower() or search_term_lower in host.ip_address.lower() or search_term_lower in host.host_type.value.lower() or search_term_lower in host.description.lower()): return True # Check host services if any(search_term_lower in service.name.lower() or search_term_lower in str(service.port).lower() or search_term_lower in service.service_type.value.lower() for service in host.services): return True # Check sub-hosts recursively if search_hosts(host.sub_hosts): return True return False if search_hosts(location.hosts): self.filtered_customers.append(customer) break else: # Empty search - show all customers self.filtered_customers = self.customers.copy() self.render_customers() def toggle_connection(self, location): location.connected = not location.connected status = "connected to" if location.connected else "disconnected from" print(f"Mock: {status} {location.name} via {location.vpn_type.value}") self.render_customers() def open_service(self, service): # Get the host IP from context - this would need to be passed properly in a real implementation print( f"Mock: Opening {service.service_type.value} service: {service.name} on port {service.port}") def open_customer_service(self, customer_service): print( f"Mock: Opening customer service: {customer_service.name} at {customer_service.url}") def show_window_from_tray(self, _icon=None, _item=None): # Use GLib.idle_add to safely call GTK functions from the tray thread GLib.idle_add(self._show_window_safe) def _show_window_safe(self): """Safely show window in main GTK thread""" self.window.deiconify() self.window.present() self.window.show_all() return False # Don't repeat the idle call def on_window_state_event(self, _widget, event): """Handle window state changes - hide to tray when minimized""" if event.new_window_state & Gdk.WindowState.ICONIFIED: self.window.hide() return False def quit_app_from_close(self, _widget=None, _event=None): """Quit app when close button is pressed""" self.quit_app() return False def quit_app(self, _widget=None): # Stop the tray icon if hasattr(self, 'tray_icon'): self.tray_icon.stop() Gtk.main_quit() sys.exit(0) def run(self): self.window.show_all() Gtk.main() def main(): app = VPNManagerWindow() app.run() if __name__ == "__main__": main()