This commit is contained in:
2025-09-06 10:15:14 +02:00
parent cf1e7bba24
commit bae1572d3f
11 changed files with 1076 additions and 675 deletions

View File

@@ -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
- Extend widget system for additional UI components
- Add configuration management for VPN client integration

BIN
control-panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -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
)
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]
),
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]
),
# 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)
]
)
# 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)
]
),
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

681
main.py
View File

@@ -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
@@ -31,182 +34,16 @@ class VPNManagerWindow:
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())
@@ -218,114 +55,124 @@ class VPNManagerWindow:
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('<span font="24">🛡️</span>')
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("<b>Active Customers</b>")
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_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("<b>Inactive Customers</b>")
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())
# Start tray icon in separate thread
threading.Thread(target=self.tray_icon.run, daemon=True).start()
# 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)
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
if matching_locations:
filtered_customer = Customer(
name=customer.name,
locations=matching_locations
)
self.filtered_customers.append(filtered_customer)
# 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
# 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):
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 True
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)

140
models.py
View File

@@ -1,38 +1,134 @@
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
vpn_config: str = "" # Path to VPN config or connection details
hosts: List[Host] = field(default_factory=list)
def __post_init__(self):
if self.hosts is None:
self.hosts = []
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 = []
# 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
def get_active_locations(self) -> List[Location]:
"""Get all active locations for this customer."""
@@ -46,9 +142,13 @@ class Customer:
"""Check if customer has any active locations."""
return any(loc.active for loc in self.locations)
def get_location_by_name(self, location_name: str) -> Optional[Location]:
"""Get a location by its name."""
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:
if location.name == location_name:
return location
return None
all_hosts.extend(location.get_all_hosts_flat())
return all_hosts

View File

@@ -5,4 +5,6 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"pystray",
"pillow",
]

143
uv.lock generated
View File

@@ -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" },
]

11
widgets/__init__.py Normal file
View File

@@ -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'
]

74
widgets/customer_card.py Normal file
View File

@@ -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"<b><big>🏢 {self.customer.name}</big></b>")
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"<span alpha='60%'><b><big>🏢 {self.customer.name}</big></b></span>")
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

81
widgets/host_item.py Normal file
View File

@@ -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"<b>{self.host.name}</b> <small>({self.host.host_type.value}) - <tt>{self.host.ip_address}</tt></small>")
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"<b><small>Virtual Machines ({len(self.host.sub_hosts)})</small></b>")
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)

144
widgets/location_card.py Normal file
View File

@@ -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"<b>📍 {self.location.name}</b>")
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"<small>{vpn_icon} {self.location.vpn_type.value} VPN</small>")
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"<small><span color='{status_color}'>{status_text}</span></small>")
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("<b>Infrastructure</b>")
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"<b>📍 {self.location.name}</b>")
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"<small>{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts</small>")
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)