This commit is contained in:
2025-09-06 06:23:56 +02:00
parent 00029680d0
commit a29252c716
7 changed files with 1195 additions and 0 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

79
CLAUDE.md Normal file
View File

@@ -0,0 +1,79 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Commands
### Running the Application
- `uv run python main.py` - Start the VPN manager GUI application
- `uv run python main.py &` - Start the application in background for testing
- The application runs as a system tray application and can be minimized to the tray
### Managing Running Instances
- `ps aux | grep "python main.py" | grep -v grep` - Check for running instances
- `pkill -f "uv run python main.py"` - Kill running instances (recommended)
- Note: `KillBash` only kills the shell, not the spawned uv process - use `pkill` instead
### Development Environment
- This project uses `uv` for Python package management
- `uv sync` - Install dependencies from pyproject.toml
- Python 3.13+ required
## Code Architecture
### Core Components
**main.py** - Main GUI application entry point
- `VPNManagerWindow` class: Primary tkinter-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
- Includes search functionality across customers, locations, and hosts
**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
- Each model includes helper methods for common operations
**data_loader.py** - Data management layer
- `load_customers()`: Currently returns mock data, designed to be replaceable
- `save_customers()`: Placeholder for future persistence
- Isolates data loading logic from UI components
### 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
**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)
- UI automatically reorganizes based on location activation state
**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
### Data Flow
1. `data_loader.load_customers()` provides initial customer data
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
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
- System tray integration for minimize-to-tray behavior
### 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

147
data_loader.py Normal file
View File

