hermes-hub/launcher/HermesHub.cs
2026-08-20 00:17:11 +07:00

396 lines
15 KiB
C#

using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Windows.Forms;
namespace HermesHub
{
static class Program
{
private static Process serverProcess = null;
private static string logFilePath = "";
private static bool isDebug = false;
private static readonly object _logLock = new object();
private static StringBuilder serverStdErr = new StringBuilder();
private static StringBuilder serverStdOut = new StringBuilder();
[STAThread]
static void Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
foreach (string arg in args)
{
if (arg.Equals("--debug", StringComparison.OrdinalIgnoreCase) || arg.Equals("-d", StringComparison.OrdinalIgnoreCase))
{
isDebug = true;
}
}
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string hermesHome = Path.Combine(localAppData, "hermes");
// Ensure %LOCALAPPDATA%\hermes\logs exists
string logsDir = Path.Combine(hermesHome, "logs");
try
{
if (!Directory.Exists(logsDir))
{
Directory.CreateDirectory(logsDir);
}
}
catch { }
logFilePath = Path.Combine(logsDir, "hermes-hub.log");
Log("================================================================================");
Log(string.Format("Hermes Hub Launcher Started at {0}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")));
Log("================================================================================");
if (isDebug) Log("[Debug Mode Active]");
int port = 8765;
string url = string.Format("http://127.0.0.1:{0}", port);
string healthUrl = url + "/api/status";
// 1. Check if Hermes Hub is already running healthy on default port (instant launch!)
if (IsEndpointHealthy(healthUrl, 1000))
{
Log(string.Format("Hermes Hub backend already running and healthy at {0}. Launching UI directly...", url));
LaunchBrowser(url);
return;
}
// 2. Resolve Hermes Python Executable dynamically
string hermesPython = Path.Combine(hermesHome, @"hermes-agent\venv\Scripts\python.exe");
if (!File.Exists(hermesPython))
{
string altPython = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"venv\Scripts\python.exe");
if (File.Exists(altPython)) hermesPython = altPython;
}
if (!File.Exists(hermesPython))
{
string errMsg = string.Format("Hermes Python environment not found at:\n{0}\n\nPlease ensure Hermes is installed.", hermesPython);
Log("[FATAL] " + errMsg);
MessageBox.Show(errMsg, "Hermes Hub Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Log("Hermes Python executable: " + hermesPython);
// 3. Discover Plugin Search Paths Dynamically
StringBuilder pathsCode = new StringBuilder();
string localAgPlugin = Path.Combine(hermesHome, @"plugins\antigravity-provider\src");
if (Directory.Exists(localAgPlugin))
{
pathsCode.Append(string.Format("r'{0}', ", localAgPlugin.Replace('\\', '/')));
}
string baseDirPlugin = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"plugins\antigravity-provider\src");
if (Directory.Exists(baseDirPlugin) && !baseDirPlugin.Equals(localAgPlugin, StringComparison.OrdinalIgnoreCase))
{
pathsCode.Append(string.Format("r'{0}', ", baseDirPlugin.Replace('\\', '/')));
}
string hermesAgentDir = Path.Combine(hermesHome, "hermes-agent");
if (Directory.Exists(hermesAgentDir))
{
pathsCode.Append(string.Format("r'{0}', ", hermesAgentDir.Replace('\\', '/')));
}
string pluginPathsList = pathsCode.ToString().TrimEnd(' ', ',');
Log("Discovered plugin search paths: " + pluginPathsList);
// 4. Check if standard port 8765 is blocked by a non-responding process
if (IsPortListening(port))
{
// Port has a socket listening, but it didn't respond to /api/status. Try dynamic port.
port = FindFreePortSafe();
url = string.Format("http://127.0.0.1:{0}", port);
healthUrl = url + "/api/status";
Log(string.Format("Default port 8765 was occupied by an unready process. Selected port: {0}", port));
}
else
{
Log(string.Format("Using standard port: {0}", port));
}
// 5. Write Clean Bootstrap Script File (hermes_hub_entry.py)
string launcherScript = Path.Combine(hermesHome, "hermes_hub_entry.py");
StringBuilder scriptContent = new StringBuilder();
scriptContent.AppendLine("# Auto-generated launcher bootstrap for Hermes Hub");
scriptContent.AppendLine("import sys, argparse");
scriptContent.AppendLine(string.Format("plugin_paths = [{0}]", pluginPathsList));
scriptContent.AppendLine("for p in plugin_paths:");
scriptContent.AppendLine(" if p and p not in sys.path:");
scriptContent.AppendLine(" sys.path.insert(0, p)");
scriptContent.AppendLine("parser = argparse.ArgumentParser()");
scriptContent.AppendLine("parser.add_argument('--port', type=int, default=8765)");
scriptContent.AppendLine("args, _ = parser.parse_known_args()");
scriptContent.AppendLine("from antigravity_provider.router.cli_commands import main");
scriptContent.AppendLine("sys.exit(main(['hub', '--port', str(args.port), '--no-browser']))");
try
{
File.WriteAllText(launcherScript, scriptContent.ToString(), Encoding.UTF8);
Log("Wrote launcher entry script: " + launcherScript);
}
catch (Exception ex)
{
Log("[WARN] Could not write entry script: " + ex.Message);
}
// 6. Start Background Backend Process with Redirected Output
ProcessStartInfo psi = new ProcessStartInfo();
psi.FileName = hermesPython;
psi.Arguments = string.Format("\"{0}\" --port {1}", launcherScript, port);
psi.WorkingDirectory = Directory.Exists(hermesHome) ? hermesHome : AppDomain.CurrentDomain.BaseDirectory;
psi.UseShellExecute = false;
psi.CreateNoWindow = true;
psi.WindowStyle = ProcessWindowStyle.Hidden;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
try
{
serverProcess = new Process();
serverProcess.StartInfo = psi;
serverProcess.OutputDataReceived += (s, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
lock (_logLock) { serverStdOut.AppendLine(e.Data); }
Log("[Backend stdout] " + e.Data);
}
};
serverProcess.ErrorDataReceived += (s, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
lock (_logLock) { serverStdErr.AppendLine(e.Data); }
Log("[Backend stderr] " + e.Data);
}
};
serverProcess.Start();
serverProcess.BeginOutputReadLine();
serverProcess.BeginErrorReadLine();
Log(string.Format("Launched backend process (PID: {0}) on port {1}", serverProcess.Id, port));
}
catch (Exception ex)
{
string errMsg = "Failed to launch Hermes Hub backend process:\n" + ex.Message;
Log("[FATAL] " + errMsg);
MessageBox.Show(errMsg, "Hermes Hub Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
Cleanup();
return;
}
AppDomain.CurrentDomain.ProcessExit += (s, e) => Cleanup();
// 7. Health Check Gate: Poll /api/status up to 20 seconds (100 * 200ms)
bool isReady = false;
int maxAttempts = 100;
Log(string.Format("Polling backend health at {0}...", healthUrl));
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
if (serverProcess.HasExited)
{
Log(string.Format("[FATAL] Backend server process terminated prematurely with exit code {0}", serverProcess.ExitCode));
break;
}
if (IsEndpointHealthy(healthUrl, 400))
{
isReady = true;
Log(string.Format("[PASS] Backend health check OK (HTTP 200) on attempt {0} ({1:F1}s)", attempt, attempt * 0.2));
break;
}
Thread.Sleep(200);
}
// If backend failed to respond or crashed: Fail-closed gate
if (!isReady)
{
string stdErrText;
lock (_logLock) { stdErrText = serverStdErr.ToString().Trim(); }
if (string.IsNullOrEmpty(stdErrText))
{
stdErrText = GetRecentLogLines(25);
}
string failMsg = string.Format(
"Hermes Hub backend failed to start.\n\n" +
"Endpoint: {0}\n\n" +
"Error / Output:\n{1}\n\n" +
"Full log file:\n{2}",
url, stdErrText, logFilePath
);
Log("[FATAL] Startup health check failed. Aborting UI launch.");
MessageBox.Show(failMsg, "Hermes Hub Startup Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
Cleanup();
return;
}
// 8. Launch UI in Microsoft Edge App Mode or Default Browser
LaunchBrowser(url);
}
private static void LaunchBrowser(string url)
{
string edgePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), @"Microsoft\Edge\Application\msedge.exe");
if (!File.Exists(edgePath))
{
edgePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Microsoft\Edge\Application\msedge.exe");
}
Process browserProc = null;
if (File.Exists(edgePath))
{
Log("Opening Edge in standalone App Mode: " + url);
ProcessStartInfo edgePsi = new ProcessStartInfo();
edgePsi.FileName = edgePath;
edgePsi.Arguments = string.Format("--app=\"{0}\" --window-size=1280,860 --app-id=hermes-hub", url);
edgePsi.UseShellExecute = false;
try
{
browserProc = Process.Start(edgePsi);
}
catch (Exception ex)
{
Log("[WARN] Failed to open Edge app mode, falling back to default browser: " + ex.Message);
Process.Start(url);
}
}
else
{
Log("Opening default browser: " + url);
Process.Start(url);
}
if (browserProc != null && serverProcess != null)
{
browserProc.WaitForExit();
Log("UI Window closed by user. Terminating backend...");
Cleanup();
}
}
private static bool IsEndpointHealthy(string url, int timeoutMs)
{
try
{
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
req.Timeout = timeoutMs;
req.Method = "GET";
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
{
return resp.StatusCode == HttpStatusCode.OK;
}
}
catch
{
return false;
}
}
private static bool IsPortListening(int port)
{
try
{
using (TcpClient client = new TcpClient())
{
IAsyncResult ar = client.BeginConnect(IPAddress.Loopback, port, null, null);
bool success = ar.AsyncWaitHandle.WaitOne(200);
if (success && client.Connected)
{
client.EndConnect(ar);
return true;
}
}
}
catch { }
return false;
}
private static int FindFreePortSafe()
{
for (int p = 8766; p <= 8790; p++)
{
if (!IsPortListening(p))
{
return p;
}
}
return 8766;
}
private static void Log(string message)
{
lock (_logLock)
{
string line = string.Format("[{0}] {1}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), message);
if (isDebug)
{
try { Console.WriteLine(line); } catch { }
}
try
{
if (!string.IsNullOrEmpty(logFilePath))
{
File.AppendAllText(logFilePath, line + Environment.NewLine, Encoding.UTF8);
}
}
catch { }
}
}
private static string GetRecentLogLines(int count)
{
try
{
if (File.Exists(logFilePath))
{
string[] lines = File.ReadAllLines(logFilePath);
int start = Math.Max(0, lines.Length - count);
StringBuilder sb = new StringBuilder();
for (int i = start; i < lines.Length; i++)
{
sb.AppendLine(lines[i]);
}
return sb.ToString().Trim();
}
}
catch { }
return "(log unavailable)";
}
private static void Cleanup()
{
Log("Cleaning up launcher and terminating backend processes...");
if (serverProcess != null && !serverProcess.HasExited)
{
try
{
serverProcess.Kill();
serverProcess.WaitForExit(2000);
Log(string.Format("Backend process (PID: {0}) terminated.", serverProcess.Id));
}
catch (Exception ex)
{
Log("Error terminating backend process: " + ex.Message);
}
}
}
}
}