hermes-hub/launcher/HermesHubWeb.cs
Hermes Team bca88a56a6 fix(launcher): браузер убивал сервер, установщик запускал десктоп вместо веба
1. Главное: ERR_CONNECTION_REFUSED в окне приложения.
   browserProc.WaitForExit() возвращался МГНОВЕННО, когда Edge уже был
   запущен: новый msedge.exe передаёт окно работающему экземпляру и сразу
   завершается. Лаунчер считал, что окно закрыли, и убивал сервер, пока
   страница ещё грузилась.
   Браузер теперь запускается с отдельным профилем (--user-data-dir), то
   есть процесс живёт столько же, сколько окно. Плюс подстраховка: выход
   браузера быстрее пяти секунд не считается закрытием окна.
   Та же ловушка была в Linux-лаунчере — исправлена там же.

   Проверено вживую: сервер отвечает 200 И окно приложения живо
   («Hermes Hub — Панель управления»).

2. Установщик по галочке «Запустить сейчас» открывал ДЕСКТОП. Владелец
   получал десктопное окно и принимал его за старую версию — внешне оно
   и правда другое. Теперь запускается веб-интерфейс, подпись галочки
   уточнена. Десктоп остаётся доступен своим ярлыком.

Тесты: 373 passed, ruff чисто. Установщик пересобран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:07:33 +07:00

