From bae1572d3f8a43ccda04846546d45eb56af3f456 Mon Sep 17 00:00:00 2001 From: Alexander Thiess Date: Sat, 6 Sep 2025 10:15:14 +0200 Subject: [PATCH] stuff --- CLAUDE.md | 68 ++-- control-panel.png | Bin 0 -> 82616 bytes data_loader.py | 377 ++++++++++++++------- main.py | 689 +++++++++++---------------------------- models.py | 162 +++++++-- pyproject.toml | 2 + uv.lock | 143 ++++++++ widgets/__init__.py | 11 + widgets/customer_card.py | 74 +++++ widgets/host_item.py | 81 +++++ widgets/location_card.py | 144 ++++++++ 11 files changed, 1076 insertions(+), 675 deletions(-) create mode 100644 control-panel.png create mode 100644 widgets/__init__.py create mode 100644 widgets/customer_card.py create mode 100644 widgets/host_item.py create mode 100644 widgets/location_card.py diff --git a/CLAUDE.md b/CLAUDE.md index c1a5b49..b186c1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,62 +18,90 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - This project uses `uv` for Python package management - `uv sync` - Install dependencies from pyproject.toml - Python 3.13+ required +- Dependencies: PyGObject (GTK3), pystray, Pillow ## Code Architecture ### Core Components **main.py** - Main GUI application entry point -- `VPNManagerWindow` class: Primary tkinter-based GUI application +- `VPNManagerWindow` class: Primary PyGObject/GTK3-based GUI application - Implements a two-column layout: active customers (left) vs inactive customers (right) - Features system tray integration using `pystray` -- Uses a modern dark theme with predefined color scheme +- Uses GNOME-style theming with CSS styling for cards - Includes search functionality across customers, locations, and hosts +- HeaderBar for native GNOME look and feel **models.py** - Data model definitions using dataclasses -- `Host`: Individual services/endpoints (SSH, Web, SMB, etc.) -- `Location`: Customer locations (headquarters, branch offices) with VPN configurations -- `Customer`: Top-level entities containing multiple locations +- `Service`: Individual services (Web GUI, SSH, RDP, etc.) on hosts +- `Host`: Physical/virtual machines with services and sub-hosts (VMs) +- `Location`: Customer locations with VPN configurations and host infrastructure +- `CustomerService`: Customer's cloud/web services (O365, CRM, etc.) +- `Customer`: Top-level entities containing services and locations - Each model includes helper methods for common operations **data_loader.py** - Data management layer -- `load_customers()`: Currently returns mock data, designed to be replaceable +- `load_customers()`: Returns comprehensive mock data with realistic infrastructure - `save_customers()`: Placeholder for future persistence - Isolates data loading logic from UI components +**widgets/** - Modular UI components using PyGObject +- `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes +- `location_card.py`: `ActiveLocationCard` and `InactiveLocationCard` classes +- `host_item.py`: `HostItem` class for displaying hosts and their services +- `__init__.py`: Widget exports for clean imports + ### Key Architecture Patterns -**Hierarchical Data Structure**: Customer → Location → Host -- Customers can have multiple locations (e.g., headquarters, branches) -- Each location has its own VPN configuration and connection state -- Locations contain multiple hosts/services that become accessible when VPN is connected +**Hierarchical Data Structure**: Customer → Location → Host → Service +- Customers have cloud services (accessible anywhere) and multiple locations +- Each location has VPN configuration, connection state, and host infrastructure +- Hosts can have sub-hosts (VMs under hypervisors) and multiple services +- Services represent endpoints (SSH, Web GUI, RDP, etc.) that can be launched **Active/Inactive Location Management**: - Locations (not customers) are activated/deactivated individually -- Left column shows customers with at least one active location (displaying only their active locations) -- Right column shows customers with at least one inactive location (displaying only their inactive locations) +- Left column shows customers with active locations (full detail view) +- Right column shows customers with inactive locations (summary cards) - UI automatically reorganizes based on location activation state +**Widget-Based UI Architecture**: +- Modular widget classes handle their own GTK widget creation +- Callback system for widget-to-main-window communication +- Clean separation between data models and UI representation + **Mock Implementation**: - Currently a UI mockup with no actual VPN functionality - All VPN operations (connect, disconnect, routes) are placeholder methods - Button actions update UI state but don't perform real network operations +- Rich mock data includes hypervisors with VMs, various service types ### Data Flow -1. `data_loader.load_customers()` provides initial customer data +1. `data_loader.load_customers()` provides initial customer data with full infrastructure 2. Main window loads and filters data based on search terms -3. UI renders two-column layout based on location active states -4. User interactions update dataclass attributes directly +3. Widget classes create GTK components for customers, locations, and hosts +4. User interactions trigger callbacks that update dataclass attributes 5. UI re-renders to reflect state changes ### UI Layout Structure -- Header: Search bar and application title -- Two-column main area with independent scrolling -- Left column shows full location details with hosts for active locations -- Right column shows summary cards with activation buttons for inactive locations +- HeaderBar with title and subtitle (GNOME HIG compliance) +- Search entry with placeholder text for filtering +- Two-column main area with independent scrolling containers +- Left column: Active locations with full infrastructure details +- Right column: Inactive locations with summary cards and activation buttons +- GNOME-style cards with CSS theming and proper spacing - System tray integration for minimize-to-tray behavior +### GTK3/PyGObject Specific Features +- CSS styling for GNOME-style cards with borders, shadows, and theming +- Native GTK widgets: HeaderBar, SearchEntry, ScrolledWindow +- Proper GNOME HIG compliance for spacing, margins, and layout +- Button styling with suggested-action and destructive-action classes +- Thread-safe system tray integration using GLib.idle_add + ### Future Extensibility - Replace `load_customers()` with real data source (database, config files, API) - Implement actual VPN connection logic in placeholder methods -- Add persistence through `save_customers()` implementation \ No newline at end of file +- Add persistence through `save_customers()` implementation +- Extend widget system for additional UI components +- Add configuration management for VPN client integration \ No newline at end of file diff --git a/control-panel.png b/control-panel.png new file mode 100644 index 0000000000000000000000000000000000000000..de80b509239faf41d30eae650155f7ff69f2a7f1 GIT binary patch literal 82616 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%0AcPZ;kYsmvcl(ze0}xkMSEohWIXbGq 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 z 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 R(iFT7D<4tH+JD^d{{VN_m{$M* literal 0 HcmV?d00001 diff --git a/data_loader.py b/data_loader.py index abc9216..d54f382 100644 --- a/data_loader.py +++ b/data_loader.py @@ -1,147 +1,276 @@ +from models import Customer, CustomerService, Location, Host, Service, ServiceType, HostType, VPNType from typing import List -from models import Customer, Location, Host def load_customers() -> List[Customer]: - """Load customer data. For now, returns mock data but can be replaced later.""" + """Load customer data. Currently returns mock data for demonstration.""" - # Create hosts for each location - alpha_hq_hosts = [ - Host(name="Web Server", address="10.10.1.5", type="SSH"), - Host(name="Admin Panel", address="https://10.10.1.10:8443", type="Web"), - Host(name="Database", address="10.10.1.20", type="SSH"), + customers = [] + + # Customer 1: TechCorp Solutions + techcorp = Customer(name="TechCorp Solutions") + + # TechCorp's cloud services + techcorp.services = [ + CustomerService("Office 365", "https://portal.office.com", "Email & Office"), + CustomerService("Pascom Cloud PBX", "https://techcorp.pascom.cloud", "Phone System"), + CustomerService("Salesforce CRM", "https://techcorp.salesforce.com", "CRM") ] - alpha_branch_hosts = [ - Host(name="File Server", address="10.10.2.5", type="SMB"), - Host(name="Backup Server", address="10.10.2.10", type="SSH"), - ] - - beta_main_hosts = [ - Host(name="Main Portal", address="https://portal.beta.local", type="Web"), - Host(name="File Server", address="192.168.50.10", type="SMB"), - ] - - gamma_dc_hosts = [ - Host(name="Application Server", address="172.16.0.100", type="SSH"), - Host(name="Monitoring Dashboard", address="https://monitor.gamma.net", type="Web"), - Host(name="Backup Server", address="172.16.0.150", type="SSH"), - ] - - gamma_dr_hosts = [ - Host(name="DR Server", address="172.16.1.100", type="SSH"), - Host(name="Backup Storage", address="172.16.1.150", type="SMB"), - ] - - delta_dev_hosts = [ - Host(name="Development Server", address="10.20.30.40", type="SSH"), - Host(name="CI/CD Pipeline", address="https://jenkins.delta.local:8080", type="Web"), - ] - - epsilon_prod_hosts = [ - Host(name="Production API", address="https://api.epsilon.com", type="Web"), - Host(name="Database Cluster", address="10.5.0.50", type="PostgreSQL"), - Host(name="Redis Cache", address="10.5.0.60", type="Redis"), - ] - - epsilon_staging_hosts = [ - Host(name="Staging API", address="https://staging-api.epsilon.com", type="Web"), - Host(name="Test Database", address="10.5.1.50", type="PostgreSQL"), - ] - - # Create locations - alpha_hq = Location( - name="Headquarters", - vpn_type="OpenVPN", - connected=False, - active=True, - hosts=alpha_hq_hosts - ) - - alpha_branch = Location( - name="Branch Office", - vpn_type="WireGuard", - connected=False, - active=False, - hosts=alpha_branch_hosts - ) - - beta_main = Location( + # TechCorp's main office location + main_office = Location( name="Main Office", - vpn_type="WireGuard", + vpn_type=VPNType.OPENVPN, connected=True, active=True, - hosts=beta_main_hosts + vpn_config="/etc/openvpn/techcorp-main.ovpn" ) - gamma_dc = Location( - name="Data Center", - vpn_type="IPSec", - connected=False, - active=False, - hosts=gamma_dc_hosts + # Proxmox hypervisor with VMs + proxmox_host = Host( + name="PVE-01", + ip_address="192.168.1.10", + host_type=HostType.PROXMOX, + description="Main virtualization server", + services=[ + Service("Web Interface", ServiceType.WEB_GUI, 8006), + Service("SSH", ServiceType.SSH, 22) + ] ) - gamma_dr = Location( - name="DR Site", - vpn_type="OpenVPN", - connected=False, - active=False, - hosts=gamma_dr_hosts - ) - - delta_dev = Location( - name="Development Lab", - vpn_type="OpenVPN", - connected=False, - active=False, - hosts=delta_dev_hosts - ) - - epsilon_prod = Location( - name="Production", - vpn_type="WireGuard", - connected=False, - active=False, - hosts=epsilon_prod_hosts - ) - - epsilon_staging = Location( - name="Staging", - vpn_type="OpenVPN", - connected=False, - active=False, - hosts=epsilon_staging_hosts - ) - - # Create customers - customers = [ - Customer( - name="Customer Alpha Corp", - locations=[alpha_hq, alpha_branch] + # VMs running on Proxmox + proxmox_host.sub_hosts = [ + Host( + name="DC-01", + ip_address="192.168.1.20", + host_type=HostType.WINDOWS_SERVER, + description="Domain Controller", + services=[ + Service("RDP", ServiceType.RDP, 3389), + Service("Admin Web", ServiceType.WEB_GUI, 8080) + ] ), - Customer( - name="Beta Industries", - locations=[beta_main] - ), - Customer( - name="Gamma Solutions", - locations=[gamma_dc, gamma_dr] - ), - Customer( - name="Delta Tech", - locations=[delta_dev] - ), - Customer( - name="Epsilon Systems", - locations=[epsilon_prod, epsilon_staging] + Host( + name="FILE-01", + ip_address="192.168.1.21", + host_type=HostType.LINUX, + description="File Server (Samba)", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("SMB Share", ServiceType.SMB, 445), + Service("Web Panel", ServiceType.WEB_GUI, 9000) + ] ), + Host( + name="DB-01", + ip_address="192.168.1.22", + host_type=HostType.LINUX, + description="PostgreSQL Database", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("PostgreSQL", ServiceType.DATABASE, 5432), + Service("pgAdmin", ServiceType.WEB_GUI, 5050) + ] + ) ] + # Network infrastructure + router = Host( + name="FW-01", + ip_address="192.168.1.1", + host_type=HostType.ROUTER, + description="pfSense Firewall/Router", + services=[ + Service("Web Interface", ServiceType.WEB_GUI, 443), + Service("SSH", ServiceType.SSH, 22) + ] + ) + + switch = Host( + name="SW-01", + ip_address="192.168.1.2", + host_type=HostType.SWITCH, + description="Managed Switch", + services=[ + Service("Web Interface", ServiceType.WEB_GUI, 80), + Service("SSH", ServiceType.SSH, 22) + ] + ) + + main_office.hosts = [proxmox_host, router, switch] + + # Branch office location + branch_office = Location( + name="Branch Office", + vpn_type=VPNType.WIREGUARD, + connected=False, + active=False, + vpn_config="/etc/wireguard/techcorp-branch.conf" + ) + + branch_server = Host( + name="BRANCH-01", + ip_address="10.10.1.10", + host_type=HostType.LINUX, + description="Branch office server", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("File Share", ServiceType.SMB, 445), + Service("Local Web", ServiceType.WEB_GUI, 8080) + ] + ) + + branch_office.hosts = [branch_server] + + techcorp.locations = [main_office, branch_office] + customers.append(techcorp) + + # Customer 2: MedPractice Group + medpractice = Customer(name="MedPractice Group") + + # MedPractice's cloud services + medpractice.services = [ + CustomerService("Google Workspace", "https://workspace.google.com", "Email & Office"), + CustomerService("Practice Management", "https://medpractice.emr-system.com", "EMR System"), + CustomerService("VoIP Provider", "https://medpractice.voip.com", "Phone System") + ] + + # Clinic location + clinic_location = Location( + name="Main Clinic", + vpn_type=VPNType.WIREGUARD, + connected=False, + active=False, + vpn_config="/etc/wireguard/medpractice.conf" + ) + + # ESXi hypervisor + esxi_host = Host( + name="ESXi-01", + ip_address="10.0.1.10", + host_type=HostType.ESXI, + description="VMware ESXi Host", + services=[ + Service("vSphere Web", ServiceType.WEB_GUI, 443), + Service("SSH", ServiceType.SSH, 22) + ] + ) + + # VMs on ESXi + esxi_host.sub_hosts = [ + Host( + name="WIN-SRV-01", + ip_address="10.0.1.20", + host_type=HostType.WINDOWS_SERVER, + description="Windows Server 2022", + services=[ + Service("RDP", ServiceType.RDP, 3389), + Service("IIS Web", ServiceType.WEB_GUI, 80) + ] + ), + Host( + name="BACKUP-01", + ip_address="10.0.1.21", + host_type=HostType.LINUX, + description="Backup Server", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("Backup Web UI", ServiceType.WEB_GUI, 8080) + ] + ) + ] + + # Physical server + physical_server = Host( + name="PHYS-01", + ip_address="10.0.1.50", + host_type=HostType.LINUX, + description="Physical Linux Server", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("Docker Portainer", ServiceType.WEB_GUI, 9000), + Service("Nginx Proxy", ServiceType.WEB_GUI, 8080) + ] + ) + + clinic_location.hosts = [esxi_host, physical_server] + medpractice.locations = [clinic_location] + customers.append(medpractice) + + # Customer 3: Manufacturing Inc + manufacturing = Customer(name="Manufacturing Inc") + + # Manufacturing's cloud services + manufacturing.services = [ + CustomerService("Microsoft 365", "https://portal.office.com", "Email & Office"), + CustomerService("SAP Cloud", "https://manufacturing.sap.com", "ERP System") + ] + + # Factory location + factory_location = Location( + name="Factory Floor", + vpn_type=VPNType.IPSEC, + connected=False, + active=True, + vpn_config="/etc/ipsec.d/manufacturing.conf" + ) + + # Manufacturing infrastructure - simpler setup + linux_server = Host( + name="PROD-01", + ip_address="172.16.1.10", + host_type=HostType.LINUX, + description="Production Server", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("Web Portal", ServiceType.WEB_GUI, 8443), + Service("FTP", ServiceType.FTP, 21) + ] + ) + + nas_server = Host( + name="NAS-01", + ip_address="172.16.1.20", + host_type=HostType.LINUX, + description="Network Attached Storage", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("Web Interface", ServiceType.WEB_GUI, 5000), + Service("SMB Share", ServiceType.SMB, 445) + ] + ) + + factory_location.hosts = [linux_server, nas_server] + + # Office location + office_location = Location( + name="Administrative Office", + vpn_type=VPNType.OPENVPN, + connected=False, + active=False, + vpn_config="/etc/openvpn/manufacturing-office.ovpn" + ) + + office_server = Host( + name="OFFICE-01", + ip_address="172.16.2.10", + host_type=HostType.WINDOWS_SERVER, + description="Office domain controller", + services=[ + Service("RDP", ServiceType.RDP, 3389), + Service("File Share", ServiceType.SMB, 445) + ] + ) + + office_location.hosts = [office_server] + + manufacturing.locations = [factory_location, office_location] + customers.append(manufacturing) + return customers def save_customers(customers: List[Customer]) -> None: - """Save customer data. Placeholder for future implementation.""" - # TODO: Implement saving to file/database + """Save customer data. Currently a placeholder.""" + # TODO: Implement actual persistence (JSON file, database, etc.) pass \ No newline at end of file diff --git a/main.py b/main.py index 93a4d9f..b8704a1 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 import gi gi.require_version('Gtk', '3.0') -gi.require_version('AppIndicator3', '0.1') -from gi.repository import Gtk, Gdk, GLib, Gio, AppIndicator3 -import threading +from gi.repository import Gtk, Gdk, GLib import sys -from models import Customer, Location, Host +import threading +import pystray +from PIL import Image, ImageDraw +from models import Customer from data_loader import load_customers +from widgets import ActiveCustomerCard, InactiveCustomerCard class VPNManagerWindow: @@ -18,9 +20,10 @@ class VPNManagerWindow: self.window = Gtk.Window() self.window.set_title("VPN Manager") self.window.set_default_size(1200, 750) - self.window.connect("delete-event", self.hide_window) + self.window.connect("delete-event", self.quit_app_from_close) + self.window.connect("window-state-event", self.on_window_state_event) - # Set up CSS for dark theme +# Set up minimal CSS for GNOME-style cards self.setup_css() # Create UI @@ -29,184 +32,18 @@ class VPNManagerWindow: # Start hidden self.window.hide() - + def setup_css(self): + """Minimal CSS for GNOME-style cards""" css_provider = Gtk.CssProvider() css = """ - window { - background-color: #1a1d29; - color: #e8eaf6; - } - - .header { - background: linear-gradient(135deg, #1a1d29 0%, #252836 100%); - padding: 20px; - } - - .title { - font-size: 20px; - font-weight: bold; - color: #e8eaf6; - } - - .search-entry { - background-color: #2d3142; - color: #e8eaf6; - border: 1px solid #5e72e4; + .card { + background: @theme_base_color; border-radius: 8px; - padding: 10px; - margin: 10px 0; - } - - .customer-card { - background-color: #252836; - border: 1px solid #3a3f5c; - border-radius: 8px; - margin: 8px 5px; - padding: 15px; - } - - .location-card { - background-color: #2a2e3f; - border: 1px solid #3a3f5c; - border-radius: 6px; - margin: 5px 20px 5px 20px; - padding: 10px; - } - - .host-item { - background-color: #1a1d29; - border: 1px solid #3a3f5c; - border-radius: 4px; - margin: 3px 2px; - padding: 6px 10px; - } - - .active-title { - color: #2dce89; - font-weight: bold; - font-size: 14px; - } - - .inactive-title { - color: #8892b0; - font-weight: bold; - font-size: 14px; - } - - .customer-name { - color: #5e72e4; - font-weight: bold; - font-size: 14px; - } - - .inactive-customer-name { - color: #8892b0; - font-weight: bold; - font-size: 14px; - } - - .location-name { - color: #e8eaf6; - font-weight: bold; - font-size: 12px; - } - - .vpn-type { - color: #8892b0; - font-size: 10px; - } - - .connected-status { - color: #2dce89; - font-weight: bold; - font-size: 10px; - } - - .disconnected-status { - color: #f5365c; - font-weight: bold; - font-size: 10px; - } - - .connect-button { - background-color: #5e72e4; - color: white; - border: none; - border-radius: 4px; - padding: 5px 15px; - font-weight: bold; - } - - .connect-button:hover { - background-color: #3a3f5c; - } - - .disconnect-button { - background-color: #f5365c; - color: white; - border: none; - border-radius: 4px; - padding: 5px 15px; - font-weight: bold; - } - - .disconnect-button:hover { - background-color: #3a3f5c; - } - - .routes-button, .deactivate-button, .activate-button { - background-color: #3a3f5c; - color: #e8eaf6; - border: none; - border-radius: 4px; - padding: 5px 15px; - margin: 0 3px; - } - - .deactivate-button { - background-color: #fb6340; - color: white; - } - - .activate-button { - background-color: #5e72e4; - color: white; - font-weight: bold; - padding: 8px 20px; - } - - .launch-button { - background-color: #5e72e4; - color: white; - border: none; - border-radius: 4px; - padding: 4px 12px; - font-weight: bold; - font-size: 9px; - } - - .host-name { - color: #e8eaf6; - font-weight: bold; - font-size: 9px; - } - - .host-address { - color: #8892b0; - font-size: 8px; - font-family: monospace; - } - - .services-header { - color: #5e72e4; - font-weight: bold; - font-size: 10px; - } - - .service-count { - color: #6c757d; - font-size: 9px; + 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()) @@ -217,115 +54,125 @@ class VPNManagerWindow: style_context.add_provider_for_screen( screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) + def setup_ui(self): - # Main container - main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + # 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) - # Header - header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - header_box.get_style_context().add_class("header") - main_vbox.pack_start(header_box, False, False, 0) - - # Title with icon - title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - title_box.set_halign(Gtk.Align.CENTER) - header_box.pack_start(title_box, False, False, 10) - - icon_label = Gtk.Label(label="🛡️") - icon_label.set_markup('🛡️') - title_box.pack_start(icon_label, False, False, 0) - - title_label = Gtk.Label(label="VPN Connection Manager") - title_label.get_style_context().add_class("title") - title_box.pack_start(title_label, False, False, 0) - - # Search bar - search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - search_box.set_margin_start(20) - search_box.set_margin_end(20) - header_box.pack_start(search_box, False, False, 0) - - search_icon = Gtk.Label(label="🔍") - search_box.pack_start(search_icon, False, False, 10) - - self.search_entry = Gtk.Entry() + # Search bar with SearchEntry + self.search_entry = Gtk.SearchEntry() self.search_entry.set_placeholder_text("Search customers, locations, or hosts...") - self.search_entry.get_style_context().add_class("search-entry") - self.search_entry.connect("changed", self.filter_customers) - search_box.pack_start(self.search_entry, True, True, 0) + self.search_entry.connect("search-changed", self.filter_customers) + main_vbox.pack_start(self.search_entry, False, False, 0) - # Main content area with two columns - columns_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, homogeneous=True) - columns_box.set_margin_start(10) - columns_box.set_margin_end(10) - columns_box.set_margin_bottom(10) + # Clean two-column layout like GNOME Control Center + columns_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) main_vbox.pack_start(columns_box, True, True, 0) # Left column - Active customers - left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) columns_box.pack_start(left_vbox, True, True, 0) - active_header = Gtk.Label(label="✅ Active Customers") - active_header.get_style_context().add_class("active-title") - active_header.set_halign(Gtk.Align.START) - left_vbox.pack_start(active_header, False, False, 0) + # Simple label header + active_label = Gtk.Label() + active_label.set_markup("Active Customers") + active_label.set_halign(Gtk.Align.START) + left_vbox.pack_start(active_label, False, False, 0) - # Active customers scrolled window + # Clean scrolled window without borders active_scrolled = Gtk.ScrolledWindow() active_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + active_scrolled.set_shadow_type(Gtk.ShadowType.NONE) left_vbox.pack_start(active_scrolled, True, True, 0) - self.active_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.active_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) active_scrolled.add(self.active_box) - # Right column - Inactive customers - right_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + # Right column - Inactive customers + right_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) columns_box.pack_start(right_vbox, True, True, 0) - inactive_header = Gtk.Label(label="💤 Inactive Customers") - inactive_header.get_style_context().add_class("inactive-title") - inactive_header.set_halign(Gtk.Align.START) - right_vbox.pack_start(inactive_header, False, False, 0) + # Simple label header + inactive_label = Gtk.Label() + inactive_label.set_markup("Inactive Customers") + inactive_label.set_halign(Gtk.Align.START) + right_vbox.pack_start(inactive_label, False, False, 0) - # Inactive customers scrolled window + # Clean scrolled window without borders inactive_scrolled = Gtk.ScrolledWindow() inactive_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + inactive_scrolled.set_shadow_type(Gtk.ShadowType.NONE) right_vbox.pack_start(inactive_scrolled, True, True, 0) - self.inactive_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.inactive_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) inactive_scrolled.add(self.inactive_box) # Render initial data self.render_customers() def setup_system_tray(self): - self.indicator = AppIndicator3.Indicator.new( - "vpn-manager", - "network-vpn", - AppIndicator3.IndicatorCategory.APPLICATION_STATUS + # 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" ) - self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) - # Create menu - menu = Gtk.Menu() + # Set direct click action + self.tray_icon.default_action = self.show_window_from_tray - # Open item - open_item = Gtk.MenuItem(label="Open VPN Manager") - open_item.connect("activate", self.show_window_from_tray) - menu.append(open_item) + # 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 - # Separator - menu.append(Gtk.SeparatorMenuItem()) - - # Quit item - quit_item = Gtk.MenuItem(label="Quit") - quit_item.connect("activate", self.quit_app) - menu.append(quit_item) - - menu.show_all() - self.indicator.set_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, + 'open_service': self.open_service, + 'open_customer_service': self.open_customer_service + } def render_customers(self): # Clear existing content @@ -343,237 +190,34 @@ class VPNManagerWindow: inactive_locations = customer.get_inactive_locations() if active_locations: - customer_data = Customer( - name=customer.name, - locations=active_locations - ) + from models import Customer + customer_data = Customer(name=customer.name) + customer_data.services = customer.services + customer_data.locations = active_locations customers_with_active.append(customer_data) if inactive_locations: - customer_data = Customer( - name=customer.name, - locations=inactive_locations - ) + from models import Customer + customer_data = Customer(name=customer.name) + customer_data.services = customer.services + customer_data.locations = inactive_locations customers_with_inactive.append(customer_data) - # Render active customers - for customer in customers_with_active: - self.create_customer_with_active_locations(customer) + # Get callbacks for widgets + callbacks = self.get_callbacks() - # Render inactive customers + # Render active customers using widget classes + for customer in customers_with_active: + customer_card = ActiveCustomerCard(customer, callbacks) + self.active_box.pack_start(customer_card.widget, False, False, 0) + + # Render inactive customers using widget classes for customer in customers_with_inactive: - self.create_customer_without_active_locations(customer) + customer_card = InactiveCustomerCard(customer, callbacks) + self.inactive_box.pack_start(customer_card.widget, False, False, 0) self.window.show_all() - def create_customer_with_active_locations(self, customer): - # Customer card - customer_frame = Gtk.Frame() - customer_frame.get_style_context().add_class("customer-card") - self.active_box.pack_start(customer_frame, False, False, 0) - - customer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - customer_frame.add(customer_vbox) - - # Customer header - customer_label = Gtk.Label(label=f"🏢 {customer.name}") - customer_label.get_style_context().add_class("customer-name") - customer_label.set_halign(Gtk.Align.START) - customer_vbox.pack_start(customer_label, False, False, 0) - - # Render each location - for location in customer.locations: - self.create_active_location_card(location, customer_vbox, customer.name) - - def create_active_location_card(self, location, parent_box, customer_name): - # Location card - location_frame = Gtk.Frame() - location_frame.get_style_context().add_class("location-card") - parent_box.pack_start(location_frame, False, False, 0) - - location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - location_frame.add(location_vbox) - - # Location header with controls - header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - location_vbox.pack_start(header_box, False, False, 0) - - # Location info - info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - header_box.pack_start(info_vbox, True, True, 0) - - location_label = Gtk.Label(label=f"📍 {location.name}") - location_label.get_style_context().add_class("location-name") - location_label.set_halign(Gtk.Align.START) - info_vbox.pack_start(location_label, False, False, 0) - - # VPN type - vpn_icons = { - "OpenVPN": "🔒", - "WireGuard": "⚡", - "IPSec": "🛡️" - } - vpn_icon = vpn_icons.get(location.vpn_type, "🔑") - - type_label = Gtk.Label(label=f"{vpn_icon} {location.vpn_type} VPN") - type_label.get_style_context().add_class("vpn-type") - type_label.set_halign(Gtk.Align.START) - info_vbox.pack_start(type_label, False, False, 0) - - # Controls - controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - header_box.pack_end(controls_box, False, False, 0) - - # Status - status_text = "● Connected" if location.connected else "○ Disconnected" - status_label = Gtk.Label(label=status_text) - if location.connected: - status_label.get_style_context().add_class("connected-status") - else: - status_label.get_style_context().add_class("disconnected-status") - controls_box.pack_start(status_label, False, False, 0) - - # Connect/Disconnect button - btn_text = "Disconnect" if location.connected else "Connect" - connect_btn = Gtk.Button(label=btn_text) - if location.connected: - connect_btn.get_style_context().add_class("disconnect-button") - else: - connect_btn.get_style_context().add_class("connect-button") - connect_btn.connect("clicked", lambda btn, l=location: self.toggle_connection(l)) - controls_box.pack_start(connect_btn, False, False, 0) - - # Routes button - routes_btn = Gtk.Button(label="Routes") - routes_btn.get_style_context().add_class("routes-button") - routes_btn.connect("clicked", lambda btn, l=location: self.set_route(l)) - controls_box.pack_start(routes_btn, False, False, 0) - - # Deactivate button - deactivate_btn = Gtk.Button(label="Deactivate") - deactivate_btn.get_style_context().add_class("deactivate-button") - deactivate_btn.connect("clicked", lambda btn, l=location: self.deactivate_location(l, customer_name)) - controls_box.pack_start(deactivate_btn, False, False, 0) - - # Hosts section - if location.hosts: - # Separator - separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) - location_vbox.pack_start(separator, False, False, 5) - - services_label = Gtk.Label(label="💼 Available Services") - services_label.get_style_context().add_class("services-header") - services_label.set_halign(Gtk.Align.START) - location_vbox.pack_start(services_label, False, False, 0) - - for host in location.hosts: - self.create_host_item(host, location_vbox) - - def create_host_item(self, host, parent_box): - host_frame = Gtk.Frame() - host_frame.get_style_context().add_class("host-item") - parent_box.pack_start(host_frame, False, False, 0) - - host_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - host_frame.add(host_box) - - # Icon - type_icons = { - "SSH": "💻", - "Web": "🌐", - "SMB": "📂", - "PostgreSQL": "🗃️", - "Redis": "🗂️" - } - icon = type_icons.get(host.type, "📡") - - icon_label = Gtk.Label(label=icon) - host_box.pack_start(icon_label, False, False, 0) - - # Host details - details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) - host_box.pack_start(details_vbox, True, True, 0) - - name_label = Gtk.Label(label=host.name) - name_label.get_style_context().add_class("host-name") - name_label.set_halign(Gtk.Align.START) - details_vbox.pack_start(name_label, False, False, 0) - - addr_label = Gtk.Label(label=host.address) - addr_label.get_style_context().add_class("host-address") - addr_label.set_halign(Gtk.Align.START) - details_vbox.pack_start(addr_label, False, False, 0) - - # Launch button for SSH and Web services - if host.type in ["SSH", "Web"]: - launch_btn = Gtk.Button(label="Launch") - launch_btn.get_style_context().add_class("launch-button") - launch_btn.connect("clicked", lambda btn, h=host: self.open_service(h)) - host_box.pack_end(launch_btn, False, False, 0) - - def create_customer_without_active_locations(self, customer): - # Customer card - customer_frame = Gtk.Frame() - customer_frame.get_style_context().add_class("customer-card") - self.inactive_box.pack_start(customer_frame, False, False, 0) - - customer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - customer_frame.add(customer_vbox) - - # Customer header - customer_label = Gtk.Label(label=f"🏢 {customer.name}") - customer_label.get_style_context().add_class("inactive-customer-name") - customer_label.set_halign(Gtk.Align.START) - customer_vbox.pack_start(customer_label, False, False, 0) - - # Render each location - for location in customer.locations: - self.create_inactive_location_card(location, customer_vbox, customer.name) - - def create_inactive_location_card(self, location, parent_box, customer_name): - # Location card - location_frame = Gtk.Frame() - location_frame.get_style_context().add_class("location-card") - parent_box.pack_start(location_frame, False, False, 0) - - location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - location_frame.add(location_hbox) - - # Location info - info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - location_hbox.pack_start(info_vbox, True, True, 0) - - location_label = Gtk.Label(label=f"📍 {location.name}") - location_label.get_style_context().add_class("location-name") - location_label.set_halign(Gtk.Align.START) - info_vbox.pack_start(location_label, False, False, 0) - - # VPN type - vpn_icons = { - "OpenVPN": "🔒", - "WireGuard": "⚡", - "IPSec": "🛡️" - } - vpn_icon = vpn_icons.get(location.vpn_type, "🔑") - - type_label = Gtk.Label(label=f"{vpn_icon} {location.vpn_type} VPN") - type_label.get_style_context().add_class("vpn-type") - type_label.set_halign(Gtk.Align.START) - info_vbox.pack_start(type_label, False, False, 0) - - # Service count - service_count = len(location.hosts) - count_label = Gtk.Label(label=f"📊 {service_count} services available") - count_label.get_style_context().add_class("service-count") - count_label.set_halign(Gtk.Align.START) - info_vbox.pack_start(count_label, False, False, 0) - - # Activate button - activate_btn = Gtk.Button(label="Set Active") - activate_btn.get_style_context().add_class("activate-button") - activate_btn.connect("clicked", lambda btn, l=location: self.set_location_active(l, customer_name)) - location_hbox.pack_end(activate_btn, False, False, 0) - def set_location_active(self, location, customer_name): for customer in self.customers: if customer.name == customer_name: @@ -590,6 +234,7 @@ class VPNManagerWindow: 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() @@ -599,24 +244,50 @@ class VPNManagerWindow: if search_term: self.filtered_customers = [] for customer in self.customers: + # Check if search term matches customer name if search_term in customer.name.lower(): self.filtered_customers.append(customer) - else: - matching_locations = [] - for location in customer.locations: - if (search_term in location.name.lower() or - search_term in location.vpn_type.lower() or - any(search_term in h.name.lower() or - search_term in h.address.lower() - for h in location.hosts)): - matching_locations.append(location) + continue + + # Check customer services + if any(search_term in service.name.lower() or + search_term in service.url.lower() or + search_term 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 in location.name.lower(): + self.filtered_customers.append(customer) + break - if matching_locations: - filtered_customer = Customer( - name=customer.name, - locations=matching_locations - ) - self.filtered_customers.append(filtered_customer) + # Check hosts and their services in this location + def search_hosts(hosts): + for host in hosts: + if (search_term in host.name.lower() or + search_term in host.ip_address.lower() or + search_term in host.host_type.value.lower() or + search_term in host.description.lower()): + return True + + # Check host services + if any(search_term in service.name.lower() or + search_term in str(service.port).lower() or + search_term 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: self.filtered_customers = self.customers.copy() @@ -625,24 +296,42 @@ class VPNManagerWindow: 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}") + print(f"Mock: {status} {location.name} via {location.vpn_type.value}") self.render_customers() - def set_route(self, location): - print(f"Mock: Setting route for {location.name}") + 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_service(self, host): - print(f"Mock: Opening {host.type} service: {host.name} at {host.address}") + 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, widget=None): + 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 hide_window(self, widget, event): - self.window.hide() - return True + 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(self, widget=None): + 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) diff --git a/models.py b/models.py index 36bb60e..7b847ea 100644 --- a/models.py +++ b/models.py @@ -1,54 +1,154 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Optional +from enum import Enum + + +class ServiceType(Enum): + """Enum for different types of services that can run on hosts.""" + SSH = "SSH" + WEB_GUI = "Web GUI" + RDP = "RDP" + VNC = "VNC" + SMB = "SMB" + DATABASE = "Database" + FTP = "FTP" + + +@dataclass +class Service: + """Represents a service on a host.""" + name: str + service_type: ServiceType + port: int + + +class HostType(Enum): + """Enum for different types of hosts.""" + LINUX = "Linux" + WINDOWS = "Windows" + WINDOWS_SERVER = "Windows Server" + PROXMOX = "Proxmox" + ESXI = "ESXi" + ROUTER = "Router" + SWITCH = "Switch" + + +class VPNType(Enum): + """Enum for different VPN types.""" + OPENVPN = "OpenVPN" + WIREGUARD = "WireGuard" + IPSEC = "IPSec" @dataclass class Host: - """Represents a host/service within a location.""" + """Represents a physical or virtual host at a location.""" name: str - address: str - type: str # e.g., "SSH", "Web", "SMB", "PostgreSQL", "Redis" + ip_address: str + host_type: HostType + description: str = "" + services: List[Service] = field(default_factory=list) + sub_hosts: List['Host'] = field( + default_factory=list) # For VMs under hypervisors + + def get_service_by_name(self, service_name: str) -> Optional[Service]: + """Get a service by its name.""" + for service in self.services: + if service.name == service_name: + return service + return None + + def is_hypervisor(self) -> bool: + """Check if this host has sub-hosts (VMs).""" + return len(self.sub_hosts) > 0 @dataclass class Location: - """Represents a location within a customer (e.g., headquarters, branch office).""" + """Represents a customer location.""" name: str - vpn_type: str # e.g., "OpenVPN", "WireGuard", "IPSec" + vpn_type: VPNType connected: bool = False active: bool = False - hosts: List[Host] = None - - def __post_init__(self): - if self.hosts is None: - self.hosts = [] + vpn_config: str = "" # Path to VPN config or connection details + hosts: List[Host] = field(default_factory=list) + + def get_host_by_name(self, host_name: str) -> Optional[Host]: + """Get a host by its name (searches recursively in sub-hosts).""" + def search_hosts(hosts_list: List[Host]) -> Optional[Host]: + for host in hosts_list: + if host.name == host_name: + return host + # Search in sub-hosts + sub_result = search_hosts(host.sub_hosts) + if sub_result: + return sub_result + return None + + return search_hosts(self.hosts) + + def get_all_hosts_flat(self) -> List[Host]: + """Get all hosts including sub-hosts in a flat list.""" + def collect_hosts(hosts_list: List[Host]) -> List[Host]: + result = [] + for host in hosts_list: + result.append(host) + result.extend(collect_hosts(host.sub_hosts)) + return result + + return collect_hosts(self.hosts) + + def get_hypervisors(self) -> List[Host]: + """Get all hosts that have sub-hosts (hypervisors).""" + return [host for host in self.get_all_hosts_flat() if host.is_hypervisor()] + + +@dataclass +class CustomerService: + """Represents a customer's cloud/web service.""" + name: str + url: str + service_type: str # e.g., "Email", "Phone System", "CRM", "ERP" + description: str = "" @dataclass class Customer: - """Represents a customer with multiple locations.""" + """Represents a customer with their services and locations.""" name: str - locations: List[Location] = None - - def __post_init__(self): - if self.locations is None: - self.locations = [] - - def get_active_locations(self) -> List[Location]: - """Get all active locations for this customer.""" - return [loc for loc in self.locations if loc.active] - - def get_inactive_locations(self) -> List[Location]: - """Get all inactive locations for this customer.""" - return [loc for loc in self.locations if not loc.active] - - def has_active_locations(self) -> bool: - """Check if customer has any active locations.""" - return any(loc.active for loc in self.locations) - + + # Customer's cloud/web services (available regardless of location) + services: List[CustomerService] = field(default_factory=list) + + # Customer's locations with their infrastructure + locations: List[Location] = field(default_factory=list) + def get_location_by_name(self, location_name: str) -> Optional[Location]: """Get a location by its name.""" for location in self.locations: if location.name == location_name: return location - return None \ No newline at end of file + return None + + def get_active_locations(self) -> List[Location]: + """Get all active locations for this customer.""" + return [loc for loc in self.locations if loc.active] + + def get_inactive_locations(self) -> List[Location]: + """Get all inactive locations for this customer.""" + return [loc for loc in self.locations if not loc.active] + + def has_active_locations(self) -> bool: + """Check if customer has any active locations.""" + return any(loc.active for loc in self.locations) + + def has_connected_locations(self) -> bool: + """Check if customer has any connected locations.""" + return any(loc.connected for loc in self.locations) + + def get_all_hosts_flat(self) -> List[Host]: + """Get all hosts from all locations in a flat list.""" + all_hosts = [] + for location in self.locations: + all_hosts.extend(location.get_all_hosts_flat()) + return all_hosts diff --git a/pyproject.toml b/pyproject.toml index 1bfdd9a..65c117c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,4 +5,6 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "pystray", + "pillow", ] diff --git a/uv.lock b/uv.lock index 589117f..1bfc0ec 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,150 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/e9/0b85c81e2b441267bca707b5d89f56c2f02578ef8f3eafddf0e0c0b8848c/pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe", size = 974602, upload-time = "2025-06-14T20:56:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/24/12e4e2dae5f85fd0c0b696404ed3374ea6ca398e7db886d4f1322eb30799/pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c", size = 676431, upload-time = "2025-06-14T20:44:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/f7/79/031492497624de4c728f1857181b06ce8c56444db4d49418fa459cba217c/pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2", size = 719330, upload-time = "2025-06-14T20:44:51.621Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7d/6169f16a0c7ec15b9381f8bf33872baf912de2ef68d96c798ca4c6ee641f/pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731", size = 667203, upload-time = "2025-06-14T20:44:53.262Z" }, + { url = "https://files.pythonhosted.org/packages/49/0f/f5ab2b0e57430a3bec9a62b6153c0e79c05a30d77b564efdb9f9446eeac5/pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96", size = 708807, upload-time = "2025-06-14T20:44:54.851Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/c5/7a866d24bc026f79239b74d05e2cf3088b03263da66d53d1b4cf5207f5ae/pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038", size = 5565335, upload-time = "2025-06-14T20:56:59.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/0b/a01477cde2a040f97e226f3e15e5ffd1268fcb6d1d664885a95ba592eca9/pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da", size = 389049, upload-time = "2025-06-14T20:46:53.757Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/64cf2661f6ab7c124d0486ec6d1d01a9bb2838a0d2a46006457d8c5e6845/pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350", size = 393110, upload-time = "2025-06-14T20:46:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/87/01e35c5a3c5bbdc93d5925366421e10835fcd7b23347b6c267df1b16d0b3/pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0", size = 392644, upload-time = "2025-06-14T20:46:56.503Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/54afe9ffee547c41e1161691e72067a37ed27466ac71c089bfdcd07ca70d/pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71", size = 396742, upload-time = "2025-06-14T20:46:57.64Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/6308fec6c9ffeda9942fef72724f4094c6df4933560f512e63eac37ebd30/pyobjc_framework_quartz-11.1.tar.gz", hash = "sha256:a57f35ccfc22ad48c87c5932818e583777ff7276605fef6afad0ac0741169f75", size = 3953275, upload-time = "2025-06-14T20:58:17.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/27/4f4fc0e6a0652318c2844608dd7c41e49ba6006ee5fb60c7ae417c338357/pyobjc_framework_quartz-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43a1138280571bbf44df27a7eef519184b5c4183a588598ebaaeb887b9e73e76", size = 216816, upload-time = "2025-06-14T20:53:37.358Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8a/1d15e42496bef31246f7401aad1ebf0f9e11566ce0de41c18431715aafbc/pyobjc_framework_quartz-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b23d81c30c564adf6336e00b357f355b35aad10075dd7e837cfd52a9912863e5", size = 221941, upload-time = "2025-06-14T20:53:38.34Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/a3f84d06e567efc12c104799c7fd015f9bea272a75f799eda8b79e8163c6/pyobjc_framework_quartz-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:07cbda78b4a8fcf3a2d96e047a2ff01f44e3e1820f46f0f4b3b6d77ff6ece07c", size = 221312, upload-time = "2025-06-14T20:53:39.435Z" }, + { url = "https://files.pythonhosted.org/packages/76/ef/8c08d4f255bb3efe8806609d1f0b1ddd29684ab0f9ffb5e26d3ad7957b29/pyobjc_framework_quartz-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:39d02a3df4b5e3eee1e0da0fb150259476910d2a9aa638ab94153c24317a9561", size = 226353, upload-time = "2025-06-14T20:53:40.655Z" }, +] + +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "vpntray" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "pillow" }, + { name = "pystray" }, +] + +[package.metadata] +requires-dist = [ + { name = "pillow" }, + { name = "pystray" }, +] diff --git a/widgets/__init__.py b/widgets/__init__.py new file mode 100644 index 0000000..501cb30 --- /dev/null +++ b/widgets/__init__.py @@ -0,0 +1,11 @@ +from .host_item import HostItem +from .location_card import ActiveLocationCard, InactiveLocationCard +from .customer_card import ActiveCustomerCard, InactiveCustomerCard + +__all__ = [ + 'HostItem', + 'ActiveLocationCard', + 'InactiveLocationCard', + 'ActiveCustomerCard', + 'InactiveCustomerCard' +] \ No newline at end of file diff --git a/widgets/customer_card.py b/widgets/customer_card.py new file mode 100644 index 0000000..950f38f --- /dev/null +++ b/widgets/customer_card.py @@ -0,0 +1,74 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from .location_card import ActiveLocationCard, InactiveLocationCard + + +class ActiveCustomerCard: + def __init__(self, customer, callbacks): + self.customer = customer + self.callbacks = callbacks + self.widget = self._create_widget() + + def _create_widget(self): + # GNOME-style card container + card_frame = Gtk.Frame() + card_frame.get_style_context().add_class("card") + card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS + + card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + card_frame.add(card_vbox) + + # Customer header + customer_label = Gtk.Label() + customer_label.set_markup(f"🏢 {self.customer.name}") + customer_label.set_halign(Gtk.Align.START) + card_vbox.pack_start(customer_label, False, False, 0) + + # Locations section + for i, location in enumerate(self.customer.locations): + if i > 0: # Add separator between locations + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + separator.set_margin_top(8) + separator.set_margin_bottom(8) + card_vbox.pack_start(separator, False, False, 0) + + location_card = ActiveLocationCard(location, self.customer.name, self.callbacks) + card_vbox.pack_start(location_card.widget, False, False, 0) + + return card_frame + + +class InactiveCustomerCard: + def __init__(self, customer, callbacks): + self.customer = customer + self.callbacks = callbacks + self.widget = self._create_widget() + + def _create_widget(self): + # GNOME-style card container + card_frame = Gtk.Frame() + card_frame.get_style_context().add_class("card") + card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS + + card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + card_frame.add(card_vbox) + + # Customer header - muted + customer_label = Gtk.Label() + customer_label.set_markup(f"🏢 {self.customer.name}") + customer_label.set_halign(Gtk.Align.START) + card_vbox.pack_start(customer_label, False, False, 0) + + # Locations section + for i, location in enumerate(self.customer.locations): + if i > 0: # Add separator between locations + separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + separator.set_margin_top(8) + separator.set_margin_bottom(8) + card_vbox.pack_start(separator, False, False, 0) + + location_card = InactiveLocationCard(location, self.customer.name, self.callbacks) + card_vbox.pack_start(location_card.widget, False, False, 0) + + return card_frame \ No newline at end of file diff --git a/widgets/host_item.py b/widgets/host_item.py new file mode 100644 index 0000000..980c7a9 --- /dev/null +++ b/widgets/host_item.py @@ -0,0 +1,81 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from models import ServiceType, HostType + + +class HostItem: + def __init__(self, host, open_service_callback): + self.host = host + self.open_service_callback = open_service_callback + self.widget = self._create_widget() + + def _create_widget(self): + # Clean horizontal layout without borders + host_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + + # Host header + host_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + host_box.pack_start(host_header, False, False, 0) + + # Host type icon + type_icons = { + HostType.LINUX: "🐧", + HostType.WINDOWS: "🪟", + HostType.WINDOWS_SERVER: "🖥️", + HostType.PROXMOX: "📦", + HostType.ESXI: "📦", + HostType.ROUTER: "🌐", + HostType.SWITCH: "🔗" + } + icon = type_icons.get(self.host.host_type, "💻") + + icon_label = Gtk.Label(label=icon) + host_header.pack_start(icon_label, False, False, 0) + + # Host details - compact single line + details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) + host_header.pack_start(details_vbox, True, True, 0) + + # Host name with IP inline + name_label = Gtk.Label() + name_label.set_markup(f"{self.host.name} ({self.host.host_type.value}) - {self.host.ip_address}") + name_label.set_halign(Gtk.Align.START) + details_vbox.pack_start(name_label, False, False, 0) + + # Services section - compact button row + if self.host.services: + services_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + services_box.set_margin_start(16) # Indent services + services_box.set_margin_top(4) + host_box.pack_start(services_box, False, False, 0) + + for service in self.host.services: + if service.service_type in [ServiceType.WEB_GUI, ServiceType.SSH, ServiceType.RDP]: # Only show launchable services + service_btn = Gtk.Button(label=service.service_type.value) + service_btn.get_style_context().add_class("suggested-action") + service_btn.connect("clicked", lambda btn, s=service: self._on_service_clicked(s)) + services_box.pack_start(service_btn, False, False, 0) + + # Sub-hosts (VMs) section + if self.host.sub_hosts: + subhost_label = Gtk.Label() + subhost_label.set_markup(f"Virtual Machines ({len(self.host.sub_hosts)})") + subhost_label.set_halign(Gtk.Align.START) + subhost_label.set_margin_top(8) + subhost_label.set_margin_start(16) + host_box.pack_start(subhost_label, False, False, 0) + + subhosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + subhosts_box.set_margin_start(32) # Double indent for VMs + host_box.pack_start(subhosts_box, False, False, 0) + + for subhost in self.host.sub_hosts: + subhost_item = HostItem(subhost, self.open_service_callback) + subhosts_box.pack_start(subhost_item.widget, False, False, 0) + + return host_box + + + def _on_service_clicked(self, service): + self.open_service_callback(service) \ No newline at end of file diff --git a/widgets/location_card.py b/widgets/location_card.py new file mode 100644 index 0000000..b84cb89 --- /dev/null +++ b/widgets/location_card.py @@ -0,0 +1,144 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from .host_item import HostItem +from models import VPNType + + +class ActiveLocationCard: + def __init__(self, location, customer_name, callbacks): + self.location = location + self.customer_name = customer_name + self.callbacks = callbacks + self.widget = self._create_widget() + + def _create_widget(self): + # Clean card layout - just a box with proper spacing + location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + + # Location header with controls + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + location_vbox.pack_start(header_box, False, False, 0) + + # Location info + info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + header_box.pack_start(info_vbox, True, True, 0) + + # Location name with VPN type + location_label = Gtk.Label() + location_label.set_markup(f"📍 {self.location.name}") + location_label.set_halign(Gtk.Align.START) + info_vbox.pack_start(location_label, False, False, 0) + + # VPN type + vpn_icons = { + VPNType.OPENVPN: "🔒", + VPNType.WIREGUARD: "⚡", + VPNType.IPSEC: "🛡️" + } + vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑") + + type_label = Gtk.Label() + type_label.set_markup(f"{vpn_icon} {self.location.vpn_type.value} VPN") + type_label.set_halign(Gtk.Align.START) + info_vbox.pack_start(type_label, False, False, 0) + + # Status and controls + controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + header_box.pack_end(controls_box, False, False, 0) + + # Status + status_text = "● Connected" if self.location.connected else "○ Disconnected" + status_color = "#4caf50" if self.location.connected else "#999" + status_label = Gtk.Label() + status_label.set_markup(f"{status_text}") + controls_box.pack_start(status_label, False, False, 0) + + # Connect/Disconnect button + btn_text = "Disconnect" if self.location.connected else "Connect" + connect_btn = Gtk.Button(label=btn_text) + if self.location.connected: + connect_btn.get_style_context().add_class("destructive-action") + else: + connect_btn.get_style_context().add_class("suggested-action") + connect_btn.connect("clicked", self._on_connect_clicked) + controls_box.pack_start(connect_btn, False, False, 0) + + # X button to deactivate (close button style) + close_btn = Gtk.Button(label="✕") + close_btn.set_tooltip_text("Deactivate location") + close_btn.get_style_context().add_class("circular") + close_btn.connect("clicked", self._on_deactivate_clicked) + controls_box.pack_start(close_btn, False, False, 0) + + # Hosts section if available + if self.location.hosts: + hosts_label = Gtk.Label() + hosts_label.set_markup("Infrastructure") + hosts_label.set_halign(Gtk.Align.START) + hosts_label.set_margin_top(8) + location_vbox.pack_start(hosts_label, False, False, 0) + + # Hosts box with indent + hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + hosts_box.set_margin_start(16) + location_vbox.pack_start(hosts_box, False, False, 0) + + for host in self.location.hosts: + host_item = HostItem(host, self.callbacks['open_service']) + hosts_box.pack_start(host_item.widget, False, False, 0) + + return location_vbox + + def _on_connect_clicked(self, button): + self.callbacks['toggle_connection'](self.location) + + def _on_deactivate_clicked(self, button): + self.callbacks['deactivate_location'](self.location, self.customer_name) + + +class InactiveLocationCard: + def __init__(self, location, customer_name, callbacks): + self.location = location + self.customer_name = customer_name + self.callbacks = callbacks + self.widget = self._create_widget() + + def _create_widget(self): + # Clean horizontal layout + location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + + # Location info + info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + location_hbox.pack_start(info_vbox, True, True, 0) + + # Location name + location_label = Gtk.Label() + location_label.set_markup(f"📍 {self.location.name}") + location_label.set_halign(Gtk.Align.START) + info_vbox.pack_start(location_label, False, False, 0) + + # VPN type and host count + vpn_icons = { + VPNType.OPENVPN: "🔒", + VPNType.WIREGUARD: "⚡", + VPNType.IPSEC: "🛡️" + } + vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑") + host_count = len(self.location.hosts) + + details_label = Gtk.Label() + details_label.set_markup(f"{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts") + details_label.set_halign(Gtk.Align.START) + info_vbox.pack_start(details_label, False, False, 0) + + # Activate button + activate_btn = Gtk.Button(label="Set Active") + activate_btn.get_style_context().add_class("suggested-action") + activate_btn.connect("clicked", self._on_activate_clicked) + location_hbox.pack_end(activate_btn, False, False, 0) + + return location_hbox + + def _on_activate_clicked(self, button): + self.callbacks['set_location_active'](self.location, self.customer_name) \ No newline at end of file