396 lines
15 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|