359 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Windows.Forms;
using Microsoft.Win32;
namespace HermesHub
{
public static class WebLauncher
{
[STAThread]
public static void Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string hermesHome = Environment.GetEnvironmentVariable("HERMES_HOME");
if (string.IsNullOrEmpty(hermesHome))
{
hermesHome = Path.Combine(localAppData, "hermes");
}
// 1. Determine Web API Host and Port from hub_settings.json or default
string host = "127.0.0.1";
int port = 5800;
string settingsFile = Path.Combine(hermesHome, "hub_settings.json");
if (File.Exists(settingsFile))
{
try
{
string json = File.ReadAllText(settingsFile, Encoding.UTF8);
int pIdx = json.IndexOf("\"web_api_port\":", StringComparison.OrdinalIgnoreCase);
if (pIdx >= 0)
{
int colon = json.IndexOf(':', pIdx);
int comma = json.IndexOfAny(new char[] { ',', '}', '\r', '\n' }, colon + 1);
if (colon >= 0 && comma > colon)
{
string pStr = json.Substring(colon + 1, comma - colon - 1).Trim();
int parsedPort;
if (int.TryParse(pStr, out parsedPort) && parsedPort > 0 && parsedPort < 65536)
{
port = parsedPort;
}
}
}
int hIdx = json.IndexOf("\"web_api_host\":", StringComparison.OrdinalIgnoreCase);
if (hIdx >= 0)
{
int q1 = json.IndexOf('"', hIdx + 15);
int q2 = json.IndexOf('"', q1 + 1);
if (q1 >= 0 && q2 > q1)
{
string hStr = json.Substring(q1 + 1, q2 - q1 - 1).Trim();
if (!string.IsNullOrEmpty(hStr) && hStr != "0.0.0.0")
{
host = hStr;
}
}
}
}
catch { }
}
string targetUrl = string.Format("http://{0}:{1}/", host, port);
string healthUrl = string.Format("http://{0}:{1}/api/health", host, port);
// 2. Check if server is already running and healthy
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
Process serverProcess = null;
if (!serverWasAlreadyRunning)
{
// Find Python
string hermesPythonW = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\pythonw.exe");
string hermesPython = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\python.exe");
string exe = File.Exists(hermesPythonW) ? hermesPythonW : hermesPython;
if (!File.Exists(exe))
{
MessageBox.Show(
"Hermes Python not found:\n" + exe + "\n\nPlease install Hermes Agent first.",
"Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
string pluginSrc = Path.Combine(hermesHome, @"plugins\antigravity-provider\src");
string agentDir = Path.Combine(hermesHome, "hermes-agent");
string baseDir = AppDomain.CurrentDomain.BaseDirectory;
string hubSrc = Path.GetFullPath(Path.Combine(baseDir, @"..\src"));
StringBuilder script = new StringBuilder();
script.AppendLine("import sys");
if (Directory.Exists(pluginSrc))
script.AppendLine("sys.path.insert(0, r'" + pluginSrc.Replace('\\', '/') + "')");
if (Directory.Exists(agentDir))
script.AppendLine("sys.path.insert(0, r'" + agentDir.Replace('\\', '/') + "')");
if (Directory.Exists(hubSrc))
script.AppendLine("sys.path.insert(0, r'" + hubSrc.Replace('\\', '/') + "')");
script.AppendLine("from antigravity_provider.router.web.server import run_server");
script.AppendLine("run_server()");
string entryScript = Path.Combine(hermesHome, "hermes_hub_web_entry.py");
try
{
File.WriteAllText(entryScript, script.ToString(), Encoding.UTF8);
}
catch (Exception ex)
{
MessageBox.Show("Cannot write entry script:\n" + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
ProcessStartInfo serverPsi = new ProcessStartInfo();
serverPsi.FileName = exe;
serverPsi.Arguments = "\"" + entryScript + "\"";
serverPsi.WorkingDirectory = hermesHome;
serverPsi.UseShellExecute = false;
serverPsi.CreateNoWindow = true;
serverPsi.WindowStyle = ProcessWindowStyle.Hidden;
// Вывод перехватываем, чтобы при падении показать причину, а не
// голое «terminated unexpectedly». Без этого владелец видит факт
// отказа и ни слова о том, чего не хватает.
serverPsi.RedirectStandardError = true;
serverPsi.RedirectStandardOutput = true;
try
{
serverProcess = Process.Start(serverPsi);
}
catch (Exception ex)
{
MessageBox.Show("Failed to start Hermes Hub web server:\n" + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
// 3. Poll /api/health with timeout (up to 15s)
bool ready = false;
for (int i = 0; i < 75; i++)
{
if (IsServerHealthy(healthUrl))
{
ready = true;
break;
}
if (serverProcess.HasExited)
{
string why = "";
try { why = serverProcess.StandardError.ReadToEnd(); } catch { }
if (string.IsNullOrEmpty(why))
{
try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { }
}
if (why.Length > 1500) why = why.Substring(why.Length - 1500);
string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim();
MessageBox.Show(msg, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Thread.Sleep(200);
}
if (!ready)
{
MessageBox.Show("Hermes Hub web server failed to respond within 15 seconds.", "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
if (serverProcess != null && !serverProcess.HasExited)
{
try { serverProcess.Kill(); } catch { }
}
return;
}
}
// 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback
string browserPath = FindChromiumBrowser();
Process browserProc = null;
DateTime browserStartedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(browserPath))
{
ProcessStartInfo browserPsi = new ProcessStartInfo();
browserPsi.FileName = browserPath;
// Отдельный профиль браузера обязателен. Без него запущенный
// msedge.exe передаёт задачу УЖЕ РАБОТАЮЩЕМУ экземпляру и тут же
// завершается: WaitForExit возвращается мгновенно, лаунчер
// считает окно закрытым и убивает сервер, пока страница ещё
// грузится. Владелец видел ERR_CONNECTION_REFUSED.
string browserProfile = Path.Combine(hermesHome, "web_browser_profile");
try { Directory.CreateDirectory(browserProfile); } catch { }
browserPsi.Arguments = string.Format(
"--app=\"{0}\" --window-size=1400,900 --user-data-dir=\"{1}\" --no-first-run --no-default-browser-check",
targetUrl, browserProfile);
browserPsi.UseShellExecute = false;
try
{
browserProc = Process.Start(browserPsi);
browserStartedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
MessageBox.Show(
"Не удалось запустить браузер в режиме приложения:\n" + ex.Message + "\n\nОткрываю стандартный браузер.",
"Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Process.Start(targetUrl);
}
}
else
{
// No Chromium browser found
MessageBox.Show(
"Браузер с поддержкой режима приложения (Microsoft Edge или Google Chrome) не найден.\n\n" +
"Интерфейс будет открыт в стандартном браузере с адресной строкой.",
"Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Information);
try
{
Process.Start(targetUrl);
}
catch (Exception ex)
{
MessageBox.Show("Не удалось открыть браузер: " + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
// 5. Server Lifecycle:
// If the server was started by this launcher session and browser is tracked,
// wait for browser window to close, then gracefully terminate server process.
if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null)
{
try
{
browserProc.WaitForExit();
}
catch { }
// Подстраховка: если процесс браузера завершился почти сразу,
// это почти наверняка передача окна другому экземпляру, а не
// закрытие пользователем. Убивать сервер в этом случае нельзя.
if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5))
{
return;
}
try
{
if (!serverProcess.HasExited)
{
serverProcess.Kill();
}
}
catch { }
}
}
public static bool IsServerHealthy(string url)
{
try
{
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
req.Timeout = 800;
req.Method = "GET";
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
{
if (resp.StatusCode == HttpStatusCode.OK)
{
using (StreamReader r = new StreamReader(resp.GetResponseStream(), Encoding.UTF8))
{
string txt = r.ReadToEnd();
return txt.Contains("\"ok\":true") || txt.Contains("\"ok\": true");
}
}
}
}
catch { }
return false;
}
public static string FindChromiumBrowser()
{
// 1. Microsoft Edge
string[] edgePaths = new string[]
{
@"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
@"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Microsoft\Edge\Application\msedge.exe")
};
foreach (string p in edgePaths)
{
if (File.Exists(p)) return p;
}
string edgeReg = GetAppPathFromRegistry("msedge.exe");
if (!string.IsNullOrEmpty(edgeReg) && File.Exists(edgeReg)) return edgeReg;
// 2. Google Chrome
string[] chromePaths = new string[]
{
@"C:\Program Files\Google\Chrome\Application\chrome.exe",
@"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Google\Chrome\Application\chrome.exe")
};
foreach (string p in chromePaths)
{
if (File.Exists(p)) return p;
}
string chromeReg = GetAppPathFromRegistry("chrome.exe");
if (!string.IsNullOrEmpty(chromeReg) && File.Exists(chromeReg)) return chromeReg;
// 3. Brave / Vivaldi / Chromium
string[] otherPaths = new string[]
{
@"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"BraveSoftware\Brave-Browser\Application\brave.exe"),
@"C:\Program Files\Vivaldi\Application\vivaldi.exe",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Vivaldi\Application\vivaldi.exe")
};
foreach (string p in otherPaths)
{
if (File.Exists(p)) return p;
}
string braveReg = GetAppPathFromRegistry("brave.exe");
if (!string.IsNullOrEmpty(braveReg) && File.Exists(braveReg)) return braveReg;
return null;
}
private static string GetAppPathFromRegistry(string exeName)
{
try
{
string key = @"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\" + exeName;
using (RegistryKey rk = Registry.LocalMachine.OpenSubKey(key))
{
if (rk != null)
{
object val = rk.GetValue(null);
if (val != null) return val.ToString();
}
}
using (RegistryKey rk = Registry.CurrentUser.OpenSubKey(key))
{
if (rk != null)
{
object val = rk.GetValue(null);
if (val != null) return val.ToString();
}
}
}
catch { }
return null;
}
}
}