@@ -0,0 +1,147 @@
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."""
# 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"),
]
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(
name="Main Office",
vpn_type="WireGuard",
connected=True,
active=True,
hosts=beta_main_hosts
)
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]
),
]
return customers
def save_customers(customers: List[Customer]) -> None:
"""Save customer data. Placeholder for future implementation."""
# TODO: Implement saving to file/database
pass

753
main.py Normal file
View File

@@ -0,0 +1,753 @@
#!/usr/bin/env python3
import tkinter as tk
from tkinter import ttk
import pystray
from PIL import Image, ImageDraw
import threading
import sys
import queue
from models import Customer, Location, Host
from data_loader import load_customers
class VPNManagerWindow:
def __init__(self):
self.window = tk.Tk()
self.window.title("VPN Manager")
self.window.geometry("1200x750")
self.window.minsize(1000, 600)
self.window.protocol("WM_DELETE_WINDOW", self.hide_window)
# Command queue for thread communication
self.command_queue = queue.Queue()
# Configure modern theme colors
self.bg_color = "#1a1d29" # Deep dark blue
self.fg_color = "#e8eaf6" # Soft white
self.entry_bg = "#2d3142" # Dark gray-blue
self.button_bg = "#252836" # Card background
self.hover_bg = "#3a3f5c" # Hover state
self.accent_color = "#5e72e4" # Primary accent (blue)
self.success_color = "#2dce89" # Green for connected
self.danger_color = "#f5365c" # Red for disconnected
self.warning_color = "#fb6340" # Orange for warnings
self.window.configure(bg=self.bg_color)
# Load customer data using dataclasses
self.customers = load_customers()
self.filtered_customers = self.customers.copy()
self.icon = None
self.setup_ui()
self.setup_tray()
self.hide_window()
def setup_ui(self):
# Header with gradient effect (simulated with frame)
header_frame = tk.Frame(self.window, bg=self.bg_color)
header_frame.pack(fill=tk.X, padx=20, pady=(20, 10))
# Title with icon
title_frame = tk.Frame(header_frame, bg=self.bg_color)
title_frame.pack(side=tk.TOP)
# Shield icon next to title
icon_label = tk.Label(
title_frame,
text="🛡️",
font=("Segoe UI Emoji", 24),
bg=self.bg_color,
fg=self.accent_color
)
icon_label.pack(side=tk.LEFT, padx=(0, 10))
title_label = tk.Label(
title_frame,
text="VPN Connection Manager",
font=("Segoe UI", 20, "bold"),
bg=self.bg_color,
fg=self.fg_color
)
title_label.pack(side=tk.LEFT)
# Search bar with modern styling
search_container = tk.Frame(header_frame, bg=self.bg_color)
search_container.pack(fill=tk.X, pady=(20, 10))
search_frame = tk.Frame(search_container, bg=self.entry_bg,
highlightbackground=self.accent_color, highlightthickness=1)
search_frame.pack(fill=tk.X)
search_label = tk.Label(
search_frame,
text="🔍",
font=("Segoe UI Emoji", 14),
bg=self.entry_bg,
fg=self.accent_color
)
search_label.pack(side=tk.LEFT, padx=(10, 5))
self.search_var = tk.StringVar()
self.search_var.trace("w", self.filter_customers)
search_entry = tk.Entry(
search_frame,
textvariable=self.search_var,
font=("Segoe UI", 12),
bg=self.entry_bg,
fg=self.fg_color,
insertbackground=self.accent_color,
relief=tk.FLAT,
bd=0
)
search_entry.pack(side=tk.LEFT, fill=tk.X,
expand=True, padx=(0, 10), pady=10)
# Main container for two columns
self.columns_container = tk.Frame(self.window, bg=self.bg_color)
self.columns_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# Left column - Active customers
left_column = tk.Frame(self.columns_container, bg=self.bg_color)
left_column.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
# Active customers header
active_header = tk.Frame(left_column, bg=self.bg_color)
active_header.pack(fill=tk.X, pady=(0, 10))
active_title = tk.Label(
active_header,
text="✅ Active Customers",
font=("Segoe UI", 14, "bold"),
bg=self.bg_color,
fg=self.success_color
)
active_title.pack(side=tk.LEFT)
# Active customers scrollable area
active_canvas = tk.Canvas(
left_column, bg=self.bg_color, highlightthickness=0)
active_scrollbar = ttk.Scrollbar(
left_column, orient="vertical", command=active_canvas.yview)
self.active_frame = tk.Frame(active_canvas, bg=self.bg_color)
self.active_frame.bind(
"<Configure>",
lambda e: active_canvas.configure(
scrollregion=active_canvas.bbox("all"))
)
active_canvas.create_window(
(0, 0), window=self.active_frame, anchor="nw")
active_canvas.configure(yscrollcommand=active_scrollbar.set)
active_canvas.pack(side="left", fill="both", expand=True)
active_scrollbar.pack(side="right", fill="y")
# Separator
separator = tk.Frame(self.columns_container, bg="#3a3f5c", width=2)
separator.pack(side=tk.LEFT, fill=tk.Y, padx=5)
# Right column - Inactive customers
right_column = tk.Frame(self.columns_container, bg=self.bg_color)
right_column.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0))
# Inactive customers header
inactive_header = tk.Frame(right_column, bg=self.bg_color)
inactive_header.pack(fill=tk.X, pady=(0, 10))
inactive_title = tk.Label(
inactive_header,
text="💤 Inactive Customers",
font=("Segoe UI", 14, "bold"),
bg=self.bg_color,
fg="#8892b0"
)
inactive_title.pack(side=tk.LEFT)
# Inactive customers scrollable area
inactive_canvas = tk.Canvas(
right_column, bg=self.bg_color, highlightthickness=0)
inactive_scrollbar = ttk.Scrollbar(
right_column, orient="vertical", command=inactive_canvas.yview)
self.inactive_frame = tk.Frame(inactive_canvas, bg=self.bg_color)
self.inactive_frame.bind(
"<Configure>",
lambda e: inactive_canvas.configure(
scrollregion=inactive_canvas.bbox("all"))
)
inactive_canvas.create_window(
(0, 0), window=self.inactive_frame, anchor="nw")
inactive_canvas.configure(yscrollcommand=inactive_scrollbar.set)
inactive_canvas.pack(side="left", fill="both", expand=True)
inactive_scrollbar.pack(side="right", fill="y")
self.active_customer_frames = []
self.inactive_customer_frames = []
self.render_customers()
def setup_tray(self):
# Create tray icon in a separate thread
def run_tray():
image = self.create_tray_icon()
menu = pystray.Menu(
pystray.MenuItem("Open VPN Manager",
self.show_window_from_tray, default=True),
pystray.MenuItem("Quit", self.quit_app)
)
# Set both menu and default click action
self.icon = pystray.Icon("VPNTray", image, menu=menu)
# Add left-click handler
def on_clicked(icon, item):
print("Tray icon clicked!")
self.show_window_from_tray()
self.icon.default_menu_item = menu.items[0]
print("Starting system tray icon...")
self.icon.run()
tray_thread = threading.Thread(target=run_tray, daemon=True)
tray_thread.start()
def render_customers(self):
# Clear existing customer frames
for frame in self.active_customer_frames:
frame.destroy()
self.active_customer_frames.clear()
for frame in self.inactive_customer_frames:
frame.destroy()
self.inactive_customer_frames.clear()
# Separate customers with active and inactive locations
customers_with_active = []
customers_with_inactive = []
for customer in self.filtered_customers:
active_locations = customer.get_active_locations()
inactive_locations = customer.get_inactive_locations()
if active_locations:
# Customer has at least one active location - show only active locations
customer_data = Customer(
name=customer.name,
locations=active_locations
)
customers_with_active.append(customer_data)
if inactive_locations:
# Customer has at least one inactive location - show only inactive locations
customer_data = Customer(
name=customer.name,
locations=inactive_locations
)
customers_with_inactive.append(customer_data)
# Render customers with active locations on the left
for customer in customers_with_active:
self.create_customer_with_active_locations(customer)
# Render customers with inactive locations on the right
for customer in customers_with_inactive:
self.create_customer_without_active_locations(customer)
def create_customer_with_active_locations(self, customer):
# Customer container card
customer_frame = tk.Frame(
self.active_frame,
bg=self.button_bg,
relief=tk.FLAT,
highlightbackground="#3a3f5c",
highlightthickness=1
)
customer_frame.pack(fill=tk.X, pady=8, padx=5)
self.active_customer_frames.append(customer_frame)
# Customer header
customer_header = tk.Frame(customer_frame, bg=self.button_bg)
customer_header.pack(fill=tk.X, padx=15, pady=(10, 5))
customer_label = tk.Label(
customer_header,
text=f"🏢 {customer.name}",
font=("Segoe UI", 14, "bold"),
bg=self.button_bg,
fg=self.accent_color
)
customer_label.pack(anchor="w")
# Render each active location
for location in customer.locations:
self.create_active_location_card(
location, customer_frame, customer.name)
def create_active_location_card(self, location, parent_frame, customer_name):
# Location card within customer container
card_frame = tk.Frame(
parent_frame,
bg="#2a2e3f",
relief=tk.FLAT,
highlightbackground="#3a3f5c",
highlightthickness=1
)
card_frame.pack(fill=tk.X, pady=5, padx=(20, 10))
# Location header
header = tk.Frame(card_frame, bg="#2a2e3f")
header.pack(fill=tk.X, padx=10, pady=8)
# Location name and VPN type
info_frame = tk.Frame(header, bg="#2a2e3f")
info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Location name
location_label = tk.Label(
info_frame,
text=f"📍 {location.name}",
font=("Segoe UI", 12, "bold"),
bg="#2a2e3f",
fg=self.fg_color,
anchor="w"
)
location_label.pack(anchor="w")
# VPN type with icon
vpn_icons = {
"OpenVPN": "🔒",
"WireGuard": "",
"IPSec": "🛡️"
}
vpn_icon = vpn_icons.get(location.vpn_type, "🔑")
type_label = tk.Label(
info_frame,
text=f"{vpn_icon} {location.vpn_type} VPN",
font=("Segoe UI", 10),
bg="#2a2e3f",
fg="#8892b0",
anchor="w"
)
type_label.pack(anchor="w")
# Connection controls
controls_frame = tk.Frame(header, bg="#2a2e3f")
controls_frame.pack(side=tk.RIGHT)
# Status indicator with modern colors
status_color = self.success_color if location.connected else self.danger_color
status_text = "● Connected" if location.connected else "○ Disconnected"
status_frame = tk.Frame(controls_frame, bg="#2a2e3f")
status_frame.pack(side=tk.LEFT, padx=(0, 10))
status_label = tk.Label(
status_frame,
text=status_text,
font=("Segoe UI", 10, "bold"),
bg="#2a2e3f",
fg=status_color
)
status_label.pack()
# Connect/Disconnect button with modern styling
btn_text = "Disconnect" if location.connected else "Connect"
btn_bg = self.danger_color if location.connected else self.accent_color
connect_btn = tk.Button(
controls_frame,
text=btn_text,
font=("Segoe UI", 10, "bold"),
bg=btn_bg,
fg="white",
activebackground=self.hover_bg,
activeforeground="white",
relief=tk.FLAT,
padx=15,
pady=5,
cursor="hand2",
command=lambda l=location: self.toggle_connection(l)
)
connect_btn.pack(side=tk.LEFT, padx=3)
# Route button with modern styling
route_btn = tk.Button(
controls_frame,
text="Routes",
font=("Segoe UI", 10),
bg=self.hover_bg,
fg=self.fg_color,
activebackground=self.accent_color,
activeforeground="white",
relief=tk.FLAT,
padx=15,
pady=5,
cursor="hand2",
command=lambda l=location: self.set_route(l)
)
route_btn.pack(side=tk.LEFT, padx=3)
# Deactivate button
deactivate_btn = tk.Button(
controls_frame,
text="Deactivate",
font=("Segoe UI", 10),
bg=self.warning_color,
fg="white",
activebackground=self.hover_bg,
activeforeground="white",
relief=tk.FLAT,
padx=15,
pady=5,
cursor="hand2",
command=lambda l=location: self.deactivate_location(
l, customer_name)
)
deactivate_btn.pack(side=tk.LEFT, padx=3)
# Hosts/Services section
if location.hosts:
hosts_frame = tk.Frame(card_frame, bg="#2a2e3f")
hosts_frame.pack(fill=tk.X, padx=20, pady=(0, 10))
# Services header with separator
separator = tk.Frame(hosts_frame, bg="#3a3f5c", height=1)
separator.pack(fill=tk.X, pady=(0, 8))
hosts_label = tk.Label(
hosts_frame,
text="💼 Available Services",
font=("Segoe UI", 10, "bold"),
bg="#2a2e3f",
fg=self.accent_color,
anchor="w"
)
hosts_label.pack(anchor="w", pady=(0, 8))
for host in location.hosts:
# Modern service item with rounded appearance
host_item = tk.Frame(
hosts_frame, bg="#1a1d29", highlightbackground="#3a3f5c", highlightthickness=1)
host_item.pack(fill=tk.X, pady=3, padx=2)
# Host type icon with improved icons
type_icon = {
"SSH": "💻",
"Web": "🌐",
"SMB": "📂",
"PostgreSQL": "🗃️",
"Redis": "🗂️"
}.get(host.type, "📡")
# Left side with icon and text
info_frame = tk.Frame(host_item, bg="#1a1d29")
info_frame.pack(side=tk.LEFT, fill=tk.X,
expand=True, padx=10, pady=6)
icon_label = tk.Label(
info_frame,
text=type_icon,
font=("Segoe UI Emoji", 12),
bg="#1a1d29"
)
icon_label.pack(side=tk.LEFT, padx=(0, 8))
# Service details
details_frame = tk.Frame(info_frame, bg="#1a1d29")
details_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
name_label = tk.Label(
details_frame,
text=host.name,
font=("Segoe UI", 9, "bold"),
bg="#1a1d29",
fg=self.fg_color,
anchor="w"
)
name_label.pack(anchor="w")
addr_label = tk.Label(
details_frame,
text=host.address,
font=("Consolas", 8),
bg="#1a1d29",
fg="#8892b0",
anchor="w"
)
addr_label.pack(anchor="w")
# Quick access button with better styling
if host.type in ["SSH", "Web"]:
access_btn = tk.Button(
host_item,
text="Launch",
font=("Segoe UI", 9, "bold"),
bg=self.accent_color,
fg="white",
activebackground=self.hover_bg,
activeforeground="white",
relief=tk.FLAT,
padx=12,
pady=4,
cursor="hand2",
command=lambda h=host: self.open_service(h)
)
access_btn.pack(side=tk.RIGHT, padx=8, pady=4)
def create_customer_without_active_locations(self, customer):
# Customer container card
customer_frame = tk.Frame(
self.inactive_frame,
bg=self.button_bg,
relief=tk.FLAT,
highlightbackground="#3a3f5c",
highlightthickness=1
)
customer_frame.pack(fill=tk.X, pady=8, padx=5)
self.inactive_customer_frames.append(customer_frame)
# Customer header
customer_header = tk.Frame(customer_frame, bg=self.button_bg)
customer_header.pack(fill=tk.X, padx=15, pady=(10, 5))
customer_label = tk.Label(
customer_header,
text=f"🏢 {customer.name}",
font=("Segoe UI", 14, "bold"),
bg=self.button_bg,
fg="#8892b0"
)
customer_label.pack(anchor="w")
# Render all locations for this customer
for location in customer.locations:
self.create_inactive_location_card(
location, customer_frame, customer.name)
def create_inactive_location_card(self, location, parent_frame, customer_name):
# Location card within customer container
card_frame = tk.Frame(
parent_frame,
bg="#2a2e3f",
relief=tk.FLAT,
highlightbackground="#3a3f5c",
highlightthickness=1
)
card_frame.pack(fill=tk.X, pady=5, padx=(20, 10))
# Location info container
info_container = tk.Frame(card_frame, bg="#2a2e3f")
info_container.pack(fill=tk.X, padx=12, pady=10)
# Location and VPN type
info_frame = tk.Frame(info_container, bg="#2a2e3f")
info_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Location name
location_label = tk.Label(
info_frame,
text=f"📍 {location.name}",
font=("Segoe UI", 12, "bold"),
bg="#2a2e3f",
fg=self.fg_color,
anchor="w"
)
location_label.pack(anchor="w")
# VPN type with icon
vpn_icons = {
"OpenVPN": "🔒",
"WireGuard": "",
"IPSec": "🛡️"
}
vpn_icon = vpn_icons.get(location.vpn_type, "🔑")
type_label = tk.Label(
info_frame,
text=f"{vpn_icon} {location.vpn_type} VPN",
font=("Segoe UI", 10),
bg="#2a2e3f",
fg="#8892b0",
anchor="w"
)
type_label.pack(anchor="w", pady=(2, 0))
# Service count summary
service_count = len(location.hosts)
services_label = tk.Label(
info_frame,
text=f"📊 {service_count} services available",
font=("Segoe UI", 9),
bg="#2a2e3f",
fg="#6c757d",
anchor="w"
)
services_label.pack(anchor="w", pady=(4, 0))
# Set Active button
activate_btn = tk.Button(
info_container,
text="Set Active",
font=("Segoe UI", 10, "bold"),
bg=self.accent_color,
fg="white",
activebackground=self.hover_bg,
activeforeground="white",
relief=tk.FLAT,
padx=20,
pady=8,
cursor="hand2",
command=lambda l=location: self.set_location_active(
l, customer_name)
)
activate_btn.pack(side=tk.RIGHT)
def set_location_active(self, location, customer_name):
# Set this location as active
# Find the location in the original data structure and update it
for customer in self.customers:
if customer.name == customer_name:
target_location = customer.get_location_by_name(location.name)
if target_location:
target_location.active = True
print(
f"Mock: Setting {customer.name} - {target_location.name} as active")
break
self.render_customers()
def deactivate_location(self, location, customer_name):
# Deactivate this location
for customer in self.customers:
if customer.name == customer_name:
target_location = customer.get_location_by_name(location.name)
if target_location:
target_location.active = False
print(
f"Mock: Deactivating {customer.name} - {target_location.name}")
break
self.render_customers()
def filter_customers(self, *args):
search_term = self.search_var.get().lower()
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:
# Check if search term matches any location or host
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)
if matching_locations:
# Create a filtered customer with only matching locations
filtered_customer = Customer(
name=customer.name,
locations=matching_locations
)
self.filtered_customers.append(filtered_customer)
else:
self.filtered_customers = self.customers.copy()
self.render_customers()
def toggle_connection(self, location):
# Mock toggle connection for 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}")
self.render_customers()
def set_route(self, location):
print(f"Mock: Setting route for {location.name}")
def open_service(self, host):
print(
f"Mock: Opening {host.type} service: {host.name} at {host.address}")
def show_window_from_tray(self, icon=None, item=None):
# Put command in queue to be processed by main thread
print("Adding show_window to queue...")
self.command_queue.put("show_window")
def show_window(self):
print("Showing window...")
self.window.deiconify()
self.window.lift()
self.window.focus_force()
print("Window should be visible now")
def hide_window(self):
self.window.withdraw()
def quit_app(self, icon=None, item=None):
if self.icon:
self.icon.stop()
self.window.quit()
sys.exit(0)
def create_tray_icon(self):
# Create a simple VPN icon
width = 64
height = 64
image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
# Draw a shield shape for VPN icon
shield_points = [
(32, 10), # Top center
(50, 20), # Top right
(50, 35), # Right side
(32, 54), # Bottom point
(14, 35), # Left side
(14, 20), # Top left
(32, 10) # Close shape
]
draw.polygon(shield_points, fill=(
66, 135, 245), outline=(255, 255, 255))
# Draw a lock symbol
draw.ellipse([25, 23, 39, 32], fill=(255, 255, 255))
draw.rectangle([27, 28, 37, 38], fill=(255, 255, 255))
return image
def process_queue(self):
# Process commands from the queue
try:
while True:
command = self.command_queue.get_nowait()
print(f"Processing command: {command}")
if command == "show_window":
self.show_window()
except queue.Empty:
pass
# Schedule next check
self.window.after(100, self.process_queue)
def run(self):
# Start processing queue
self.process_queue()
# Start the tkinter main loop in the main thread
self.window.mainloop()
def main():
app = VPNManagerWindow()
app.run()
if __name__ == "__main__":
main()

54
models.py Normal file
View File

@@ -0,0 +1,54 @@
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Host:
"""Represents a host/service within a location."""
name: str
address: str
type: str # e.g., "SSH", "Web", "SMB", "PostgreSQL", "Redis"
@dataclass
class Location:
"""Represents a location within a customer (e.g., headquarters, branch office)."""
name: str
vpn_type: str # e.g., "OpenVPN", "WireGuard", "IPSec"
connected: bool = False
active: bool = False
hosts: List[Host] = None
def __post_init__(self):
if self.hosts is None:
self.hosts = []
@dataclass
class Customer:
"""Represents a customer with multiple 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)
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

10
pyproject.toml Normal file
View File

@@ -0,0 +1,10 @@
[project]
name = "vpntray"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"pystray>=0.19.0",
"pillow>=10.0.0",
]

151
uv.lock generated Normal file
View File

@@ -0,0 +1,151 @@
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", specifier = ">=10.0.0" },
{ name = "pystray", specifier = ">=0.19.0" },
]