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

689
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
@@ -29,184 +32,18 @@ class VPNManagerWindow:
# Start hidden
self.window.hide()
def setup_css(self):
"""Minimal CSS for GNOME-style cards"""
css_provider = Gtk.CssProvider()
css = """
window {
background-color: #1a1d29;
color: #e8eaf6;
}
.header {
background: linear-gradient(135deg, #1a1d29 0%, #252836 100%);
padding: 20px;
}
.title {
font-size: 20px;
font-weight: bold;
color: #e8eaf6;
}
.search-entry {
background-color: #2d3142;
color: #e8eaf6;
border: 1px solid #5e72e4;
.card {
background: @theme_base_color;
border-radius: 8px;
padding: 10px;
margin: 10px 0;
}
.customer-card {
background-color: #252836;
border: 1px solid #3a3f5c;
border-radius: 8px;
margin: 8px 5px;
padding: 15px;
}
.location-card {
background-color: #2a2e3f;
border: 1px solid #3a3f5c;
border-radius: 6px;
margin: 5px 20px 5px 20px;
padding: 10px;
}
.host-item {
background-color: #1a1d29;
border: 1px solid #3a3f5c;
border-radius: 4px;
margin: 3px 2px;
padding: 6px 10px;
}
.active-title {
color: #2dce89;
font-weight: bold;
font-size: 14px;
}
.inactive-title {
color: #8892b0;
font-weight: bold;
font-size: 14px;
}
.customer-name {
color: #5e72e4;
font-weight: bold;
font-size: 14px;
}
.inactive-customer-name {
color: #8892b0;
font-weight: bold;
font-size: 14px;
}
.location-name {
color: #e8eaf6;
font-weight: bold;
font-size: 12px;
}
.vpn-type {
color: #8892b0;
font-size: 10px;
}
.connected-status {
color: #2dce89;
font-weight: bold;
font-size: 10px;
}
.disconnected-status {
color: #f5365c;
font-weight: bold;
font-size: 10px;
}
.connect-button {
background-color: #5e72e4;
color: white;
border: none;
border-radius: 4px;
padding: 5px 15px;
font-weight: bold;
}
.connect-button:hover {
background-color: #3a3f5c;
}
.disconnect-button {
background-color: #f5365c;
color: white;
border: none;
border-radius: 4px;
padding: 5px 15px;
font-weight: bold;
}
.disconnect-button:hover {
background-color: #3a3f5c;
}
.routes-button, .deactivate-button, .activate-button {
background-color: #3a3f5c;
color: #e8eaf6;
border: none;
border-radius: 4px;
padding: 5px 15px;
margin: 0 3px;
}
.deactivate-button {
background-color: #fb6340;
color: white;
}
.activate-button {
background-color: #5e72e4;
color: white;
font-weight: bold;
padding: 8px 20px;
}
.launch-button {
background-color: #5e72e4;
color: white;
border: none;
border-radius: 4px;
padding: 4px 12px;
font-weight: bold;
font-size: 9px;
}
.host-name {
color: #e8eaf6;
font-weight: bold;
font-size: 9px;
}
.host-address {
color: #8892b0;
font-size: 8px;
font-family: monospace;
}
.services-header {
color: #5e72e4;
font-weight: bold;
font-size: 10px;
}
.service-count {
color: #6c757d;
font-size: 9px;
border: 1px solid @borders;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 16px;
margin: 6px;
}
"""
css_provider.load_from_data(css.encode())
@@ -217,115 +54,125 @@ class VPNManagerWindow:
style_context.add_provider_for_screen(
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
def setup_ui(self):
# Main container
main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Use HeaderBar for native GNOME look
header_bar = Gtk.HeaderBar()
header_bar.set_show_close_button(True)
header_bar.set_title("VPN Manager")
header_bar.set_subtitle("Connection Manager")
self.window.set_titlebar(header_bar)
# Main container with proper spacing
main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
main_vbox.set_margin_start(12)
main_vbox.set_margin_end(12)
main_vbox.set_margin_top(12)
main_vbox.set_margin_bottom(12)
self.window.add(main_vbox)
# Header
header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
header_box.get_style_context().add_class("header")
main_vbox.pack_start(header_box, False, False, 0)
# Title with icon
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
title_box.set_halign(Gtk.Align.CENTER)
header_box.pack_start(title_box, False, False, 10)
icon_label = Gtk.Label(label="🛡️")
icon_label.set_markup('<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 column - Inactive customers
right_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
columns_box.pack_start(right_vbox, True, True, 0)
inactive_header = Gtk.Label(label="💤 Inactive Customers")
inactive_header.get_style_context().add_class("inactive-title")
inactive_header.set_halign(Gtk.Align.START)
right_vbox.pack_start(inactive_header, False, False, 0)
# Simple label header
inactive_label = Gtk.Label()
inactive_label.set_markup("<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())
# Quit item
quit_item = Gtk.MenuItem(label="Quit")
quit_item.connect("activate", self.quit_app)
menu.append(quit_item)
menu.show_all()
self.indicator.set_menu(menu)
# Start tray icon in separate thread
threading.Thread(target=self.tray_icon.run, daemon=True).start()
def get_callbacks(self):
"""Return callback functions for widget interactions"""
return {
'toggle_connection': self.toggle_connection,
'set_location_active': self.set_location_active,
'deactivate_location': self.deactivate_location,
'open_service': self.open_service,
'open_customer_service': self.open_customer_service
}
def render_customers(self):
# Clear existing content
@@ -343,237 +190,34 @@ class VPNManagerWindow:
inactive_locations = customer.get_inactive_locations()
if active_locations:
customer_data = Customer(
name=customer.name,
locations=active_locations
)
from models import Customer
customer_data = Customer(name=customer.name)
customer_data.services = customer.services
customer_data.locations = active_locations
customers_with_active.append(customer_data)
if inactive_locations:
customer_data = Customer(
name=customer.name,
locations=inactive_locations
)
from models import Customer
customer_data = Customer(name=customer.name)
customer_data.services = customer.services
customer_data.locations = inactive_locations
customers_with_inactive.append(customer_data)
# Render active customers
for customer in customers_with_active:
self.create_customer_with_active_locations(customer)
# Get callbacks for widgets
callbacks = self.get_callbacks()
# Render inactive customers
# Render active customers using widget classes
for customer in customers_with_active:
customer_card = ActiveCustomerCard(customer, callbacks)
self.active_box.pack_start(customer_card.widget, False, False, 0)
# Render inactive customers using widget classes
for customer in customers_with_inactive:
self.create_customer_without_active_locations(customer)
customer_card = InactiveCustomerCard(customer, callbacks)
self.inactive_box.pack_start(customer_card.widget, False, False, 0)
self.window.show_all()
def create_customer_with_active_locations(self, customer):
# Customer card
customer_frame = Gtk.Frame()
customer_frame.get_style_context().add_class("customer-card")
self.active_box.pack_start(customer_frame, False, False, 0)
customer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
customer_frame.add(customer_vbox)
# Customer header
customer_label = Gtk.Label(label=f"🏢 {customer.name}")
customer_label.get_style_context().add_class("customer-name")
customer_label.set_halign(Gtk.Align.START)
customer_vbox.pack_start(customer_label, False, False, 0)
# Render each location
for location in customer.locations:
self.create_active_location_card(location, customer_vbox, customer.name)
def create_active_location_card(self, location, parent_box, customer_name):
# Location card
location_frame = Gtk.Frame()
location_frame.get_style_context().add_class("location-card")
parent_box.pack_start(location_frame, False, False, 0)
location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
location_frame.add(location_vbox)
# Location header with controls
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
location_vbox.pack_start(header_box, False, False, 0)
# Location info
info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
header_box.pack_start(info_vbox, True, True, 0)
location_label = Gtk.Label(label=f"📍 {location.name}")
location_label.get_style_context().add_class("location-name")
location_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(location_label, False, False, 0)
# VPN type
vpn_icons = {
"OpenVPN": "🔒",
"WireGuard": "",
"IPSec": "🛡️"
}
vpn_icon = vpn_icons.get(location.vpn_type, "🔑")
type_label = Gtk.Label(label=f"{vpn_icon} {location.vpn_type} VPN")
type_label.get_style_context().add_class("vpn-type")
type_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(type_label, False, False, 0)
# Controls
controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
header_box.pack_end(controls_box, False, False, 0)
# Status
status_text = "● Connected" if location.connected else "○ Disconnected"
status_label = Gtk.Label(label=status_text)
if location.connected:
status_label.get_style_context().add_class("connected-status")
else:
status_label.get_style_context().add_class("disconnected-status")
controls_box.pack_start(status_label, False, False, 0)
# Connect/Disconnect button
btn_text = "Disconnect" if location.connected else "Connect"
connect_btn = Gtk.Button(label=btn_text)
if location.connected:
connect_btn.get_style_context().add_class("disconnect-button")
else:
connect_btn.get_style_context().add_class("connect-button")
connect_btn.connect("clicked", lambda btn, l=location: self.toggle_connection(l))
controls_box.pack_start(connect_btn, False, False, 0)
# Routes button
routes_btn = Gtk.Button(label="Routes")
routes_btn.get_style_context().add_class("routes-button")
routes_btn.connect("clicked", lambda btn, l=location: self.set_route(l))
controls_box.pack_start(routes_btn, False, False, 0)
# Deactivate button
deactivate_btn = Gtk.Button(label="Deactivate")
deactivate_btn.get_style_context().add_class("deactivate-button")
deactivate_btn.connect("clicked", lambda btn, l=location: self.deactivate_location(l, customer_name))
controls_box.pack_start(deactivate_btn, False, False, 0)
# Hosts section
if location.hosts:
# Separator
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
location_vbox.pack_start(separator, False, False, 5)
services_label = Gtk.Label(label="💼 Available Services")
services_label.get_style_context().add_class("services-header")
services_label.set_halign(Gtk.Align.START)
location_vbox.pack_start(services_label, False, False, 0)
for host in location.hosts:
self.create_host_item(host, location_vbox)
def create_host_item(self, host, parent_box):
host_frame = Gtk.Frame()
host_frame.get_style_context().add_class("host-item")
parent_box.pack_start(host_frame, False, False, 0)
host_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
host_frame.add(host_box)
# Icon
type_icons = {
"SSH": "💻",
"Web": "🌐",
"SMB": "📂",
"PostgreSQL": "🗃️",
"Redis": "🗂️"
}
icon = type_icons.get(host.type, "📡")
icon_label = Gtk.Label(label=icon)
host_box.pack_start(icon_label, False, False, 0)
# Host details
details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
host_box.pack_start(details_vbox, True, True, 0)
name_label = Gtk.Label(label=host.name)
name_label.get_style_context().add_class("host-name")
name_label.set_halign(Gtk.Align.START)
details_vbox.pack_start(name_label, False, False, 0)
addr_label = Gtk.Label(label=host.address)
addr_label.get_style_context().add_class("host-address")
addr_label.set_halign(Gtk.Align.START)
details_vbox.pack_start(addr_label, False, False, 0)
# Launch button for SSH and Web services
if host.type in ["SSH", "Web"]:
launch_btn = Gtk.Button(label="Launch")
launch_btn.get_style_context().add_class("launch-button")
launch_btn.connect("clicked", lambda btn, h=host: self.open_service(h))
host_box.pack_end(launch_btn, False, False, 0)
def create_customer_without_active_locations(self, customer):
# Customer card
customer_frame = Gtk.Frame()
customer_frame.get_style_context().add_class("customer-card")
self.inactive_box.pack_start(customer_frame, False, False, 0)
customer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
customer_frame.add(customer_vbox)
# Customer header
customer_label = Gtk.Label(label=f"🏢 {customer.name}")
customer_label.get_style_context().add_class("inactive-customer-name")
customer_label.set_halign(Gtk.Align.START)
customer_vbox.pack_start(customer_label, False, False, 0)
# Render each location
for location in customer.locations:
self.create_inactive_location_card(location, customer_vbox, customer.name)
def create_inactive_location_card(self, location, parent_box, customer_name):
# Location card
location_frame = Gtk.Frame()
location_frame.get_style_context().add_class("location-card")
parent_box.pack_start(location_frame, False, False, 0)
location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
location_frame.add(location_hbox)
# Location info
info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
location_hbox.pack_start(info_vbox, True, True, 0)
location_label = Gtk.Label(label=f"📍 {location.name}")
location_label.get_style_context().add_class("location-name")
location_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(location_label, False, False, 0)
# VPN type
vpn_icons = {
"OpenVPN": "🔒",
"WireGuard": "",
"IPSec": "🛡️"
}
vpn_icon = vpn_icons.get(location.vpn_type, "🔑")
type_label = Gtk.Label(label=f"{vpn_icon} {location.vpn_type} VPN")
type_label.get_style_context().add_class("vpn-type")
type_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(type_label, False, False, 0)
# Service count
service_count = len(location.hosts)
count_label = Gtk.Label(label=f"📊 {service_count} services available")
count_label.get_style_context().add_class("service-count")
count_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(count_label, False, False, 0)
# Activate button
activate_btn = Gtk.Button(label="Set Active")
activate_btn.get_style_context().add_class("activate-button")
activate_btn.connect("clicked", lambda btn, l=location: self.set_location_active(l, customer_name))
location_hbox.pack_end(activate_btn, False, False, 0)
def set_location_active(self, location, customer_name):
for customer in self.customers:
if customer.name == customer_name:
@@ -590,6 +234,7 @@ class VPNManagerWindow:
target_location = customer.get_location_by_name(location.name)
if target_location:
target_location.active = False
target_location.connected = False # Disconnect when deactivating
print(f"Mock: Deactivating {customer.name} - {target_location.name}")
break
self.render_customers()
@@ -599,24 +244,50 @@ class VPNManagerWindow:
if search_term:
self.filtered_customers = []
for customer in self.customers:
# Check if search term matches customer name
if search_term in customer.name.lower():
self.filtered_customers.append(customer)
else:
matching_locations = []
for location in customer.locations:
if (search_term in location.name.lower() or
search_term in location.vpn_type.lower() or
any(search_term in h.name.lower() or
search_term in h.address.lower()
for h in location.hosts)):
matching_locations.append(location)
continue
# Check customer services
if any(search_term in service.name.lower() or
search_term in service.url.lower() or
search_term in service.service_type.lower()
for service in customer.services):
self.filtered_customers.append(customer)
continue
# Check locations and their hosts
for location in customer.locations:
# Check location name
if search_term in location.name.lower():
self.filtered_customers.append(customer)
break
if matching_locations:
filtered_customer = Customer(
name=customer.name,
locations=matching_locations
)
self.filtered_customers.append(filtered_customer)
# Check hosts and their services in this location
def search_hosts(hosts):
for host in hosts:
if (search_term in host.name.lower() or
search_term in host.ip_address.lower() or
search_term in host.host_type.value.lower() or
search_term in host.description.lower()):
return True
# Check host services
if any(search_term in service.name.lower() or
search_term in str(service.port).lower() or
search_term in service.service_type.value.lower()
for service in host.services):
return True
# Check sub-hosts recursively
if search_hosts(host.sub_hosts):
return True
return False
if search_hosts(location.hosts):
self.filtered_customers.append(customer)
break
else:
self.filtered_customers = self.customers.copy()
@@ -625,24 +296,42 @@ class VPNManagerWindow:
def toggle_connection(self, location):
location.connected = not location.connected
status = "connected to" if location.connected else "disconnected from"
print(f"Mock: {status} - {location.name} via {location.vpn_type}")
print(f"Mock: {status} {location.name} via {location.vpn_type.value}")
self.render_customers()
def set_route(self, location):
print(f"Mock: Setting route for {location.name}")
def open_service(self, service):
# Get the host IP from context - this would need to be passed properly in a real implementation
print(f"Mock: Opening {service.service_type.value} service: {service.name} on port {service.port}")
def open_service(self, host):
print(f"Mock: Opening {host.type} service: {host.name} at {host.address}")
def open_customer_service(self, customer_service):
print(f"Mock: Opening customer service: {customer_service.name} at {customer_service.url}")
def show_window_from_tray(self, widget=None):
def show_window_from_tray(self, _icon=None, _item=None):
# Use GLib.idle_add to safely call GTK functions from the tray thread
GLib.idle_add(self._show_window_safe)
def _show_window_safe(self):
"""Safely show window in main GTK thread"""
self.window.deiconify()
self.window.present()
self.window.show_all()
return False # Don't repeat the idle call
def hide_window(self, widget, event):
self.window.hide()
return True
def on_window_state_event(self, _widget, event):
"""Handle window state changes - hide to tray when minimized"""
if event.new_window_state & Gdk.WindowState.ICONIFIED:
self.window.hide()
return False
def quit_app(self, widget=None):
def quit_app_from_close(self, _widget=None, _event=None):
"""Quit app when close button is pressed"""
self.quit_app()
return False
def quit_app(self, _widget=None):
# Stop the tray icon
if hasattr(self, 'tray_icon'):
self.tray_icon.stop()
Gtk.main_quit()
sys.exit(0)