Add v0.9.5 features: minimize to tray, settings, relay support
## New Features ### Minimize to Tray - Window minimizes to system tray instead of taskbar - Tray notification shown when minimized - Double-click tray icon to restore ### Settings System - New settings dialog (Edit > Settings or Ctrl+,) - JSON-based settings persistence - General tab: minimize to tray toggle - Relay Server tab: enable/configure relay connection ### Relay Server Support - New relay_client.py for connecting to relay server - WebSocket client with auto-reconnection - Forwards API requests to local server - Updates QR code/URL when relay connected ### PWA Updates - Added relay mode detection and authentication - Password passed via header for API requests - WebSocket authentication for relay connections - Desktop status handling (connected/disconnected) - Wake lock icon now always visible with status indicator ## Files Added - gui/settings_manager.py - gui/settings_dialog.py - relay_client.py ## Dependencies - Added aiohttp>=3.9.0 for relay client 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
232
relay_client.py
Normal file
232
relay_client.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# Relay Client for MacroPad Server
|
||||
# Connects to relay server and forwards API requests to local server
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Callable
|
||||
import aiohttp
|
||||
|
||||
|
||||
class RelayClient:
|
||||
"""WebSocket client that connects to relay server and proxies requests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
relay_url: str,
|
||||
password: str,
|
||||
session_id: Optional[str] = None,
|
||||
local_port: int = 40000,
|
||||
on_connected: Optional[Callable] = None,
|
||||
on_disconnected: Optional[Callable] = None,
|
||||
on_session_id: Optional[Callable[[str], None]] = None
|
||||
):
|
||||
self.relay_url = relay_url.rstrip('/')
|
||||
if not self.relay_url.endswith('/desktop'):
|
||||
self.relay_url += '/desktop'
|
||||
self.password = password
|
||||
self.session_id = session_id
|
||||
self.local_url = f"http://localhost:{local_port}"
|
||||
|
||||
# Callbacks
|
||||
self.on_connected = on_connected
|
||||
self.on_disconnected = on_disconnected
|
||||
self.on_session_id = on_session_id
|
||||
|
||||
# State
|
||||
self._ws = None
|
||||
self._session = None
|
||||
self._running = False
|
||||
self._connected = False
|
||||
self._thread = None
|
||||
self._loop = None
|
||||
self._reconnect_delay = 1
|
||||
|
||||
def start(self):
|
||||
"""Start the relay client in a background thread."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run_async_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the relay client."""
|
||||
self._running = False
|
||||
if self._loop and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to relay server."""
|
||||
return self._connected
|
||||
|
||||
def _run_async_loop(self):
|
||||
"""Run the asyncio event loop in the background thread."""
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
try:
|
||||
self._loop.run_until_complete(self._connection_loop())
|
||||
except Exception as e:
|
||||
print(f"Relay client error: {e}")
|
||||
finally:
|
||||
self._loop.close()
|
||||
|
||||
async def _connection_loop(self):
|
||||
"""Main connection loop with reconnection logic."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._connect_and_run()
|
||||
except Exception as e:
|
||||
print(f"Relay connection error: {e}")
|
||||
|
||||
if self._running:
|
||||
# Exponential backoff for reconnection
|
||||
await asyncio.sleep(self._reconnect_delay)
|
||||
self._reconnect_delay = min(self._reconnect_delay * 2, 30)
|
||||
|
||||
async def _connect_and_run(self):
|
||||
"""Connect to relay server and handle messages."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
self._session = session
|
||||
async with session.ws_connect(self.relay_url) as ws:
|
||||
self._ws = ws
|
||||
|
||||
# Authenticate
|
||||
if not await self._authenticate():
|
||||
return
|
||||
|
||||
self._connected = True
|
||||
self._reconnect_delay = 1 # Reset backoff on successful connect
|
||||
|
||||
if self.on_connected:
|
||||
self.on_connected()
|
||||
|
||||
# Message handling loop
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await self._handle_message(json.loads(msg.data))
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
print(f"WebSocket error: {ws.exception()}")
|
||||
break
|
||||
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
break
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"Relay connection failed: {e}")
|
||||
finally:
|
||||
self._connected = False
|
||||
self._ws = None
|
||||
self._session = None
|
||||
if self.on_disconnected:
|
||||
self.on_disconnected()
|
||||
|
||||
async def _authenticate(self) -> bool:
|
||||
"""Authenticate with the relay server."""
|
||||
auth_msg = {
|
||||
"type": "auth",
|
||||
"sessionId": self.session_id,
|
||||
"password": self.password
|
||||
}
|
||||
await self._ws.send_json(auth_msg)
|
||||
|
||||
# Wait for auth response
|
||||
response = await self._ws.receive_json()
|
||||
|
||||
if response.get("type") == "auth_response":
|
||||
if response.get("success"):
|
||||
new_session_id = response.get("sessionId")
|
||||
if new_session_id and new_session_id != self.session_id:
|
||||
self.session_id = new_session_id
|
||||
if self.on_session_id:
|
||||
self.on_session_id(new_session_id)
|
||||
return True
|
||||
else:
|
||||
print(f"Authentication failed: {response.get('error', 'Unknown error')}")
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def _handle_message(self, msg: dict):
|
||||
"""Handle a message from the relay server."""
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "api_request":
|
||||
await self._handle_api_request(msg)
|
||||
elif msg_type == "ws_message":
|
||||
# Forward WebSocket message from web client
|
||||
await self._handle_ws_message(msg)
|
||||
elif msg_type == "ping":
|
||||
await self._ws.send_json({"type": "pong"})
|
||||
|
||||
async def _handle_api_request(self, msg: dict):
|
||||
"""Forward API request to local server and send response back."""
|
||||
request_id = msg.get("requestId")
|
||||
method = msg.get("method", "GET").upper()
|
||||
path = msg.get("path", "/")
|
||||
body = msg.get("body")
|
||||
headers = msg.get("headers", {})
|
||||
|
||||
url = f"{self.local_url}{path}"
|
||||
|
||||
try:
|
||||
# Forward request to local server
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
json=body if body and method in ("POST", "PUT", "PATCH") else None,
|
||||
headers=headers
|
||||
) as response:
|
||||
# Handle binary responses (images)
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if content_type.startswith("image/"):
|
||||
# Base64 encode binary data
|
||||
import base64
|
||||
data = await response.read()
|
||||
response_body = {
|
||||
"base64": base64.b64encode(data).decode("utf-8"),
|
||||
"contentType": content_type
|
||||
}
|
||||
else:
|
||||
try:
|
||||
response_body = await response.json()
|
||||
except:
|
||||
response_body = {"text": await response.text()}
|
||||
|
||||
await self._ws.send_json({
|
||||
"type": "api_response",
|
||||
"requestId": request_id,
|
||||
"status": response.status,
|
||||
"body": response_body
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await self._ws.send_json({
|
||||
"type": "api_response",
|
||||
"requestId": request_id,
|
||||
"status": 500,
|
||||
"body": {"error": str(e)}
|
||||
})
|
||||
|
||||
async def _handle_ws_message(self, msg: dict):
|
||||
"""Handle WebSocket message from web client."""
|
||||
data = msg.get("data", {})
|
||||
# For now, we don't need to forward messages from web clients
|
||||
# to the local server because the local server broadcasts changes
|
||||
# The relay will handle broadcasting back to web clients
|
||||
pass
|
||||
|
||||
async def broadcast(self, data: dict):
|
||||
"""Broadcast a message to all connected web clients via relay."""
|
||||
if self._ws and self._connected:
|
||||
await self._ws.send_json({
|
||||
"type": "ws_broadcast",
|
||||
"data": data
|
||||
})
|
||||
Reference in New Issue
Block a user