host.match_monitor.tray_app

System tray application for Match Monitor (Windows).

Launch via: python -m host.match_monitor --tray

__main__.py automatically re-launches under pythonw.exe so the originating cmd/PowerShell window closes immediately and no console is left open.

The "Open Console" tray menu item allocates a fresh Windows console window (via AllocConsole) on demand. "Close Console" frees it. The console runs the interactive monitor> prompt and can be opened/closed independently.

  1"""
  2System tray application for Match Monitor (Windows).
  3
  4Launch via:  python -m host.match_monitor --tray
  5
  6__main__.py automatically re-launches under pythonw.exe so the originating
  7cmd/PowerShell window closes immediately and no console is left open.
  8
  9The "Open Console" tray menu item allocates a fresh Windows console window
 10(via AllocConsole) on demand.  "Close Console" frees it.  The console runs
 11the interactive monitor> prompt and can be opened/closed independently.
 12"""
 13
 14import ctypes
 15import logging
 16import subprocess
 17import sys
 18import threading
 19import time
 20from pathlib import Path
 21
 22logger = logging.getLogger("match_monitor")
 23
 24if hasattr(sys, '_MEIPASS'):
 25    # Running as a PyInstaller frozen exe — data files land in _MEIPASS
 26    _LOGO_PATH = Path(sys._MEIPASS) / 'images' / 'raptacongear.png'
 27else:
 28    _LOGO_PATH = Path(__file__).parent.parent.parent / 'images' / 'raptacongear.png'
 29
 30_STATUS_COLORS = {
 31    'idle':      '#808080',  # gray  — polling
 32    'connected': '#57F287',  # green — connected
 33    'uploading': '#FEE75C',  # yellow — uploading
 34    'error':     '#ED4245',  # red   — error
 35}
 36
 37_ICON_CACHE: dict = {}
 38
 39
 40def _make_icon(status: str, size: int = 64):
 41    """Raptacon gear logo with a small colored status badge (bottom-right)."""
 42    from PIL import Image, ImageDraw
 43
 44    color = _STATUS_COLORS.get(status, _STATUS_COLORS['idle'])
 45
 46    try:
 47        logo = Image.open(_LOGO_PATH).convert('RGBA').resize((size, size), Image.LANCZOS)
 48    except Exception:  # noqa: BLE001 — fall back to blank icon if logo missing/corrupt
 49        logo = Image.new('RGBA', (size, size), (0, 0, 0, 0))
 50        ImageDraw.Draw(logo).ellipse([4, 4, size - 4, size - 4], fill='#303030')
 51
 52    draw = ImageDraw.Draw(logo)
 53    r = size // 5
 54    x0, y0 = size - r * 2 - 2, size - r * 2 - 2
 55    draw.ellipse([x0, y0, size - 2, size - 2], fill=color, outline='white', width=1)
 56
 57    return logo
 58
 59
 60def _icon_for(status: str):
 61    if status not in _ICON_CACHE:
 62        _ICON_CACHE[status] = _make_icon(status)
 63    return _ICON_CACHE[status]
 64
 65
 66class MatchMonitorTray:
 67    """Wraps a ServerContext in a pystray system-tray icon."""
 68
 69    def __init__(self, ctx) -> None:
 70        import pystray
 71        self._pystray = pystray
 72        self._ctx = ctx
 73        self._stop_requested = False
 74
 75        # Console state — starts closed; opened on demand via AllocConsole()
 76        self._console_open = False
 77        self._console_hwnd = 0
 78        self._console_visible = False
 79        self._console_thread: threading.Thread | None = None
 80
 81        self._icon = pystray.Icon(
 82            'MatchMonitor',
 83            _icon_for('idle'),
 84            'Match Monitor\nPolling for robot...',
 85            menu=pystray.Menu(self._menu_items),
 86        )
 87
 88    # ------------------------------------------------------------------
 89    # Dynamic menu (rebuilt on each right-click)
 90    # ------------------------------------------------------------------
 91
 92    def _menu_items(self):
 93        pystray = self._pystray
 94        info = self._ctx.connector.status_info
 95        state = info['state']
 96        connected = self._ctx.connector.is_connected
 97
 98        uptime_str = ''
 99        if info['connected_since'] is not None:
100            uptime = time.monotonic() - info['connected_since']
101            mins, secs = divmod(int(uptime), 60)
102            hrs, mins = divmod(mins, 60)
103            uptime_str = f'  ({hrs}h {mins:02d}m {secs:02d}s)'
104
105        if not self._console_open:
106            console_item = pystray.MenuItem('Open Console', self._open_console)
107        elif self._console_visible:
108            console_item = pystray.MenuItem('Hide Console', self._hide_console)
109        else:
110            console_item = pystray.MenuItem('Show Console', self._show_console)
111
112        return (
113            pystray.MenuItem(f'● {state}{uptime_str}', None, enabled=False),
114            pystray.Menu.SEPARATOR,
115            pystray.MenuItem('Robot', pystray.Menu(
116                pystray.MenuItem('Force Upload',   self._force_upload,   enabled=connected),
117                pystray.MenuItem('Stop Upload',    self._stop_upload,    enabled=connected),
118                pystray.MenuItem('Clear Manifest', self._clear_manifest, enabled=connected),
119                pystray.MenuItem('List Logs',      self._list_logs,      enabled=connected),
120            )),
121            pystray.MenuItem('Open Log Folder', self._open_log_folder),
122            pystray.MenuItem('Set Code Directory...', self._set_code_dir),
123            pystray.Menu.SEPARATOR,
124            console_item,
125            pystray.Menu.SEPARATOR,
126            pystray.MenuItem('Quit', self._quit),
127        )
128
129    # ------------------------------------------------------------------
130    # Robot actions
131    # ------------------------------------------------------------------
132
133    def _force_upload(self):
134        self._ctx.connector.send_to_robot({'type': 'FORCE_UPLOAD'})
135
136    def _stop_upload(self):
137        self._ctx.connector.send_to_robot({'type': 'STOP_UPLOAD'})
138
139    def _clear_manifest(self):
140        self._ctx.connector.send_to_robot({'type': 'CLEAR_MANIFEST'})
141
142    def _list_logs(self):
143        self._ctx.connector.send_to_robot({'type': 'LIST_LOGS'})
144
145    def _open_log_folder(self):
146        subprocess.Popen(['explorer', str(self._ctx.out_path)])
147
148    def _set_code_dir(self):
149        """Open a folder picker dialog to set the robot code directory.
150
151        Runs the tkinter dialog in a subprocess because pystray callbacks
152        execute on a background thread, and tkinter requires the main thread.
153        """
154        import subprocess
155        import sys
156        from pathlib import Path
157        from .receiver import _save_config_key
158
159        initial = str(self._ctx.code_dir_holder[0]) if self._ctx.code_dir_holder[0] else ''
160        script = (
161            "import tkinter as tk; from tkinter.filedialog import askdirectory; "
162            "r=tk.Tk(); r.withdraw(); r.attributes('-topmost',True); "
163            f"print(askdirectory(title='Select Robot Code Directory', initialdir={initial!r}) or ''); "
164            "r.destroy()"
165        )
166        result = subprocess.run([sys.executable, '-c', script],
167                                capture_output=True, text=True)
168        chosen = result.stdout.strip()
169
170        if not chosen:
171            return
172
173        new_path = Path(chosen).resolve()
174        self._ctx.code_dir_holder[0] = new_path
175
176        notifier = self._ctx.discord_holder[0]
177        if notifier:
178            notifier._repo_root = new_path
179
180        _save_config_key(self._ctx.config_path, 'robot_code_dir', str(new_path))
181        is_git = (new_path / '.git').exists()
182        suffix = '' if is_git else ' (no .git)'
183        logger.info(f"Code directory set to: {new_path}{suffix}")
184
185    # ------------------------------------------------------------------
186    # Console management
187    # ------------------------------------------------------------------
188
189    def _open_console(self):
190        """Allocate a new Windows console window and start the monitor> prompt."""
191        if self._console_open:
192            self._show_console()
193            return
194
195        ctypes.windll.kernel32.AllocConsole()
196        ctypes.windll.kernel32.SetConsoleTitleW("Match Monitor Console")
197
198        # Redirect Python I/O to the new console
199        try:
200            sys.stdout = open('CONOUT$', 'w', buffering=1, encoding='utf-8',
201                              errors='replace')
202            sys.stderr = open('CONOUT$', 'w', buffering=1, encoding='utf-8',
203                              errors='replace')
204            sys.stdin = open('CONIN$', 'r', encoding='utf-8', errors='replace')
205        except OSError as e:
206            logger.warning(f"Could not redirect streams to console: {e}")
207            return
208
209        # Update the logging console handler to write to the new console
210        self._ctx.console_handler.stream = sys.stdout
211        self._ctx.console_handler.setLevel(logging.INFO)
212
213        self._console_hwnd = ctypes.windll.kernel32.GetConsoleWindow()
214        self._console_open = True
215        self._console_visible = True
216
217        # Disable the console close (X) button.
218        # CTRL_CLOSE_EVENT cannot be safely handled without Windows eventually
219        # terminating the process — disabling the button avoids the problem
220        # entirely.  Users close the console with 'exit' instead.
221        _SC_CLOSE = 0xF060
222        _MF_BYCOMMAND = 0
223        _hmenu = ctypes.windll.user32.GetSystemMenu(self._console_hwnd, False)
224        ctypes.windll.user32.DeleteMenu(_hmenu, _SC_CLOSE, _MF_BYCOMMAND)
225
226        # Start the interactive monitor> prompt in the console
227        from .receiver import _console_loop
228        self._console_thread = threading.Thread(
229            target=_console_loop,
230            args=(self._ctx.connector, self._ctx.console_handler,
231                  self._ctx.file_handler, self._ctx.log_dir,
232                  self._ctx.registry, self._ctx.discord_holder,
233                  self._ctx.config_path, self._ctx.code_dir_holder),
234            kwargs={'analyzer': self._ctx.analyzer,
235                    'out_path': self._ctx.out_path,
236                    'match_client': self._ctx.match_client,
237                    'quit_callback': self._quit,
238                    'close_callback': self._on_console_closed},
239            daemon=True,
240        )
241        self._console_thread.start()
242
243        print("Match Monitor Console — type 'help' for commands.")
244
245    def _on_console_closed(self):
246        """Free the Windows console and reset state so it can be re-opened.
247
248        Called after the console loop exits (exit command or stop-server).
249        Must be idempotent.
250        """
251        if not self._console_open:
252            return
253
254        self._console_open = False
255        self._console_visible = False
256        self._console_hwnd = 0
257        self._console_thread = None
258
259        # Redirect logging handler away from the (about-to-be-freed) console
260        import os
261        _devnull = open(os.devnull, 'w')
262        self._ctx.console_handler.stream = _devnull
263
264        # Close stdin first — this unblocks any pending input() in the console
265        # thread so it exits cleanly via EOFError/OSError.
266        try:
267            if sys.stdin is not None:
268                sys.stdin.close()
269            sys.stdin = None
270        except Exception:
271            logger.debug("Error closing stdin during console teardown", exc_info=True)
272
273        # Detach from the console — window closes (we are the only process).
274        ctypes.windll.kernel32.FreeConsole()
275
276        # Close stdout/stderr after detaching (handles are now invalid)
277        for attr in ('stdout', 'stderr'):
278            try:
279                s = getattr(sys, attr, None)
280                if s is not None:
281                    s.close()
282            except Exception:
283                logger.debug("Error closing %s during console teardown", attr, exc_info=True)
284            setattr(sys, attr, None)
285
286        # Force the tray menu to rebuild so "Open Console" replaces "Hide Console"
287        try:
288            self._icon.update_menu()
289        except Exception:
290            logger.debug("Error updating tray menu after console close", exc_info=True)
291
292    def _hide_console(self):
293        if self._console_hwnd:
294            ctypes.windll.user32.ShowWindow(self._console_hwnd, 0)  # SW_HIDE
295        self._console_visible = False
296
297    def _show_console(self):
298        if self._console_hwnd:
299            ctypes.windll.user32.ShowWindow(self._console_hwnd, 5)  # SW_SHOW
300            ctypes.windll.user32.SetForegroundWindow(self._console_hwnd)
301        self._console_visible = True
302
303    # ------------------------------------------------------------------
304    # Quit
305    # ------------------------------------------------------------------
306
307    def _quit(self):
308        """Stop the tray icon — cleanup runs in run() after icon.run() returns."""
309        self._stop_requested = True
310        self._icon.stop()
311
312    # ------------------------------------------------------------------
313    # Background update loop
314    # ------------------------------------------------------------------
315
316    def _update_loop(self) -> None:
317        while not self._stop_requested:
318            time.sleep(2)
319            try:
320                info = self._ctx.connector.status_info
321                state_str = info['state'].lower()
322
323                if 'uploading' in state_str:
324                    icon_state = 'uploading'
325                    tooltip = 'Match Monitor\nUploading logs...'
326                elif 'connected' in state_str:
327                    icon_state = 'connected'
328                    robot = info.get('robot_address') or ''
329                    uptime = ''
330                    if info['connected_since'] is not None:
331                        secs = int(time.monotonic() - info['connected_since'])
332                        m, s = divmod(secs, 60)
333                        h, m = divmod(m, 60)
334                        uptime = f'  {h}h {m:02d}m {s:02d}s'
335                    tooltip = f'Match Monitor\nConnected: {robot}{uptime}'
336                else:
337                    icon_state = 'idle'
338                    tooltip = 'Match Monitor\nPolling for robot...'
339
340                self._icon.icon = _icon_for(icon_state)
341                self._icon.title = tooltip
342            except Exception:
343                logger.debug("Error updating tray icon", exc_info=True)
344
345    # ------------------------------------------------------------------
346    # Entry point
347    # ------------------------------------------------------------------
348
349    def run(self) -> None:
350        """Start the HTTP server in background, run the tray on the main thread."""
351        threading.Thread(
352            target=self._ctx.server.serve_forever,
353            daemon=True,
354        ).start()
355
356        threading.Thread(target=self._update_loop, daemon=True).start()
357
358        logger.info("Match Monitor tray started")
359        try:
360            self._icon.run()  # blocks until _quit() calls icon.stop()
361        except KeyboardInterrupt:
362            logger.info("Keyboard interrupt received, shutting down")
363
364        # --- Cleanup after tray icon stops ---
365        self._stop_requested = True
366        self._ctx.connector.stop()
367        logger.info("Match Monitor tray shutting down")
368        threading.Thread(target=self._ctx.server.shutdown, daemon=True).start()
369        sys.exit(0)
370
371
372def run_server_tray(bind: str, port: int, output_dir: str = None,
373                    debug: bool = False) -> None:
374    """Set up the server and run it as a system tray application."""
375    from .receiver import setup_server
376
377    ctx = setup_server(bind, port, output_dir, debug)
378    logger.info(f"Match Monitor tray on {bind}:{port}, saving to {ctx.out_path}")
379
380    MatchMonitorTray(ctx).run()
logger = <Logger match_monitor (DEBUG)>
class MatchMonitorTray:
 67class MatchMonitorTray:
 68    """Wraps a ServerContext in a pystray system-tray icon."""
 69
 70    def __init__(self, ctx) -> None:
 71        import pystray
 72        self._pystray = pystray
 73        self._ctx = ctx
 74        self._stop_requested = False
 75
 76        # Console state — starts closed; opened on demand via AllocConsole()
 77        self._console_open = False
 78        self._console_hwnd = 0
 79        self._console_visible = False
 80        self._console_thread: threading.Thread | None = None
 81
 82        self._icon = pystray.Icon(
 83            'MatchMonitor',
 84            _icon_for('idle'),
 85            'Match Monitor\nPolling for robot...',
 86            menu=pystray.Menu(self._menu_items),
 87        )
 88
 89    # ------------------------------------------------------------------
 90    # Dynamic menu (rebuilt on each right-click)
 91    # ------------------------------------------------------------------
 92
 93    def _menu_items(self):
 94        pystray = self._pystray
 95        info = self._ctx.connector.status_info
 96        state = info['state']
 97        connected = self._ctx.connector.is_connected
 98
 99        uptime_str = ''
