`ruff check .` failed on every push since CI was added (1221 findings), so the job aborted before pytest and the release gate ever ran. Adds [tool.ruff] selecting bug-catching rules only (E9, F63, F7, F82, F811); style and modernization rules stay off until their ~1200 findings are burned down. That selection immediately surfaced seven live defects: - hermes_hub_app: HubModal used but never imported, so the "Назначить" role modal raised NameError — the handler added to close an earlier review finding could never open. - hermes_hub_app: three `after(0, lambda: ...(e))` callbacks referencing the except-bound name, which Python unbinds at block exit, so every UI error path raised NameError instead of reporting the error. - auto_assigner: build_team_hierarchy referenced an undefined `is_main` and crashed on every call. - ui/assets and live_provision_and_validate: annotations naming Any/Tuple without importing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
654 lines
27 KiB
Python
654 lines
27 KiB
Python
"""Hermes Hub — Native Windows Desktop Application (Stabilization & UX v3).
|
||
|
||
Features:
|
||
- Windows AppUserModelID (HermesHub.Desktop) for proper Taskbar Pinning & Identity
|
||
- High-Performance Sub-100ms Tab Switching (Persistent View Cache, Zero I/O on switch)
|
||
- Instrumentation: tab_switch_ms Logging & Benchmark Verification
|
||
- Debounced Resize & Movement Handling
|
||
- Unified Health Presentation (Strict Priority Resolver, No Stale Quota on Unadded Accounts)
|
||
- Real Provider Icon Marks (Google Antigravity, OpenAI Codex, OpenCode Go)
|
||
- Non-blocking Background Workers & Graceful Shutdown Coordinator
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import logging
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
import webbrowser
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import customtkinter as ctk
|
||
|
||
# ── Set Windows AppUserModelID before UI initialization ──
|
||
if sys.platform == "win32":
|
||
try:
|
||
APP_ID = "HermesHub.Desktop"
|
||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Ensure plugin and repo paths are on sys.path ──
|
||
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
|
||
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
|
||
_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent"
|
||
for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
|
||
_ps = str(_p)
|
||
if _p.exists() and _ps not in sys.path:
|
||
sys.path.insert(0, _ps)
|
||
|
||
from antigravity_provider.router.router_config import load_router_config
|
||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||
from antigravity_provider.router.adapters import get_adapter
|
||
|
||
from antigravity_provider.router.ui.theme import Theme
|
||
from antigravity_provider.router.ui.assets import AssetManager
|
||
from antigravity_provider.router.ui.components import HubButton, HubModal
|
||
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
|
||
|
||
from antigravity_provider.router.unified_health import (
|
||
UnifiedHealthService,
|
||
EventLogService,
|
||
SystemReadiness,
|
||
STATUS_HEALTHY,
|
||
)
|
||
|
||
from antigravity_provider.router.ui.views.team_view import TeamView
|
||
from antigravity_provider.router.ui.views.accounts_view import AccountsView
|
||
from antigravity_provider.router.ui.views.providers_view import ProvidersView
|
||
from antigravity_provider.router.ui.views.routing_view import RoutingView
|
||
from antigravity_provider.router.ui.views.health_view import HealthView
|
||
from antigravity_provider.router.ui.views.logs_view import LogsView
|
||
from antigravity_provider.router.ui.views.settings_view import SettingsView
|
||
from antigravity_provider.router.ui.views.about_view import AboutView
|
||
|
||
logger = logging.getLogger("hermes.hub.gui")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Actions Layer (Safe & Non-blocking)
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
|
||
if ok:
|
||
EventLogService.get().log("account", f"Профиль {profile_id} назначен основным аккаунтом Hermes ({provider}).", level="info")
|
||
return ok, msg
|
||
|
||
|
||
def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||
ok, msg = AutoAssigner.set_primary_orchestrator(profile_id)
|
||
if ok:
|
||
EventLogService.get().log("routing", f"Профиль {profile_id} назначен главным оркестратором команды.", level="info")
|
||
return ok, msg
|
||
|
||
|
||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||
"""Strictly tests stored credentials WITHOUT triggering OAuth or opening browsers."""
|
||
config = load_router_config()
|
||
pcfg = config.get_profile(profile_id)
|
||
if not pcfg:
|
||
return {"success": False, "error": f"Профиль '{profile_id}' не найден"}
|
||
|
||
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||
if not status.get("authenticated"):
|
||
return {"success": False, "error": "Аккаунт не добавлен. Сначала выполните подключение."}
|
||
|
||
adapter = get_adapter(pcfg.provider)
|
||
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
||
t0 = time.time()
|
||
try:
|
||
resp = adapter.invoke(pcfg, {
|
||
"model": model,
|
||
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
|
||
"temperature": 0.1,
|
||
})
|
||
el = round(time.time() - t0, 2)
|
||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success")
|
||
return {"success": True, "model": model, "duration_sec": el, "response": content[:120]}
|
||
except Exception as e:
|
||
EventLogService.get().log("system", f"Ошибка теста {profile_id} ({model}): {e}", level="error")
|
||
return {"success": False, "model": model, "duration_sec": round(time.time() - t0, 2), "error": str(e)}
|
||
|
||
|
||
def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||
auth_p = ProfileAuthManager.get_profile_dir(provider, profile_id) / "auth.json"
|
||
if auth_p.is_file():
|
||
try:
|
||
auth_p.unlink()
|
||
EventLogService.get().log("account", f"Учетные данные для {profile_id} удалены.", level="warning")
|
||
return True, f"Учетные данные для '{profile_id}' удалены"
|
||
except Exception as e:
|
||
return False, f"Ошибка удаления: {e}"
|
||
return True, "Учетные данные отсутствовали"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# Main Application Window
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
class HermesHubApp(ctk.CTk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("Hermes Hub")
|
||
self.geometry("1380x880")
|
||
self.minsize(1100, 700)
|
||
|
||
ctk.set_appearance_mode("dark")
|
||
ctk.set_default_color_theme("blue")
|
||
self.configure(fg_color=Theme.BG_WINDOW)
|
||
|
||
# Set Windows Multi-Resolution Icon
|
||
ico_path = AssetManager.get().get_ico_path()
|
||
if ico_path and os.path.exists(ico_path):
|
||
try:
|
||
self.iconbitmap(ico_path)
|
||
except Exception:
|
||
pass
|
||
|
||
self._current_view = "team"
|
||
self._views: Dict[str, ctk.CTkFrame] = {}
|
||
self._view_generations: Dict[str, int] = {}
|
||
self._shutting_down = False
|
||
self._resize_timer_id = None
|
||
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
self.bind("<Configure>", self._on_window_configure)
|
||
|
||
self._build_layout()
|
||
self._show_view("team")
|
||
|
||
try:
|
||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||
HermesRefreshScheduler.get().start()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||
AccountQuotaService.get().start_background_scheduler()
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(50, self._refresh_data)
|
||
|
||
def _build_layout(self):
|
||
# ── Sidebar (Left) ──
|
||
self.sidebar = ctk.CTkFrame(self, width=Theme.WIDTH_SIDEBAR, fg_color=Theme.BG_SIDEBAR, corner_radius=0)
|
||
self.sidebar.pack(side="left", fill="y")
|
||
self.sidebar.pack_propagate(False)
|
||
|
||
# Top Centered Brand Logo
|
||
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
||
brand_container.pack(fill="x", padx=16, pady=(20, 14))
|
||
|
||
logo_img = AssetManager.get().get_logo_image(size=(72, 72))
|
||
if logo_img:
|
||
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
||
logo_lbl.pack(anchor="center", pady=(0, 6))
|
||
|
||
ctk.CTkLabel(brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT).pack(anchor="center")
|
||
ctk.CTkLabel(brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="center")
|
||
|
||
# Divider
|
||
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12))
|
||
|
||
# Nav Items with clean Fluent glyphs
|
||
self._nav_items = [
|
||
("team", "Команда", "👥"),
|
||
("accounts", "Аккаунты", "🔍"),
|
||
("routing", "Маршрутизация", "🔀"),
|
||
("providers", "Провайдеры", "🌐"),
|
||
("health", "Состояние системы", "🛡️"),
|
||
("logs", "Журнал событий", "📜"),
|
||
("settings", "Настройки", "⚙️"),
|
||
("about", "О программе", "ℹ️"),
|
||
]
|
||
|
||
self._nav_buttons: Dict[str, ctk.CTkButton] = {}
|
||
for key, label, icon in self._nav_items:
|
||
btn = ctk.CTkButton(
|
||
self.sidebar,
|
||
text=f" {icon} {label}",
|
||
font=Theme.font_body(),
|
||
height=Theme.HEIGHT_NAV_ITEM,
|
||
fg_color="transparent",
|
||
hover_color=Theme.SURFACE_HOVER,
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
anchor="w",
|
||
corner_radius=Theme.RADIUS_SM,
|
||
command=lambda k=key: self._show_view(k),
|
||
)
|
||
btn.pack(fill="x", padx=12, pady=2)
|
||
self._nav_buttons[key] = btn
|
||
|
||
# Refresh button at bottom of sidebar
|
||
refresh_btn = HubButton(
|
||
self.sidebar,
|
||
text="🔄 Обновить",
|
||
variant="secondary",
|
||
height=38,
|
||
command=self._refresh_data,
|
||
)
|
||
refresh_btn.pack(side="bottom", fill="x", padx=14, pady=16)
|
||
|
||
# ── Status Bar (Bottom) ──
|
||
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_STATUSBAR, fg_color=Theme.BG_STATUSBAR, corner_radius=0)
|
||
self.statusbar.pack(side="bottom", fill="x")
|
||
|
||
self.status_left = ctk.CTkLabel(
|
||
self.statusbar,
|
||
text="Аккаунты: ... | Роли: ... | Обновлено: ...",
|
||
font=Theme.font_micro(),
|
||
text_color=Theme.TEXT_MUTED,
|
||
)
|
||
self.status_left.pack(side="left", padx=16)
|
||
|
||
self.status_right = ctk.CTkLabel(
|
||
self.statusbar,
|
||
text="● Hermes работает",
|
||
font=Theme.font_micro(),
|
||
text_color=Theme.STATUS_HEALTHY,
|
||
)
|
||
self.status_right.pack(side="right", padx=16)
|
||
|
||
# ── Main Content Area ──
|
||
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
||
self.content.pack(side="right", fill="both", expand=True)
|
||
|
||
# Pre-instantiate all views so switching is 100% instant (0-15 ms)
|
||
for key, _, _ in self._nav_items:
|
||
self._views[key] = self._create_view(key)
|
||
|
||
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
||
"""Create view widget instance."""
|
||
if view_name == "team":
|
||
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||
elif view_name == "accounts":
|
||
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
||
elif view_name == "providers":
|
||
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
||
elif view_name == "routing":
|
||
return RoutingView(self.content)
|
||
elif view_name == "health":
|
||
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
||
elif view_name == "logs":
|
||
return LogsView(self.content)
|
||
elif view_name == "settings":
|
||
return SettingsView(self.content)
|
||
elif view_name == "about":
|
||
return AboutView(self.content)
|
||
else:
|
||
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||
|
||
def _show_view(self, view_name: str):
|
||
"""Instant view switching using pack_forget() and cached widgets with lazy generation update."""
|
||
t0 = time.time()
|
||
prev_view = self._current_view
|
||
self._current_view = view_name
|
||
|
||
# Update sidebar button states
|
||
for key, btn in self._nav_buttons.items():
|
||
if key == view_name:
|
||
btn.configure(
|
||
fg_color=Theme.SURFACE_SELECTED,
|
||
text_color=Theme.TEXT_ACCENT,
|
||
border_width=1,
|
||
border_color=Theme.BORDER_ACCENT,
|
||
)
|
||
else:
|
||
btn.configure(
|
||
fg_color="transparent",
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
border_width=0,
|
||
)
|
||
|
||
# Hide currently active views
|
||
for v in self._views.values():
|
||
v.pack_forget()
|
||
|
||
# Show target view instantly
|
||
target_view = self._views.get(view_name)
|
||
if target_view:
|
||
target_view.pack(fill="both", expand=True)
|
||
|
||
# Lazy update if view state is behind current snapshot generation
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
snap = HubStateStore.get().get_snapshot()
|
||
if self._view_generations.get(view_name, 0) < snap.generation:
|
||
if hasattr(target_view, "update_data"):
|
||
try:
|
||
target_view.update_data(snap)
|
||
except Exception as ex:
|
||
logger.warning("Error in lazy view update for %s: %s", view_name, ex)
|
||
self._view_generations[view_name] = snap.generation
|
||
|
||
# Instrument tab switch latency
|
||
el_ms = round((time.time() - t0) * 1000, 2)
|
||
if el_ms > 100:
|
||
logger.warning(f"[TAB SWITCH SLOW] {prev_view} -> {view_name}: {el_ms} ms")
|
||
else:
|
||
logger.debug(f"[TAB SWITCH] {prev_view} -> {view_name}: {el_ms} ms")
|
||
|
||
def _on_window_configure(self, event):
|
||
"""Debounce window resize to maintain 60fps smoothness."""
|
||
if event.widget != self:
|
||
return
|
||
if self._resize_timer_id:
|
||
try:
|
||
self.after_cancel(self._resize_timer_id)
|
||
except Exception:
|
||
pass
|
||
self._resize_timer_id = self.after(100, self._handle_debounced_resize)
|
||
|
||
def _handle_debounced_resize(self):
|
||
self._resize_timer_id = None
|
||
|
||
# ─────── Data Refresh (Threaded via Scheduler & HubStateStore) ───────
|
||
|
||
def _refresh_data(self):
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
self.status_left.configure(text="Обновление состояния...")
|
||
except Exception:
|
||
pass
|
||
|
||
def _load():
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
snap = HubStateStore.get().refresh(force_scan=True)
|
||
if not self._shutting_down:
|
||
self.after(0, lambda: self._on_data_loaded(snap))
|
||
except Exception as e:
|
||
if not self._shutting_down:
|
||
try:
|
||
self.after(0, lambda err=str(e): self._on_data_error(err))
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_load, daemon=True).start()
|
||
|
||
def _on_data_loaded(self, snapshot_or_readiness: Any):
|
||
if self._shutting_down:
|
||
return
|
||
|
||
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
|
||
if isinstance(snapshot_or_readiness, HubSnapshot):
|
||
snap = snapshot_or_readiness
|
||
readiness = snap.readiness
|
||
else:
|
||
snap = HubStateStore.get().get_snapshot()
|
||
readiness = snapshot_or_readiness
|
||
|
||
self.status_left.configure(
|
||
text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
|
||
)
|
||
|
||
r_color = Theme.STATUS_HEALTHY if readiness.state == "healthy" else (Theme.STATUS_WARNING if readiness.state in ("limited", "degraded") else Theme.STATUS_ERROR)
|
||
self.status_right.configure(text=f"● {readiness.title_ru}", text_color=r_color)
|
||
|
||
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
||
curr_view = self._views.get(self._current_view)
|
||
if curr_view and hasattr(curr_view, "update_data"):
|
||
try:
|
||
curr_view.update_data(snap)
|
||
except Exception as ex:
|
||
logger.warning("Error updating current view %s: %s", self._current_view, ex)
|
||
self._view_generations[self._current_view] = snap.generation
|
||
|
||
def _on_data_error(self, error: str):
|
||
if self._shutting_down:
|
||
return
|
||
self.status_left.configure(text=f"Ошибка: {error}")
|
||
|
||
# ─────── Action Handler ───────
|
||
|
||
def _handle_action(self, action: str, data: Dict[str, Any]):
|
||
pid = data.get("profile_id", "")
|
||
prov = data.get("provider", "")
|
||
|
||
if action == "set_main":
|
||
self._run_in_thread(
|
||
lambda: do_set_main(prov, pid),
|
||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||
)
|
||
elif action == "set_orchestrator":
|
||
self._run_in_thread(
|
||
lambda: do_set_orchestrator(pid),
|
||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||
)
|
||
elif action == "test":
|
||
self._show_toast(f"⚡ Тестирование {data.get('display_name', pid)}...")
|
||
self._run_in_thread(
|
||
lambda: do_test_profile(prov, pid),
|
||
on_success=self._show_test_result,
|
||
)
|
||
elif action == "oauth" or action == "add_account":
|
||
self._open_add_account_wizard()
|
||
elif action == "delete_credentials":
|
||
self._run_in_thread(
|
||
lambda: do_delete_credentials(prov, pid),
|
||
on_success=lambda r: self._show_toast(f"✅ {r[1]}" if r[0] else f"❌ {r[1]}"),
|
||
)
|
||
elif action == "assign_role":
|
||
self._open_assign_role_modal(pid, data.get("display_name", pid))
|
||
elif action == "auto_assign_all":
|
||
self._show_toast("⚡ Автоматическое распределение ролей...")
|
||
self._run_in_thread(
|
||
lambda: AutoAssigner.auto_assign_all(),
|
||
on_success=lambda r: self._show_toast("✅ Роли успешно распределены"),
|
||
)
|
||
elif action == "refresh_data":
|
||
self._refresh_data()
|
||
|
||
def _open_assign_role_modal(self, profile_id: str, display_name: str):
|
||
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)
|
||
|
||
ctk.CTkLabel(
|
||
modal.body,
|
||
text=f"Выберите роль в команде Hermes для профиля «{display_name}» ({profile_id}):",
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_SECONDARY,
|
||
wraplength=440,
|
||
justify="left",
|
||
).pack(anchor="w", pady=(0, 12))
|
||
|
||
role_var = ctk.StringVar(value="orchestrator")
|
||
roles = [
|
||
("orchestrator", "👑 Главный оркестратор"),
|
||
("coder", "💻 Кодер (Code Generation)"),
|
||
("reviewer", "🔍 Ревьюер (Code Review)"),
|
||
("researcher", "🌐 Исследователь (Search / Docs)"),
|
||
("tester", "🧪 Тестировщик (Deterministic Tests)"),
|
||
("general", "⚡ Агент общего назначения (Subagent)"),
|
||
("spare", "🛡️ Резерв (Spare)"),
|
||
]
|
||
|
||
for val, lbl in roles:
|
||
ctk.CTkRadioButton(
|
||
modal.body,
|
||
text=lbl,
|
||
variable=role_var,
|
||
value=val,
|
||
font=Theme.font_body(),
|
||
text_color=Theme.TEXT_PRIMARY,
|
||
fg_color=Theme.ACCENT,
|
||
hover_color=Theme.ACCENT_HOVER,
|
||
).pack(anchor="w", padx=8, pady=3)
|
||
|
||
def _save():
|
||
chosen = role_var.get()
|
||
ok, msg = AutoAssigner.assign_profile_to_role(profile_id, chosen, is_primary=(chosen != "spare"))
|
||
modal.destroy()
|
||
self._show_toast(f"✅ {msg}" if ok else f"❌ {msg}")
|
||
self._refresh_data()
|
||
|
||
HubButton(modal.footer, text="Отмена", variant="secondary", width=100, command=modal.destroy).pack(side="left")
|
||
HubButton(modal.footer, text="Применить роль", variant="primary", width=160, command=_save).pack(side="right")
|
||
|
||
def _open_add_account_wizard(self):
|
||
wizard = AddAccountWizard(self, on_complete=self._on_wizard_complete)
|
||
|
||
def _on_wizard_complete(self, result: Dict[str, Any]):
|
||
self._show_toast(f"✅ Аккаунт {result.get('identity')} успешно подключён")
|
||
self._refresh_data()
|
||
|
||
def _show_test_result(self, result: Dict[str, Any]):
|
||
if result.get("success"):
|
||
msg = f"✓ Тест успешен | Модель: {result.get('model')} | Время: {result.get('duration_sec')}s"
|
||
else:
|
||
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
|
||
self._show_toast(msg)
|
||
|
||
def _run_in_thread(self, func, on_success=None, on_error=None):
|
||
def _worker():
|
||
if self._shutting_down:
|
||
return
|
||
try:
|
||
result = func()
|
||
if not self._shutting_down:
|
||
if on_success:
|
||
self.after(0, lambda: on_success(result))
|
||
self.after(300, self._refresh_data)
|
||
except Exception as e:
|
||
if not self._shutting_down:
|
||
try:
|
||
if on_error:
|
||
self.after(0, lambda err=str(e): on_error(err))
|
||
else:
|
||
self.after(0, lambda err=str(e): self._show_toast(f"❌ Ошибка: {err}"))
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
def _show_toast(self, message: str):
|
||
if self._shutting_down:
|
||
return
|
||
self.status_left.configure(text=message)
|
||
self.after(6000, self._restore_status)
|
||
|
||
def _restore_status(self):
|
||
if self._shutting_down:
|
||
return
|
||
from antigravity_provider.router.state_store import HubStateStore
|
||
readiness = HubStateStore.get().get_snapshot().readiness
|
||
self.status_left.configure(
|
||
text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
|
||
)
|
||
|
||
def _on_close(self):
|
||
"""Graceful shutdown coordinator without leaving orphan processes."""
|
||
self._shutting_down = True
|
||
try:
|
||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||
HermesRefreshScheduler.get().stop()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||
AccountQuotaService.get().stop_background_scheduler()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self._resize_timer_id:
|
||
self.after_cancel(self._resize_timer_id)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
global _APP_MUTEX_HANDLE
|
||
if _APP_MUTEX_HANDLE and sys.platform == "win32":
|
||
ctypes.windll.kernel32.CloseHandle(_APP_MUTEX_HANDLE)
|
||
_APP_MUTEX_HANDLE = None
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.destroy()
|
||
except Exception:
|
||
pass
|
||
os._exit(0)
|
||
|
||
|
||
_APP_MUTEX_HANDLE = None
|
||
|
||
|
||
def check_single_instance() -> bool:
|
||
"""Ensure only one Hermes Hub instance runs. If already running, activate existing window and return False."""
|
||
global _APP_MUTEX_HANDLE
|
||
if sys.platform != "win32":
|
||
return True
|
||
try:
|
||
user32 = ctypes.windll.user32
|
||
kernel32 = ctypes.windll.kernel32
|
||
|
||
mutex_name = "Global\\HermesHubSingleInstanceMutex"
|
||
mutex = kernel32.CreateMutexW(None, True, mutex_name)
|
||
last_err = kernel32.GetLastError()
|
||
|
||
# ERROR_ALREADY_EXISTS = 183
|
||
if last_err == 183:
|
||
hwnd = user32.FindWindowW(None, "Hermes Hub")
|
||
if hwnd:
|
||
user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||
user32.SetForegroundWindow(hwnd)
|
||
if mutex:
|
||
kernel32.CloseHandle(mutex)
|
||
return False
|
||
|
||
_APP_MUTEX_HANDLE = mutex
|
||
return True
|
||
except Exception:
|
||
return True
|
||
|
||
|
||
def launch_hub():
|
||
from antigravity_provider import paths
|
||
from antigravity_provider.version import __version__
|
||
|
||
# Startup logging
|
||
log_file = paths.get_startup_log_file()
|
||
try:
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting Hermes Hub v{__version__} (PID {os.getpid()})\n")
|
||
except Exception:
|
||
pass
|
||
|
||
if not check_single_instance():
|
||
try:
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Another instance is already running. Focused existing window and exiting.\n")
|
||
except Exception:
|
||
pass
|
||
sys.exit(0)
|
||
|
||
try:
|
||
app = HermesHubApp()
|
||
app.mainloop()
|
||
except Exception as exc:
|
||
try:
|
||
import traceback
|
||
tb = traceback.format_exc()
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] FATAL CRASH:\n{tb}\n")
|
||
if sys.platform == "win32":
|
||
ctypes.windll.user32.MessageBoxW(
|
||
0,
|
||
f"Произошла критическая ошибка при запуске Hermes Hub:\n\n{exc}\n\nПодробности записаны в: {log_file}",
|
||
"Hermes Hub — Ошибка запуска",
|
||
0x10, # MB_ICONERROR
|
||
)
|
||
except Exception:
|
||
pass
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
launch_hub()
|