aboutsummaryrefslogtreecommitdiff
path: root/src/zen/cmds/ui_cmd.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/cmds/ui_cmd.cpp')
-rw-r--r--src/zen/cmds/ui_cmd.cpp236
1 files changed, 236 insertions, 0 deletions
diff --git a/src/zen/cmds/ui_cmd.cpp b/src/zen/cmds/ui_cmd.cpp
new file mode 100644
index 000000000..da06ce305
--- /dev/null
+++ b/src/zen/cmds/ui_cmd.cpp
@@ -0,0 +1,236 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include "ui_cmd.h"
+
+#include <zencore/except_fmt.h>
+#include <zencore/fmtutils.h>
+#include <zencore/logging.h>
+#include <zencore/process.h>
+#include <zenutil/consoletui.h>
+#include <zenutil/zenserverprocess.h>
+
+#if ZEN_PLATFORM_WINDOWS
+# include <zencore/windows.h>
+# include <shellapi.h>
+#endif
+
+namespace zen {
+
+namespace {
+
+ struct RunningServerInfo
+ {
+ uint16_t Port;
+ uint32_t Pid;
+ std::string SessionId;
+ std::string CmdLine;
+ };
+
+ static std::vector<RunningServerInfo> CollectRunningServers()
+ {
+ std::vector<RunningServerInfo> Servers;
+ ZenServerState State;
+ if (!State.InitializeReadOnly())
+ return Servers;
+
+ State.Snapshot([&](const ZenServerState::ZenServerEntry& Entry) {
+ StringBuilder<25> SessionSB;
+ Entry.GetSessionId().ToString(SessionSB);
+ std::error_code CmdLineEc;
+ std::string CmdLine = GetProcessCommandLine(static_cast<int>(Entry.Pid.load()), CmdLineEc);
+ Servers.push_back({Entry.EffectiveListenPort.load(), Entry.Pid.load(), std::string(SessionSB.c_str()), std::move(CmdLine)});
+ });
+
+ return Servers;
+ }
+
+} // namespace
+
+UiCommand::UiCommand()
+{
+ m_Options.add_options()("h,help", "Print help");
+ m_Options.add_options()("a,all", "Open dashboard for all running instances", cxxopts::value(m_All)->default_value("false"));
+ m_Options.add_option("", "u", "hosturl", "Host URL", cxxopts::value(m_HostName)->default_value(""), "<hosturl>");
+ m_Options.add_option("",
+ "p",
+ "path",
+ "Dashboard path (default: /dashboard/)",
+ cxxopts::value(m_DashboardPath)->default_value("/dashboard/"),
+ "<path>");
+ m_Options.parse_positional("path");
+}
+
+UiCommand::~UiCommand()
+{
+}
+
+void
+UiCommand::OpenBrowser(std::string_view HostName)
+{
+ // Allow shortcuts for specifying dashboard path, and ensure it is in a format we expect
+ // (leading slash, trailing slash if no file extension)
+
+ if (!m_DashboardPath.empty())
+ {
+ if (m_DashboardPath[0] != '/')
+ {
+ m_DashboardPath = "/dashboard/" + m_DashboardPath;
+ }
+
+ if (m_DashboardPath.find_last_of('.') == std::string::npos && m_DashboardPath.back() != '/')
+ {
+ m_DashboardPath += '/';
+ }
+ }
+
+ bool Success = false;
+
+ ExtendableStringBuilder<256> FullUrl;
+ FullUrl << HostName << m_DashboardPath;
+
+#if ZEN_PLATFORM_WINDOWS
+ HINSTANCE Result = ShellExecuteA(nullptr, "open", FullUrl.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
+ Success = reinterpret_cast<intptr_t>(Result) > 32;
+#else
+ // Validate URL doesn't contain shell metacharacters that could lead to command injection
+ std::string_view FullUrlView = FullUrl;
+ constexpr std::string_view DangerousChars = ";|&$`\\\"'<>(){}[]!#*?~\n\r";
+ if (FullUrlView.find_first_of(DangerousChars) != std::string_view::npos)
+ {
+ throw OptionParseException(fmt::format("URL contains invalid characters: '{}'", FullUrl), m_Options.help());
+ }
+
+# if ZEN_PLATFORM_MAC
+ std::string Command = fmt::format("open \"{}\"", FullUrl);
+# elif ZEN_PLATFORM_LINUX
+ std::string Command = fmt::format("xdg-open \"{}\"", FullUrl);
+# else
+ ZEN_NOT_IMPLEMENTED("Browser launching not implemented on this platform");
+# endif
+
+ Success = system(Command.c_str()) == 0;
+#endif
+
+ if (!Success)
+ {
+ throw zen::runtime_error("Failed to launch browser for '{}'", FullUrl);
+ }
+
+ ZEN_CONSOLE("Web browser launched for '{}' successfully", FullUrl);
+}
+
+void
+UiCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
+{
+ using namespace std::literals;
+
+ ZEN_UNUSED(GlobalOptions);
+
+ if (!ParseOptions(argc, argv))
+ {
+ return;
+ }
+
+ // Resolve target server
+ uint16_t ServerPort = 0;
+
+ if (m_HostName.empty())
+ {
+ // Auto-discover running instances.
+ std::vector<RunningServerInfo> Servers = CollectRunningServers();
+
+ if (m_All)
+ {
+ if (Servers.empty())
+ {
+ throw OptionParseException("No running Zen server instances found", m_Options.help());
+ }
+
+ for (const auto& Server : Servers)
+ {
+ OpenBrowser(fmt::format("http://localhost:{}", Server.Port));
+ }
+ return;
+ }
+
+ // If multiple are found and we have an interactive terminal, present a picker
+ // instead of silently using the first one.
+ if (Servers.size() > 1 && IsTuiAvailable())
+ {
+ std::vector<std::string> Labels;
+ Labels.reserve(Servers.size() + 1);
+ Labels.push_back(fmt::format("(all {} instances)", Servers.size()));
+
+ const int32_t Cols = static_cast<int32_t>(TuiConsoleColumns());
+ constexpr int32_t kIndicator = 3; // " ▶ " or " " prefix
+ constexpr int32_t kSeparator = 2; // " " before cmdline
+ constexpr int32_t kEllipsis = 3; // "..."
+
+ for (const auto& Server : Servers)
+ {
+ std::string Label = fmt::format("port {:<5} pid {:<7} session {}", Server.Port, Server.Pid, Server.SessionId);
+
+ if (!Server.CmdLine.empty())
+ {
+ int32_t Available = Cols - kIndicator - kSeparator - static_cast<int32_t>(Label.size());
+ if (Available > kEllipsis)
+ {
+ Label += " ";
+ if (static_cast<int32_t>(Server.CmdLine.size()) <= Available)
+ {
+ Label += Server.CmdLine;
+ }
+ else
+ {
+ Label.append(Server.CmdLine, 0, static_cast<size_t>(Available - kEllipsis));
+ Label += "...";
+ }
+ }
+ }
+
+ Labels.push_back(std::move(Label));
+ }
+
+ int SelectedIdx = TuiPickOne("Multiple Zen server instances found. Select one to open:", Labels);
+ if (SelectedIdx < 0)
+ return; // User cancelled
+
+ if (SelectedIdx == 0)
+ {
+ // "All" selected
+ for (const auto& Server : Servers)
+ {
+ OpenBrowser(fmt::format("http://localhost:{}", Server.Port));
+ }
+ return;
+ }
+
+ ServerPort = Servers[SelectedIdx - 1].Port;
+ m_HostName = fmt::format("http://localhost:{}", ServerPort);
+ }
+
+ if (m_HostName.empty())
+ {
+ // Single or zero instances, or not an interactive terminal:
+ // fall back to default resolution (picks first instance or returns empty)
+ m_HostName = ResolveTargetHostSpec("", ServerPort);
+ }
+ }
+ else
+ {
+ if (m_All)
+ {
+ throw OptionParseException("--all cannot be used together with --hosturl", m_Options.help());
+ }
+ m_HostName = ResolveTargetHostSpec(m_HostName, ServerPort);
+ }
+
+ if (m_HostName.empty())
+ {
+ throw OptionParseException("Unable to resolve server specification", m_Options.help());
+ }
+
+ OpenBrowser(m_HostName);
+}
+
+} // namespace zen