100        if info['connected_since'] is not None:
101            uptime = time.monotonic() - info['connected_since']
102            mins, secs = divmod(int(uptime), 60)
103            hrs, mins = divmod(mins, 60)
104            uptime_str = f'  ({hrs}h {mins:02d}m {secs:02d}s)'
105
106        if not self._console_open:
107            console_item = pystray.MenuItem('Open Console', self._open_console)
108        elif self._console_visible:
109            console_item = pystray.MenuItem('Hide Console', self._hide_console)
110        else:
111            console_item = pystray.MenuItem('Show Console', self._show_console)
112
113        return (
114            pystray.MenuItem(f'● {state}{uptime_str}', None, enabled=False),
115            pystray.Menu.SEPARATOR,
116            pystray.MenuItem('Robot', pystray.Menu(
117                pystray.MenuItem('Force Upload',   self._force_upload,   enabled=connected),
118                pystray.MenuItem('Stop Upload',    self._stop_upload,    enabled=connected),
119                pystray.MenuItem('Clear Manifest', self._clear_manifest, enabled=connected),
120                pystray.MenuItem('List Logs',      self._list_logs,      enabled=connected),
121            )),
122            pystray.MenuItem('Open Log Folder', self._open_log_folder),
123            pystray.MenuItem('Set Code Directory...', self._set_code_dir),
124            pystray.Menu.SEPARATOR,
125            console_item,
126            pystray.Menu.SEPARATOR,
127            pystray.MenuItem('Quit', self._quit),
128        )
129
130    # ------------------------------------------------------------------
131    # Robot actions
132    # ------------------------------------------------------------------
133
134    def _force_upload(self):
135        self._ctx.connector.send_to_robot({'type': 'FORCE_UPLOAD'})
136
137    def _stop_upload(self):
138        self._ctx.connector.send_to_robot({'type': 'STOP_UPLOAD'})
139
140    def _clear_manifest(self):
141        self._ctx.connector.send_to_robot({'type': 'CLEAR_MANIFEST'})
142
143    def _list_logs(self):
144        self._ctx.connector.send_to_robot({'type': 'LIST_LOGS'})
145
146    def _open_log_folder(self):
147        subprocess.Popen(['explorer', str(self._ctx.out_path)])
148
149    def _set_code_dir(self):
150        """Open a folder picker dialog to set the robot code directory.
151
152        Runs the tkinter dialog in a subprocess because pystray callbacks
153        execute on a background thread, and tkinter requires the main thread.
154        """
155        import subprocess
156        import sys
157        from pathlib import Path
158        from .receiver import _save_config_key
159
160        initial = str(self._ctx.code_dir_holder[0]) if self._ctx.code_dir_holder[0] else ''
161        script = (
162            "import tkinter as tk; from tkinter.filedialog import askdirectory; "
163            "r=tk.Tk(); r.withdraw(); r.attributes('-topmost',True); "
164            f"print(askdirectory(title='Select Robot Code Directory', initialdir={initial!r}) or ''); "
165            "r.destroy()"
166        )
167        result = subprocess.run([sys.executable, '-c', script],
168                                capture_output=True, text=True)
169        chosen = result.stdout.strip()
170
171        if not chosen:
172            return
173
174        new_path = Path(chosen).resolve()
175        self._ctx.code_dir_holder[0] = new_path
176
177        notifier = self._ctx.discord_holder[0]
178        if notifier:
179            notifier._repo_root = new_path
180
181        _save_config_key(self._ctx.config_path, 'robot_code_dir', str(new_path))
182        is_git = (new_path / '.git').exists()
183        suffix = '' if is_git else ' (no .git)'
184        logger.info(f"Code directory set to: {new_path}{suffix}")
185
186    # ------------------------------------------------------------------
187    # Console management
188    # ------------------------------------------------------------------
189
190    def _open_console(self):
191        """Allocate a new Windows console window and start the monitor> prompt."""
192        if self._console_open:
193            self._show_console()
194            return
195
196        ctypes.windll.kernel32.AllocConsole()
197        ctypes.windll.kernel32.SetConsoleTitleW("Match Monitor Console")
198
199        # Redirect Python I/O to the new console
200        try:
201            sys.stdout = open('CONOUT$', 'w', buffering=1, encoding='utf-8',
202                              errors='replace')
203            sys.stderr = open('CONOUT$', 'w', buffering=1, encoding='utf-8',
204                              errors='replace')
205            sys.stdin = open('CONIN$', 'r', encoding='utf-8', errors='replace')
206        except OSError as e:
207            logger.warning(f"Could not redirect streams to console: {e}")
208            return
209
210        # Update the logging console handler to write to the new console
211        self._ctx.console_handler.stream = sys.stdout
212        self._ctx.console_handler.setLevel(logging.INFO)
213
214        self._console_hwnd = ctypes.windll.kernel32.GetConsoleWindow()
215        self._console_open = True
216        self._console_visible = True
217
218        # Disable the console close (X) button.
219        # CTRL_CLOSE_EVENT cannot be safely handled without Windows eventually
220        # terminating the process — disabling the button avoids the problem
221        # entirely.  Users close the console with 'exit' instead.
222        _SC_CLOSE = 0xF060
223        _MF_BYCOMMAND = 0
224        _hmenu = ctypes.windll.user32.GetSystemMenu(self._console_hwnd, False)
225        ctypes.windll.user32.DeleteMenu(_hmenu, _SC_CLOSE, _MF_BYCOMMAND)
226
227        # Start the interactive monitor> prompt in the console
228        from .receiver import _console_loop
229        self._console_thread = threading.Thread(
230            target=_console_loop,
231            args=(self._ctx.connector, self._ctx.console_handler,
232                  self._ctx.file_handler, self._ctx.log_dir,
233                  self._ctx.registry, self._ctx.discord_holder,
234                  self._ctx.config_path, self._ctx.code_dir_holder),
235            kwargs={'analyzer': self._ctx.analyzer,
236                    'out_path': self._ctx.out_path,
237                    'match_client': self._ctx.match_client,
238                    'quit_callback': self._quit,
239                    'close_callback': self._on_console_closed},
240            daemon=True,
241        )
242        self._console_thread.start()
243
244        print("Match Monitor Console — type 'help' for commands.")
245
246    def _on_console_closed(self):
247        """Free the Windows console and reset state so it can be re-opened.
248
249        Called after the console loop exits (exit command or stop-server).
250        Must be idempotent.
251        """
252        if not self._console_open:
253            return
254
255        self._console_open = False
256        self._console_visible = False
257        self._console_hwnd = 0
258        self._console_thread = None
259
260        # Redirect logging handler away from the (about-to-be-freed) console
261        import os
262        _devnull = open(os.devnull, 'w')
263        self._ctx.console_handler.stream = _devnull
264
265        # Close stdin first — this unblocks any pending input() in the console
266        # thread so it exits cleanly via EOFError/OSError.
267        try:
268            if sys.stdin is not None:
269                sys.stdin.close()
270            sys.stdin = None
271        except Exception:
272            logger.debug("Error closing stdin during console teardown", exc_info=True)
273
274        # Detach from the console — window closes (we are the only process).
275        ctypes.windll.kernel32.FreeConsole()
276
277        # Close stdout/stderr after detaching (handles are now invalid)
278        for attr in ('stdout', 'stderr'):
279            try:
280                s = getattr(sys, attr, None)
281                if s is not None:
282                    s.close()
283            except Exception:
284                logger.debug("Error closing %s during console teardown", attr, exc_info=True)
285            setattr(sys, attr, None)
286
287        # Force the tray menu to rebuild so "Open Console" replaces "Hide Console"
288        try:
289            self._icon.update_menu()
290        except Exception:
291            logger.debug("Error updating tray menu after console close", exc_info=True)
292
293    def _hide_console(self):
294        if self._console_hwnd:
295            ctypes.windll.user32.ShowWindow(self._console_hwnd, 0)  # SW_HIDE
296        self._console_visible = False
297
298    def _show_console(self):
299        if self._console_hwnd:
300            ctypes.windll.user32.ShowWindow(self._console_hwnd, 5)  # SW_SHOW
301            ctypes.windll.user32.SetForegroundWindow(self._console_hwnd)
302        self._console_visible = True
303
304    # ------------------------------------------------------------------
305    # Quit
306    # ------------------------------------------------------------------
307
308    def _quit(self):
309        """Stop the tray icon — cleanup runs in run() after icon.run() returns."""
310        self._stop_requested = True
311        self._icon.stop()
312
313    # ------------------------------------------------------------------
314    # Background update loop
315    # ------------------------------------------------------------------
316
317    def _update_loop(self) -> None:
318        while not self._stop_requested:
319            time.sleep(2)
320            try:
321                info = self._ctx.connector.status_info
322                state_str = info['state'].lower()
323
324                if 'uploading' in state_str:
325                    icon_state = 'uploading'
326                    tooltip = 'Match Monitor\nUploading logs...'
327                elif 'connected' in state_str:
328                    icon_state = 'connected'
329                    robot = info.get('robot_address') or ''
330                    uptime = ''
331                    if info['connected_since'] is not None:
332                        secs = int(time.monotonic() - info['connected_since'])
333                        m, s = divmod(secs, 60)
334                        h, m = divmod(m, 60)
335                        uptime = f'  {h}h {m:02d}m {s:02d}s'
336                    tooltip = f'Match Monitor\nConnected: {robot}{uptime}'
337                else:
338                    icon_state = 'idle'
339                    tooltip = 'Match Monitor\nPolling for robot...'
340
341                self._icon.icon = _icon_for(icon_state)
342                self._icon.title = tooltip
343            except Exception:
344                logger.debug("Error updating tray icon", exc_info=True)
345
346    # ------------------------------------------------------------------
347    # Entry point
348    # ------------------------------------------------------------------
349
350    def run(self) -> None:
351        """Start the HTTP server in background, run the tray on the main thread."""
352        threading.Thread(
353            target=self._ctx.server.serve_forever,
354            daemon=True,
355        ).start()
356
357        threading.Thread(target=self._update_loop, daemon=True).start()
358
359        logger.info("Match Monitor tray started")
360        try:
361            self._icon.run()  # blocks until _quit() calls icon.stop()
362        except KeyboardInterrupt:
363            logger.info("Keyboard interrupt received, shutting down")
364
365        # --- Cleanup after tray icon stops ---
366        self._stop_requested = True
367        self._ctx.connector.stop()
368        logger.info("Match Monitor tray shutting down")
369        threading.Thread(target=self._ctx.server.shutdown, daemon=True).start()
370        sys.exit(0)

