Initial commit: GSPro Remote MVP - Phase 1 complete

This commit is contained in:
Ryan Hill 2025-11-13 15:38:58 -06:00
commit 74ca4b38eb
50 changed files with 12818 additions and 0 deletions

335
backend/app/core/mdns.py Normal file
View file

@ -0,0 +1,335 @@
"""
mDNS service registration for GSPro Remote.
Allows the application to be discovered on the local network.
"""
import logging
import socket
import threading
from typing import Optional, Dict, Any
try:
from zeroconf import ServiceInfo, Zeroconf, IPVersion
except ImportError as e:
raise ImportError(f"Zeroconf library not installed: {e}")
logger = logging.getLogger(__name__)
class MDNSService:
"""
Manages mDNS/Bonjour service registration for network discovery.
"""
def __init__(
self,
name: str = "gsproapp",
port: int = 5005,
service_type: str = "_http._tcp.local.",
properties: Optional[Dict[str, Any]] = None,
):
"""
Initialize mDNS service.
Args:
name: Service name (will be accessible as {name}.local)
port: Port number the service is running on
service_type: mDNS service type
properties: Additional service properties
"""
self.name = name
self.port = port
self.service_type = service_type
self.properties = properties or {}
self.zeroconf: Optional[Zeroconf] = None
self.service_info: Optional[ServiceInfo] = None
self.is_running = False
self._lock = threading.Lock()
def _get_local_ip(self) -> str:
"""
Get the local IP address of the machine.
Returns:
Local IP address as string
"""
try:
# Create a socket to determine the local IP
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
# Connect to a public DNS server to determine local interface
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
# Fallback to localhost if can't determine
return "127.0.0.1"
def _create_service_info(self) -> ServiceInfo:
"""
Create the ServiceInfo object for registration.
Returns:
Configured ServiceInfo object
"""
local_ip = self._get_local_ip()
hostname = socket.gethostname()
# Create fully qualified service name
service_name = f"{self.name}.{self.service_type}"
# Add default properties
default_properties = {
"version": "0.1.0",
"platform": "windows",
"api": "rest",
"ui": "web",
}
# Merge with custom properties
all_properties = {**default_properties, **self.properties}
# Convert properties to bytes
properties_bytes = {}
for key, value in all_properties.items():
if isinstance(value, str):
properties_bytes[key] = value.encode("utf-8")
elif isinstance(value, bytes):
properties_bytes[key] = value
else:
properties_bytes[key] = str(value).encode("utf-8")
# Create service info
service_info = ServiceInfo(
type_=self.service_type,
name=service_name,
addresses=[socket.inet_aton(local_ip)],
port=self.port,
properties=properties_bytes,
server=f"{hostname}.local.",
)
return service_info
def start(self) -> bool:
"""
Start the mDNS service registration.
Returns:
True if service started successfully, False otherwise
"""
with self._lock:
if self.is_running:
logger.warning("mDNS service is already running")
return True
try:
# Create Zeroconf instance
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
# Create and register service
self.service_info = self._create_service_info()
self.zeroconf.register_service(self.service_info)
self.is_running = True
logger.info(f"mDNS service registered: {self.name}.local:{self.port} (type: {self.service_type})")
return True
except Exception as e:
logger.error(f"Failed to start mDNS service: {e}")
self.cleanup()
return False
def stop(self) -> None:
"""Stop the mDNS service registration."""
with self._lock:
if not self.is_running:
return
self.cleanup()
self.is_running = False
logger.info("mDNS service stopped")
def cleanup(self) -> None:
"""Clean up mDNS resources."""
try:
if self.zeroconf and self.service_info:
self.zeroconf.unregister_service(self.service_info)
if self.zeroconf:
self.zeroconf.close()
self.zeroconf = None
self.service_info = None
except Exception as e:
logger.error(f"Error during mDNS cleanup: {e}")
def update_properties(self, properties: Dict[str, Any]) -> bool:
"""
Update service properties.
Args:
properties: New properties to set
Returns:
True if properties updated successfully, False otherwise
"""
with self._lock:
if not self.is_running:
logger.warning("Cannot update properties: service is not running")
return False
try:
self.properties.update(properties)
# Recreate and re-register service with new properties
if self.zeroconf and self.service_info:
self.zeroconf.unregister_service(self.service_info)
self.service_info = self._create_service_info()
self.zeroconf.register_service(self.service_info)
logger.info("mDNS service properties updated")
return True
except Exception as e:
logger.error(f"Failed to update mDNS properties: {e}")
return False
def get_url(self) -> str:
"""
Get the URL for accessing the service.
Returns:
Service URL
"""
return f"http://{self.name}.local:{self.port}"
def __enter__(self):
"""Context manager entry."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.stop()
class MDNSBrowser:
"""
Browse for mDNS services on the network.
Useful for discovering other GSPro Remote instances.
"""
def __init__(self, service_type: str = "_http._tcp.local."):
"""
Initialize mDNS browser.
Args:
service_type: Type of services to browse for
"""
self.service_type = service_type
self.services: Dict[str, Dict[str, Any]] = {}
self.zeroconf: Optional[Zeroconf] = None
def browse(self, timeout: float = 5.0) -> Dict[str, Dict[str, Any]]:
"""
Browse for services on the network.
Args:
timeout: Time to wait for services (seconds)
Returns:
Dictionary of discovered services
"""
try:
from zeroconf import ServiceBrowser, ServiceListener
import time
class Listener(ServiceListener):
def __init__(self, browser):
self.browser = browser
def add_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(service_type, name)
if info:
self.browser.services[name] = {
"name": name,
"address": socket.inet_ntoa(info.addresses[0]) if info.addresses else None,
"port": info.port,
"properties": info.properties,
}
def remove_service(self, zeroconf, service_type, name):
self.browser.services.pop(name, None)
def update_service(self, zeroconf, service_type, name):
pass
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
listener = Listener(self)
browser = ServiceBrowser(self.zeroconf, self.service_type, listener)
# Wait for services to be discovered
time.sleep(timeout)
browser.cancel()
self.zeroconf.close()
return self.services
except Exception as e:
logger.error(f"Failed to browse for services: {e}")
return {}
# Test function for development
def test_mdns_service():
"""Test mDNS service registration."""
import time
print("Testing mDNS service registration...")
# Test service registration
service = MDNSService(
name="gsproapp-test",
port=5005,
properties={"test": "true", "instance": "development"},
)
if service.start():
print(f"✓ mDNS service started: {service.get_url()}")
print(f" You should be able to access it at: http://gsproapp-test.local:5005")
# Keep service running for 10 seconds
print(" Service will run for 10 seconds...")
time.sleep(10)
# Test property update
if service.update_properties({"status": "running", "uptime": "10s"}):
print("✓ Properties updated successfully")
service.stop()
print("✓ mDNS service stopped")
else:
print("✗ Failed to start mDNS service")
# Test service browsing
print("\nBrowsing for HTTP services on the network...")
browser = MDNSBrowser()
services = browser.browse(timeout=3.0)
if services:
print(f"Found {len(services)} services:")
for name, info in services.items():
print(f" - {name}: {info['address']}:{info['port']}")
else:
print("No services found")
print("\nmDNS test complete!")
if __name__ == "__main__":
test_mdns_service()