Wraps a ServerContext in a pystray system-tray icon.

MatchMonitorTray(ctx)
70    def __init__(self, ctx) -> None:
71        import pystray
72        self._pystray = pystray
73        self._ctx = ctx
74        self._stop_requested = False
75
76        # Console state — starts closed; opened on demand via AllocConsole()
77        self._console_open = False
78        self._console_hwnd = 0
79        self._console_visible = False
80        self._console_thread: threading.Thread | None = None
81
82        self._icon = pystray.Icon(
83            'MatchMonitor',
84            _icon_for('idle'),
85            'Match Monitor\nPolling for robot...',
86            menu=pystray.Menu(self._menu_items),
87        )
def run(self) -> None:
350    def run(self) -> None:
351        """Start the HTTP server in background, run the tray on the main thread."""
352        threading.Thread(
353            target=self._ctx.server.serve_forever,
354            daemon=True,
355        ).start()
356
357        threading.Thread(target=self._update_loop, daemon=True).start()
358
359        logger.info("Match Monitor tray started")
360        try:
361            self._icon.run()  # blocks until _quit() calls icon.stop()
362        except KeyboardInterrupt:
363            logger.info("Keyboard interrupt received, shutting down")
364
365        # --- Cleanup after tray icon stops ---
366        self._stop_requested = True
367        self._ctx.connector.stop()
368        logger.info("Match Monitor tray shutting down")
369        threading.Thread(target=self._ctx.server.shutdown, daemon=True).start()
370        sys.exit(0)

Start the HTTP server in background, run the tray on the main thread.

def run_server_tray( bind: str, port: int, output_dir: str = None, debug: bool = False) -> None:
373def run_server_tray(bind: str, port: int, output_dir: str = None,
374                    debug: bool = False) -> None:
375    """Set up the server and run it as a system tray application."""
376    from .receiver import setup_server
377
378    ctx = setup_server(bind, port, output_dir, debug)
379    logger.info(f"Match Monitor tray on {bind}:{port}, saving to {ctx.out_path}")
380
381    MatchMonitorTray(ctx).run()

Set up the server and run it as a system tray application.