From 761a085ed14d02337f04b12323bb6a9f5c0e9b73 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Tue, 3 Nov 2026 00:11:06 +0100 Subject: [PATCH 01/16] feat: Added Untiy CLI integration to keep Unity signed in without standard Unity Hub --- README.md | 1 + source/form.fbp | 146 +++++++++++ source/interface.cpp | 8 + source/interface.h | 4 + source/interface_derived.cpp | 179 ++++++++++++- source/interface_derived.hpp | 9 + source/unity_cli.cpp | 479 +++++++++++++++++++++++++++++++++++ source/unity_cli.hpp | 70 +++++ 8 files changed, 885 insertions(+), 11 deletions(-) create mode 100644 source/unity_cli.cpp create mode 100644 source/unity_cli.hpp diff --git a/README.md b/README.md index 7390d7d1..eea0f914 100755 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ UnityHubNative launches many times faster, uses far less memory and CPU, and is ## Features - Add an existing project to the list using the Add Existing button - Open projects by double-clicking them in the list, or by selecting them and pressing Enter +- Open projects signed in to your Unity account (Asset Store, Unity services) without the official hub running, through the official Unity CLI, for Unity 2019.1 and newer. UnityHubNative offers to install the CLI on launch, and the Install CLI and Login CLI buttons in the Editor Versions tab install it and sign it in to your Unity account. Requires Unity CLI 1.0.0-beta.10 or newer (1.0.0-beta.12 on Windows) - Create new unity projects with templates, for any modern Unity version you have installed, with the Create New button - View Unity installs by double clicking them in the list views in the Editor Versions section - Manage Unity installs diff --git a/source/form.fbp b/source/form.fbp index 89709ab1..bbab44f6 100644 --- a/source/form.fbp +++ b/source/form.fbp @@ -1490,6 +1490,152 @@ + + 5 + wxALL|wxEXPAND + 0 + + 1 + 1 + 1 + 1 + + + + + 0 + + + + + 1 + 0 + 1 + + 1 + + 0 + 0 + + Dock + 0 + Left + 1 + + 1 + + + 0 + 0 + INSTALL_CLI + Install CLI + + 0 + + 0 + + + 0 + + 1 + installCliBtn + 1 + + + protected + 1 + + + + Resizable + 1 + + + ; ; forward_declare + 0 + + + wxFILTER_NONE + wxDefaultValidator + + + + + + + + 5 + wxALL|wxEXPAND + 0 + + 1 + 1 + 1 + 1 + + + + + 0 + + + + + 1 + 0 + 1 + + 1 + + 0 + 0 + + Dock + 0 + Left + 0 + + 1 + + + 0 + 0 + LOGIN_CLI + Login CLI + + 0 + + 0 + + + 0 + + 1 + loginCliBtn + 1 + + + protected + 1 + + + + Resizable + 1 + + + ; ; forward_declare + 0 + + + wxFILTER_NONE + wxDefaultValidator + + + + + + 5 wxALL|wxEXPAND diff --git a/source/interface.cpp b/source/interface.cpp index 97f4ab7e..4f21ff22 100755 --- a/source/interface.cpp +++ b/source/interface.cpp @@ -134,6 +134,14 @@ MainFrame::MainFrame( wxWindow* parent, wxWindowID id, const wxString& title, co bSizer5->Add( activatePersonalBtn, 0, wxALL|wxEXPAND, 5 ); + installCliBtn = new wxButton( installs_pane, INSTALL_CLI, wxT("Install CLI"), wxDefaultPosition, wxDefaultSize, 0 ); + bSizer5->Add( installCliBtn, 0, wxALL|wxEXPAND, 5 ); + + loginCliBtn = new wxButton( installs_pane, LOGIN_CLI, wxT("Login CLI"), wxDefaultPosition, wxDefaultSize, 0 ); + loginCliBtn->Enable( false ); + + bSizer5->Add( loginCliBtn, 0, wxALL|wxEXPAND, 5 ); + wxButton* reloadInstalls; reloadInstalls = new wxButton( installs_pane, wxID_RELOAD, wxT("Reload"), wxDefaultPosition, wxDefaultSize, 0 ); bSizer5->Add( reloadInstalls, 0, wxALL|wxEXPAND, 5 ); diff --git a/source/interface.h b/source/interface.h index 1de3ff83..403e3194 100755 --- a/source/interface.h +++ b/source/interface.h @@ -51,6 +51,8 @@ #define VERSIONS_LIST 1012 #define INSTALLVIAHUB 1013 #define PPA_ACTIVATE 1014 +#define INSTALL_CLI 1015 +#define LOGIN_CLI 1016 /////////////////////////////////////////////////////////////////////////////// /// Class MainFrame @@ -73,6 +75,8 @@ class MainFrame : public wxFrame wxButton* removeInstallBtn; wxButton* activateProPlusBtn; wxButton* activatePersonalBtn; + wxButton* installCliBtn; + wxButton* loginCliBtn; wxMenuBar* menubar; wxMenuItem* menuRemove; wxMenuItem* menuReveal; diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index c63a3aec..c8cfc7f9 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -8,10 +8,13 @@ #include "interface_derived.hpp" #include "activation.hpp" +#include "unity_cli.hpp" #include #include #include #include +#include +#include #include @@ -30,6 +33,7 @@ const int TIMER_LENGTH = 5 * 1000 * 60; constexpr std::string_view config_name = "com.ravbug.unityhubnative.config"; constexpr std::string_view config_sortCol = "sortColIndex"; constexpr std::string_view config_sortAscending = "sortAscending"; +constexpr std::string_view config_hideCliPrompt = "hideUnityCliPrompt"; //Declare events here wxBEGIN_EVENT_TABLE(MainFrameDerived, wxFrame) @@ -55,6 +59,8 @@ EVT_BUTTON(wxID_RELOAD,MainFrameDerived::OnReloadEditors) EVT_BUTTON(OPEN_WITH, MainFrameDerived::OnOpenWith) EVT_BUTTON(ACTIV_PROPLUS, MainFrameDerived::OnActivateProPlus) EVT_BUTTON(ACTIV_PERSONAL, MainFrameDerived::OnActivatePersonal) +EVT_BUTTON(INSTALL_CLI, MainFrameDerived::OnInstallCli) +EVT_BUTTON(LOGIN_CLI, MainFrameDerived::OnLoginCli) EVT_LIST_ITEM_ACTIVATED(wxID_HARDDISK, MainFrameDerived::OnOpenProject) EVT_LIST_ITEM_SELECTED(wxID_HARDDISK, MainFrameDerived::OnSelectProject) @@ -112,6 +118,35 @@ MainFrameDerived::MainFrameDerived() : MainFrame(NULL){ this->SetLabel(std::format("Unity Hub Native {}",AppVersion)); projSearchCtrl->Bind(wxEVT_KEY_UP, &MainFrameDerived::Filter, this); projSearchCtrl->SetFocus(); + + // Don't let the window get smaller than its tabs need to show all of their buttons + { + // Lists scroll, so they shouldn't make the window wider for long names and paths + constexpr int listMinWidth = 150; + for (wxWindow* list : {static_cast(projectsList), static_cast(installsList), static_cast(installsPathsList)}) { + list->SetMinSize(wxSize(listMinWidth, list->GetMinSize().y)); + } + wxSize pageMin; + for (size_t i = 0; i < notebook->GetPageCount(); i++) { + if (const auto sizer = notebook->GetPage(i)->GetSizer()) { + pageMin.IncTo(sizer->GetMinSize()); + } + } + constexpr int notebookBorder = 5; // around the notebook in the frame's sizer + const wxSize clientMin = notebook->CalcSizeFromPage(pageMin) + wxSize(2 * notebookBorder, 2 * notebookBorder); + wxSize windowMin = ClientToWindowSize(clientMin); + windowMin.IncTo(GetMinSize()); + SetMinSize(windowMin); + wxSize size = GetSize(); + if (!(size.x >= windowMin.x && size.y >= windowMin.y)) { + size.IncTo(windowMin); + SetSize(size); + } + } + + RefreshCliState(); + // once the window is on screen + CallAfter(&MainFrameDerived::OfferCliInstall); } void MainFrameDerived::OnSelectProject(wxListEvent&){ @@ -432,12 +467,7 @@ void MainFrameDerived::OpenProject(const long& index){ auto editorPath = editor.executablePath(); //check that the unity editor exists at that location if (filesystem::exists(editorPath)) { - - string cmd = "\"" + editorPath.string() + "\" -projectpath \"" + p.path.string() + "\""; - - //start the process - launch_process(cmd); - + OpenProject(p, editor); return; } } @@ -474,12 +504,139 @@ const char* const PlatToStr(TargetPlatform plat) { @param e the editor version to use when opening the project */ void MainFrameDerived::OpenProject(const project& p, const editor& e, TargetPlatform plat){ - string cmd = "\"" + e.executablePath().string() + "\" -projectpath \"" + p.path.string() + "\""; - if (plat != TargetPlatform::CurrentPlatform) { - auto str = PlatToStr(plat); - cmd += std::format(" -buildTarget {}", str); + const std::string_view buildTarget = PlatToStr(plat); + auto launchDirectly = [p, e, buildTarget]{ + string cmd = "\"" + e.executablePath().string() + "\" -projectpath \"" + p.path.string() + "\""; + if (!buildTarget.empty()) { + cmd += std::format(" -buildTarget {}", buildTarget); + } + launch_process(cmd); + }; + + // Launched directly, the Editor starts signed out and starts the official Hub to sign in. + // The Unity CLI serves the Editor's sign-in itself, without the Hub. + if (UnityCli::CanOpen(p, e)) { + if (const auto cli = UnityCli::Find()) { + cli->Open(p, e, buildTarget, launchDirectly); + return; + } + } + launchDirectly(); +} + +/** + Updates the Install CLI and Login CLI buttons. Install is available while there is no + recent enough CLI, Login while the CLI is not signed in. + */ +void MainFrameDerived::RefreshCliState(){ + const auto cli = UnityCli::Locate(); + const bool usable = cli && cli->recentEnough; + installCliBtn->SetLabel(cli && !usable ? "Update CLI" : "Install CLI"); + installCliBtn->Enable(!usable); + loginCliBtn->Enable(false); + installCliBtn->GetParent()->Layout(); + if (!usable) { + return; + } + wxWeakRef self(this); + cli->CheckSignedIn([self](bool signedIn){ + if (self) { + self->loginCliBtn->Enable(!signedIn); + } + }); +} + +/** + On launch, explains the Unity CLI and offers to install it, unless the user turned this off + */ +void MainFrameDerived::OfferCliInstall(){ + wxConfig config(config_name.data()); + if (config.ReadBool(config_hideCliPrompt.data(), false)) { + return; } - launch_process(cmd); + const auto cli = UnityCli::Locate(); + if (cli && cli->recentEnough) { + return; + } + + const wxString status = cli + ? wxString::Format("The Unity CLI on this computer (version %s) is too old for this.", cli->version.empty() ? "unknown" : cli->version) + : wxString("The Unity CLI isn't installed yet."); + const wxString message = + "Unity Hub Native can open your projects signed in to your Unity account, so the Asset Store, " + "Package Manager and other Unity services work, without starting the official Unity Hub. " + "It does this through Unity's official command-line tool, the Unity CLI.\n\n" + status + "\n\n" + "Installing runs Unity's installer from unity.com. It puts the unity command in your user folder " + "and adds it to your shell's PATH. After that, you sign in to your Unity account once in your browser.\n\n" + "You can also do this later from the Editor Versions tab."; + + wxRichMessageDialog dialog(this, message, "Open Projects Signed In", wxYES_NO | wxICON_INFORMATION); + dialog.SetYesNoLabels(cli ? "Update CLI" : "Install CLI", "Not Now"); + dialog.ShowCheckBox("Don't show this again"); + const bool install = dialog.ShowModal() == wxID_YES; + if (dialog.IsCheckBoxChecked()) { + config.Write(config_hideCliPrompt.data(), true); + } + if (install) { + InstallCli(); + } +} + +/** + Installs or updates the Unity CLI, then helps the user sign in + */ +void MainFrameDerived::InstallCli(){ + wxWeakRef self(this); + UnityCli::Install(this, [self](bool installed){ + if (!self) { + return; + } + self->RefreshCliState(); + if (!installed) { + return; + } + const auto cli = UnityCli::Find(); + if (!cli) { + wxMessageBox("The installer finished, but Unity Hub Native cannot find a recent enough Unity CLI.", "Cannot Install Unity CLI", wxOK | wxICON_ERROR, self.get()); + return; + } + cli->CheckSignedIn([self](bool signedIn){ + if (!self) { + return; + } + if (signedIn) { + wxMessageBox("The Unity CLI is installed and signed in. Projects now open signed in to your Unity account.", "Unity CLI Installed", wxOK | wxICON_INFORMATION, self.get()); + } + else if (wxMessageBox("The Unity CLI is installed. Sign in to your Unity account now, so projects open signed in?", "Unity CLI Installed", wxYES_NO | wxICON_QUESTION, self.get()) == wxYES) { + self->LoginCli(); + } + }); + }); +} + +/** + Signs the Unity CLI in to the user's Unity account through the browser + */ +void MainFrameDerived::LoginCli(){ + const auto cli = UnityCli::Find(); + if (!cli) { + RefreshCliState(); + return; + } + wxWeakRef self(this); + cli->SignIn(this, [self](bool){ + if (self) { + self->RefreshCliState(); + } + }); +} + +void MainFrameDerived::OnInstallCli(wxCommandEvent&){ + InstallCli(); +} + +void MainFrameDerived::OnLoginCli(wxCommandEvent&){ + LoginCli(); } MainFrameDerived::~MainFrameDerived() diff --git a/source/interface_derived.hpp b/source/interface_derived.hpp index 9eb67783..b1308ce3 100644 --- a/source/interface_derived.hpp +++ b/source/interface_derived.hpp @@ -61,6 +61,14 @@ class MainFrameDerived : public MainFrame{ void OnSelectEditorPath(wxCommandEvent&); void OnQuit(wxCommandEvent&); + + // Unity CLI: opens projects signed in without the official Hub + void OnInstallCli(wxCommandEvent&); + void OnLoginCli(wxCommandEvent&); + void InstallCli(); + void LoginCli(); + void OfferCliInstall(); + void RefreshCliState(); wxWindow* const projectActionItems[3]{ revealProjBtn, @@ -175,6 +183,7 @@ class MainFrameDerived : public MainFrame{ void OnOpenHub(wxCommandEvent& event); void OnReloadEditors(wxCommandEvent& event){ this->LoadEditorVersions(); + RefreshCliState(); } void OnColumnClick(wxListEvent& event); static int wxCALLBACK CompareItems(wxIntPtr item1, wxIntPtr item2, wxIntPtr sortData); diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp new file mode 100644 index 00000000..f1a21571 --- /dev/null +++ b/source/unity_cli.cpp @@ -0,0 +1,479 @@ +// +// unity_cli.cpp +// Unity Hub Native +// + +#include "unity_cli.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + // A Unity CLI version such as "1.0.0-beta.13" + struct CliVersion { + int major = 0, minor = 0, patch = 0; + std::string preTag; // e.g. "beta", empty for a release + int preNumber = 0; + }; + + // The Editor identity helper that `unity open` starts arrived in 1.0.0-beta.6, but + // before 1.0.0-beta.10 it could leave the Editor silently signed out, and before + // 1.0.0-beta.12 it dropped Editor requests on Windows +#if _WIN32 + const CliVersion minimumVersion{1, 0, 0, "beta", 12}; +#else + const CliVersion minimumVersion{1, 0, 0, "beta", 10}; +#endif + + // The installer's version manifest for the stable channel (its default) + constexpr const char* stableManifestUrl = "https://public-cdn.cloud.unity3d.com/hub/prod/cli/latest.json"; + + std::optional ParseCliVersion(const std::string& text) { + CliVersion version; + char tag[32]{}; + const int matched = sscanf(text.c_str(), "%d.%d.%d-%31[A-Za-z].%d", &version.major, &version.minor, &version.patch, tag, &version.preNumber); + if (matched < 3) { + return std::nullopt; + } + if (matched >= 4) { + version.preTag = tag; + } + return version; + } + + bool IsAtLeast(const CliVersion& version, const CliVersion& minimum) { + const auto core = [](const CliVersion& v) { return std::tie(v.major, v.minor, v.patch); }; + if (core(version) != core(minimum)) { + return core(version) > core(minimum); + } + // a release is newer than any pre-release of the same version + if (version.preTag.empty() || minimum.preTag.empty()) { + return version.preTag.empty(); + } + return std::tie(version.preTag, version.preNumber) >= std::tie(minimum.preTag, minimum.preNumber); + } + + wxString ToWx(const std::filesystem::path& path) { +#if _WIN32 + return wxString(path.wstring()); +#else + return wxString::FromUTF8(path.string()); +#endif + } + + std::filesystem::path HomeDirectory() { +#if _WIN32 + const wchar_t* profile = _wgetenv(L"USERPROFILE"); + return profile ? std::filesystem::path(profile) : homedir; +#else + return std::filesystem::path(getpwuid(getuid())->pw_dir); +#endif + } + + // Where the official Hub and the CLI's installer record where the CLI is (cli-install.json) + std::filesystem::path HubDataDirectory() { +#if __APPLE__ + return HomeDirectory() / "Library/Application Support/UnityHub"; +#elif _WIN32 + const wchar_t* appData = _wgetenv(L"APPDATA"); + return appData ? std::filesystem::path(appData) / "UnityHub" : HomeDirectory() / "AppData" / "Roaming" / "UnityHub"; +#else + const char* config = getenv("XDG_CONFIG_HOME"); + return (config && *config ? std::filesystem::path(config) : HomeDirectory() / ".config") / "unityhub"; +#endif + } + + // Where Unity's installer puts the CLI by default + std::filesystem::path DefaultExecutable() { +#if _WIN32 + const wchar_t* localAppData = _wgetenv(L"LOCALAPPDATA"); + const auto base = localAppData ? std::filesystem::path(localAppData) : HomeDirectory() / "AppData" / "Local"; + return base / "Unity" / "bin" / "unity.exe"; +#elif __APPLE__ + return HomeDirectory() / ".unity" / "bin" / "unity"; +#else + return HomeDirectory() / ".local" / "bin" / "unity"; +#endif + } + + // Finds where `"key"` and its following colon end, in a small JSON document + std::optional JsonValueStart(const std::string& json, std::string_view key) { + const std::string quotedKey = "\"" + std::string(key) + "\""; + auto pos = json.find(quotedKey); + if (pos == std::string::npos) { + return std::nullopt; + } + pos = json.find_first_not_of(" \t\r\n", pos + quotedKey.size()); + if (pos == std::string::npos || json[pos] != ':') { + return std::nullopt; + } + pos = json.find_first_not_of(" \t\r\n", pos + 1); + if (pos == std::string::npos) { + return std::nullopt; + } + return pos; + } + + /** + Reads a string value from a small JSON document, enough for cli-install.json + @return the unescaped value, or nullopt if the key is missing or not a string + */ + std::optional JsonStringField(const std::string& json, std::string_view key) { + const auto start = JsonValueStart(json, key); + if (!start || json[*start] != '"') { + return std::nullopt; + } + std::string value; + for (auto pos = *start + 1; pos < json.size(); ++pos) { + const char c = json[pos]; + if (c == '"') { + return value; + } + if (c != '\\') { + value += c; + continue; + } + if (++pos == json.size()) { + break; + } + switch (json[pos]) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case 'r': value += '\r'; break; + case 'u': return std::nullopt; // not needed for paths, JSON keeps them as UTF-8 + default: value += json[pos]; break; // \\ \" \/ + } + } + return std::nullopt; + } + + // Reads a boolean value from a small JSON document + std::optional JsonBoolField(const std::string& json, std::string_view key) { + const auto start = JsonValueStart(json, key); + if (!start) { + return std::nullopt; + } + if (json.compare(*start, 4, "true") == 0) { + return true; + } + if (json.compare(*start, 5, "false") == 0) { + return false; + } + return std::nullopt; + } + + std::optional QueryVersion(const std::filesystem::path& cli) { + wxArrayString output, errors; + const auto command = "\"" + ToWx(cli) + "\" --version"; + if (wxExecute(command, output, errors, wxEXEC_NODISABLE | wxEXEC_HIDE_CONSOLE) != 0) { + return std::nullopt; + } + for (auto& line : output) { + const auto text = line.Trim().Trim(false).utf8_string(); + if (ParseCliVersion(text)) { + return text; + } + } + return std::nullopt; + } + + // Shows why a command failed, with the end of its output, where the reason usually is + void ShowFailure(const wxString& title, const wxString& summary, wxString output) { + constexpr size_t maxLength = 2000; + if (output.length() > maxLength) { + output = "…" + output.Right(maxLength); + } + wxMessageBox(output.empty() ? summary : summary + "\n\n" + output, title, wxOK | wxICON_ERROR); + } + + struct Progress { + wxWindow* parent; + wxString title; + wxString message; + }; + + /** + Called when a command finishes + @param status the exit code + @param cancelled whether the user cancelled it from its progress window + @param output what it wrote to stdout and stderr + */ + using CommandDone = std::function; + + // Runs a command without blocking the UI, and collects its output + class CommandProcess : public wxProcess { + public: + explicit CommandProcess(CommandDone done) : done(std::move(done)) { +#if _WIN32 + Redirect(); +#else + outputFile = wxFileName::CreateTempFileName("UnityHubNative-cli-"); +#endif + } + + ~CommandProcess() override { + if (!outputFile.empty()) { + wxRemoveFile(outputFile); + } + } + + // Where the command's output goes on macOS and Linux + wxString OutputTarget() const { + return outputFile.empty() ? wxString("/dev/null") : outputFile; + } + + // Shows an indeterminate progress window with a Cancel button while the command runs + void ShowProgress(const Progress& info) { + progress = std::make_unique(info.title, info.message, 100, info.parent, wxPD_APP_MODAL | wxPD_CAN_ABORT | wxPD_SMOOTH); + timer.SetOwner(this); + Bind(wxEVT_TIMER, &CommandProcess::OnPulse, this); + timer.Start(100); + } + + void OnTerminate(int, int status) override { + timer.Stop(); + progress.reset(); + // report outside of wxWidgets' process bookkeeping, which may show dialogs + wxTheApp->CallAfter([done = std::move(done), status, cancelled = cancelled, output = ReadOutput()] { + done(status, cancelled, output); + }); + delete this; + } + + private: + void OnPulse(wxTimerEvent&) { + if (progress && !progress->Pulse() && !cancelled) { + cancelled = true; + wxProcess::Kill(static_cast(GetPid()), wxSIGTERM, wxKILL_CHILDREN); + } + } + + wxString ReadOutput() { + wxString text; +#if _WIN32 + // read only what is there: the command's children may have inherited these pipes and keep them open + std::string bytes; + for (auto stream : {GetInputStream(), GetErrorStream()}) { + char buffer[4096]; + while (stream && stream->CanRead()) { + stream->Read(buffer, sizeof(buffer)); + if (stream->LastRead() == 0) { + break; + } + bytes.append(buffer, stream->LastRead()); + } + } + text = wxString::FromUTF8(bytes); +#else + if (!outputFile.empty()) { + if (wxFFile file(outputFile); file.IsOpened()) { + file.ReadAll(&text, wxConvUTF8); + } + } +#endif + return text.Trim().Trim(false); + } + + CommandDone done; + wxString outputFile; + std::unique_ptr progress; + wxTimer timer; + bool cancelled = false; + }; + + /** + Runs a command without blocking the UI + @param args the program and its arguments + @param done called on the UI thread when the command finishes + @param progress if set, shows a progress window that can cancel the command + */ + void RunCommand(std::vector args, CommandDone done, std::optional progress = std::nullopt) { + const auto program = args.front(); + auto process = new CommandProcess(done); +#if !_WIN32 + // Send the output to a file rather than a pipe: the Editor and the CLI's background + // helpers outlive the CLI, and when a process exits wxWidgets reads its pipes to + // the end, which would block the UI for as long as they hold them open + args.insert(args.begin(), {"/bin/sh", "-c", "out=\"$1\"; shift; exec \"$@\" >\"$out\" 2>&1 OutputTarget()}); +#endif + +#if _WIN32 + std::vector storage; + std::vector argv; + for (const auto& arg : args) { + storage.push_back(arg.ToStdWstring()); + } +#else + std::vector storage; + std::vector argv; + for (const auto& arg : args) { + storage.push_back(arg.utf8_string()); + } +#endif + for (const auto& arg : storage) { + argv.push_back(arg.c_str()); + } + argv.push_back(nullptr); + + int flags = wxEXEC_ASYNC | wxEXEC_HIDE_CONSOLE; + if (progress) { + // so cancelling stops everything the command started + flags |= wxEXEC_MAKE_GROUP_LEADER; + // before starting: the process may be reported finished as soon as it starts + process->ShowProgress(*progress); + } + if (wxExecute(argv.data(), flags, process) == 0) { + delete process; + done(-1, false, "Could not start " + program); + } + } +} + +std::optional UnityCli::Locate() { + std::vector candidates; + // the Hub and the CLI's installer record where the CLI is + if (std::ifstream file{HubDataDirectory() / "cli-install.json"}) { + const std::string json{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (const auto recorded = JsonStringField(json, "path")) { + candidates.emplace_back(std::u8string(recorded->begin(), recorded->end())); + } + } + candidates.push_back(DefaultExecutable()); + + for (const auto& candidate : candidates) { + std::error_code ec; + if (!std::filesystem::is_regular_file(candidate, ec)) { + continue; + } + UnityCli cli{candidate}; + if (const auto version = QueryVersion(candidate)) { + cli.version = *version; + cli.recentEnough = IsAtLeast(*ParseCliVersion(*version), minimumVersion); + } + return cli; + } + return std::nullopt; +} + +std::optional UnityCli::Find() { + auto cli = Locate(); + if (cli && cli->recentEnough) { + return cli; + } + return std::nullopt; +} + +bool UnityCli::CanOpen(const project& p, const editor& e) { + const auto ev = parseVersion(e.name); + // Editors before 2019.1 sign in on their own and never look for a Hub. This also + // rules out editors whose version could not be read. + if (std::tuple(ev.major, ev.minor) < std::tuple(2019, 1)) { + return false; + } + // `unity open` refuses to move an older project onto 6.7 or later (PROJECT_UPGRADE_REQUIRED) + if (std::tuple(ev.major, ev.minor) >= std::tuple(6000, 7)) { + const auto pv = parseVersion(p.version); + if (std::tuple(pv.major, pv.minor, pv.patch, pv.build) < std::tuple(ev.major, ev.minor, ev.patch, ev.build)) { + return false; + } + } + return true; +} + +void UnityCli::Install(wxWindow* parent, std::function done) { + // Unity's official installer, as documented for the CLI. It also records where it + // put the CLI in cli-install.json, which Locate() reads. + // + // The installer defaults to the stable channel, but Unity has published only beta + // versions of the CLI so far, so the stable manifest is missing and that install fails. + // Use the beta channel until a stable release exists. +#if _WIN32 + std::vector args{"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + "if (-not $env:UNITY_CLI_CHANNEL) { " + "try { Invoke-WebRequest -UseBasicParsing -Method Head " + wxString(stableManifestUrl) + " | Out-Null } " + "catch { $env:UNITY_CLI_CHANNEL = 'beta' } " + "}; " + "irm https://unity.com/install.ps1 | iex"}; +#else + // Download with curl or wget, whichever is there (stock Ubuntu desktops have only wget), + // and download first, so a failed download fails the install instead of running an + // empty script. UNITY_NON_INTERACTIVE skips the installer's terminal-only setup questions. + std::vector args{"/bin/sh", "-c", + "fetch() { " + "if command -v curl >/dev/null 2>&1; then curl -fsSL \"$1\" -o \"$2\"; " + "elif command -v wget >/dev/null 2>&1; then wget -qO \"$2\" \"$1\"; " + "else echo 'Installing the Unity CLI needs curl or wget.' >&2; return 127; fi; " + "}; " + "if [ -z \"${UNITY_CLI_CHANNEL:-}\" ] && ! fetch " + wxString(stableManifestUrl) + " /dev/null 2>/dev/null; then " + "export UNITY_CLI_CHANNEL=beta; " + "fi; " + "f=$(mktemp) || exit 1; " + "fetch https://unity.com/install.sh \"$f\" && UNITY_NON_INTERACTIVE=1 bash \"$f\"; " + "s=$?; rm -f \"$f\"; exit $s"}; +#endif + RunCommand(args, [done](int status, bool cancelled, const wxString& output) { + if (status != 0 && !cancelled) { + ShowFailure("Cannot Install Unity CLI", wxString::Format("The Unity CLI installer failed (exit code %d).", status), output); + } + done(status == 0); + }, Progress{parent, "Installing Unity CLI", "Downloading and installing the Unity CLI from unity.com…"}); +} + +void UnityCli::Open(const project& p, const editor& e, std::string_view buildTarget, std::function launchDirectly) const { +#if __APPLE__ + const auto editorPath = e.path; // the Unity.app bundle +#else + const auto editorPath = e.executablePath(); +#endif + std::vector args{ToWx(executable), "open", ToWx(p.path), "--editor-path", ToWx(editorPath)}; + if (!buildTarget.empty()) { + args.insert(args.end(), {"--build-target", wxString::FromUTF8(buildTarget.data(), buildTarget.size())}); + } + // never wait on a prompt nobody can see + args.insert(args.end(), {"--non-interactive", "--no-banner", "--no-color"}); + + RunCommand(args, [projectName = wxString::FromUTF8(p.name), launchDirectly](int status, bool, const wxString& output) { + if (status == 0) { + return; + } + auto message = wxString::Format("Unity CLI could not open \"%s\" (exit code %d).", projectName, status); + if (!output.empty()) { + message += "\n\n" + output; + } + message += "\n\nOpen it directly with the Editor instead? Unity may then start Unity Hub in the background to sign in."; + if (wxMessageBox(message, "Cannot Open Project", wxYES_NO | wxICON_WARNING) == wxYES) { + launchDirectly(); + } + }); +} + +void UnityCli::CheckSignedIn(std::function done) const { + RunCommand({ToWx(executable), "auth", "status", "--json", "--non-interactive", "--no-banner", "--no-color"}, + [done](int status, bool, const wxString& output) { + done(status == 0 && JsonBoolField(output.utf8_string(), "loggedIn").value_or(false)); + }); +} + +void UnityCli::SignIn(wxWindow* parent, std::function done) const { + RunCommand({ToWx(executable), "auth", "login", "--no-banner", "--no-color"}, + [done](int status, bool cancelled, const wxString& output) { + if (status != 0 && !cancelled) { + ShowFailure("Cannot Sign In", wxString::Format("Unity CLI could not sign in (exit code %d).", status), output); + } + done(status == 0); + }, + Progress{parent, "Sign In to Unity", "Your browser opens to sign in to your Unity account.\nFinish signing in there; this window closes when you're done."}); +} diff --git a/source/unity_cli.hpp b/source/unity_cli.hpp new file mode 100644 index 00000000..4366f5ae --- /dev/null +++ b/source/unity_cli.hpp @@ -0,0 +1,70 @@ +// +// unity_cli.hpp +// Unity Hub Native +// +// Opens projects through the official Unity CLI (`unity open`). The CLI serves +// the Editor's sign-in itself, so the Editor starts signed in without the +// official Unity Hub running (or being started in the background). +// + +#pragma once + +#include "globals.h" +#include +#include +#include + +class wxWindow; + +struct UnityCli { + std::filesystem::path executable; + std::string version; + // whether this version can sign the Editor in without the Hub + bool recentEnough = false; + + /** + Finds the installed Unity CLI, whatever its version + @return the CLI, or nullopt if it is not installed + */ + static std::optional Locate(); + + /** + Finds an installed Unity CLI recent enough to sign the Editor in without the Hub + @return the CLI, or nullopt if it is not installed or too old + */ + static std::optional Find(); + + /** + Whether project p should be opened with editor e through the CLI. Older Editors + sign in on their own, and the CLI refuses some version changes. + */ + static bool CanOpen(const project& p, const editor& e); + + /** + Installs or updates the CLI with Unity's official installer, showing a progress window + @param done called with whether the installer succeeded + */ + static void Install(wxWindow* parent, std::function done); + + /** + Opens a project with `unity open` without blocking. If the CLI fails, shows its + message and offers to launch the Editor directly instead. + @param p the project to open + @param e the editor to open it with + @param buildTarget the value to pass to -buildTarget, or empty for none + @param launchDirectly opens the project without the CLI + */ + void Open(const project& p, const editor& e, std::string_view buildTarget, std::function launchDirectly) const; + + /** + Checks whether the CLI is signed in to a Unity account, without blocking + @param done called with true if it is signed in + */ + void CheckSignedIn(std::function done) const; + + /** + Signs the CLI in through the browser (`unity auth login`), showing a progress window the user can cancel + @param done called with whether the user signed in + */ + void SignIn(wxWindow* parent, std::function done) const; +}; From 31f5bee52df5fe438e19fee41d306f575600e618 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 12:09:46 +0100 Subject: [PATCH 02/16] feat: Changed text formatting on the CLI installation popup --- source/interface_derived.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index c8cfc7f9..9d79fcad 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -562,13 +562,18 @@ void MainFrameDerived::OfferCliInstall(){ const wxString status = cli ? wxString::Format("The Unity CLI on this computer (version %s) is too old for this.", cli->version.empty() ? "unknown" : cli->version) : wxString("The Unity CLI isn't installed yet."); + // wxRichMessageDialog lays this out with wxTextSizerWrapper, which drops empty + // lines, so a blank line has to contain a character or the gap disappears. + const wxString paragraphGap = "\n \n"; const wxString message = "Unity Hub Native can open your projects signed in to your Unity account, so the Asset Store, " "Package Manager and other Unity services work, without starting the official Unity Hub. " - "It does this through Unity's official command-line tool, the Unity CLI.\n\n" + status + "\n\n" + "It does this through Unity's official command-line tool, the Unity CLI." + + paragraphGap + " > " + status + paragraphGap + "Installing runs Unity's installer from unity.com. It puts the unity command in your user folder " - "and adds it to your shell's PATH. After that, you sign in to your Unity account once in your browser.\n\n" - "You can also do this later from the Editor Versions tab."; + "and adds it to your shell's PATH. After that, you sign in to your Unity account once in your browser." + + paragraphGap + + "You can also do this later from the Editor Versions tab." + paragraphGap; wxRichMessageDialog dialog(this, message, "Open Projects Signed In", wxYES_NO | wxICON_INFORMATION); dialog.SetYesNoLabels(cli ? "Update CLI" : "Install CLI", "Not Now"); From 2544dab140855e32538c7cc1d81c2f48de657b4a Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 12:45:34 +0100 Subject: [PATCH 03/16] feat: Added message about sudo password --- source/unity_cli.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index f1a21571..55032393 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -194,7 +194,7 @@ namespace { void ShowFailure(const wxString& title, const wxString& summary, wxString output) { constexpr size_t maxLength = 2000; if (output.length() > maxLength) { - output = "…" + output.Right(maxLength); + output = wxString::FromUTF8("…") + output.Right(maxLength); } wxMessageBox(output.empty() ? summary : summary + "\n\n" + output, title, wxOK | wxICON_ERROR); } @@ -424,12 +424,21 @@ void UnityCli::Install(wxWindow* parent, std::function done) { "fetch https://unity.com/install.sh \"$f\" && UNITY_NON_INTERACTIVE=1 bash \"$f\"; " "s=$?; rm -f \"$f\"; exit $s"}; #endif + // a plain literal holding "…" converts to an empty wxString unless the locale is UTF-8, so convert explicitly + const wxString message = wxString::FromUTF8( +#if _WIN32 + "Downloading and installing the Unity CLI from unity.com…" +#else + "Downloading and installing the Unity CLI from unity.com…\n" + "You may be asked for your sudo password a few times." +#endif + ); RunCommand(args, [done](int status, bool cancelled, const wxString& output) { if (status != 0 && !cancelled) { ShowFailure("Cannot Install Unity CLI", wxString::Format("The Unity CLI installer failed (exit code %d).", status), output); } done(status == 0); - }, Progress{parent, "Installing Unity CLI", "Downloading and installing the Unity CLI from unity.com…"}); + }, Progress{parent, "Installing Unity CLI", message}); } void UnityCli::Open(const project& p, const editor& e, std::string_view buildTarget, std::function launchDirectly) const { From b5576484a9c056ac35e61f4ae6a506c29f41f4da Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 12:53:39 +0100 Subject: [PATCH 04/16] fix: Fixed lost SIGCHLD wake-up that left Unity CLI commands hanging wxWakeUpPipe::WakeUpNoLock() wrote to the pipe and only then cleared m_pipeIsEmpty. When the main thread drained the pipe in between, the flag stayed false forever, so no later SIGCHLD was written to the pipe. Child processes were never reaped and wxExecute() waited for them indefinitely, which left opening a project through the Unity CLI stuck. Make the flag atomic and claim it before writing. --- wxWidgets/include/wx/unix/private/wakeuppipe.h | 12 ++++++++++-- wxWidgets/src/unix/wakeuppipe.cpp | 13 ++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/wxWidgets/include/wx/unix/private/wakeuppipe.h b/wxWidgets/include/wx/unix/private/wakeuppipe.h index d6bf4c33..3cafaf2c 100644 --- a/wxWidgets/include/wx/unix/private/wakeuppipe.h +++ b/wxWidgets/include/wx/unix/private/wakeuppipe.h @@ -13,6 +13,8 @@ #include "wx/unix/pipe.h" #include "wx/evtloopsrc.h" +#include + // ---------------------------------------------------------------------------- // wxWakeUpPipe: allows to wake up the event loop by writing to it // ---------------------------------------------------------------------------- @@ -51,11 +53,17 @@ class wxWakeUpPipe : public wxEventLoopSourceHandler private: wxPipe m_pipe; - // This flag is set to true after writing to the pipe and reset to false + // This flag is set to false before writing to the pipe and reset to true // after reading from it in the main thread. Having it allows us to avoid // overflowing the pipe with too many writes if the main thread can't keep // up with reading from it. - bool m_pipeIsEmpty; + // + // It is atomic because WakeUpNoLock() is also called from signal handlers, + // which can run on any thread, concurrently with OnReadWaiting(). It must + // be cleared before writing: clearing it after the write let the main + // thread drain the pipe in between, leaving the flag wrongly false for good, + // so that no later wake up (e.g. SIGCHLD) was ever written to the pipe. + std::atomic m_pipeIsEmpty; }; // ---------------------------------------------------------------------------- diff --git a/wxWidgets/src/unix/wakeuppipe.cpp b/wxWidgets/src/unix/wakeuppipe.cpp index d3af838f..4073001c 100644 --- a/wxWidgets/src/unix/wakeuppipe.cpp +++ b/wxWidgets/src/unix/wakeuppipe.cpp @@ -67,8 +67,9 @@ wxWakeUpPipe::wxWakeUpPipe() void wxWakeUpPipe::WakeUpNoLock() { - // No need to do anything if the pipe already contains something. - if ( !m_pipeIsEmpty ) + // No need to do anything if the pipe already contains something. Claim the + // right to write before writing, see the comment on m_pipeIsEmpty. + if ( !m_pipeIsEmpty.exchange(false) ) return; if ( write(m_pipe[wxPipe::Write], "s", 1) != 1 ) @@ -76,11 +77,9 @@ void wxWakeUpPipe::WakeUpNoLock() // don't use wxLog here, we can be in another thread and this could // result in dead locks perror("write(wake up pipe)"); - } - else - { - // We just wrote to it, so it's not empty any more. - m_pipeIsEmpty = false; + + // Nothing was written, so allow trying again. + m_pipeIsEmpty = true; } } From d9bd1bdb9aaeb4ced16ff1d29ebc25bd24406ac5 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:45:56 +0100 Subject: [PATCH 05/16] fix: Fixed use-after-free when a Unity CLI command ends during a progress pulse wxProgressDialog::Pulse() yields to the event loop, which on GTK may handle the command's SIGCHLD, so OnTerminate could destroy the progress window and the CommandProcess while Pulse() and OnPulse() were still using them. Close the window and delete the process from CallAfter instead. --- source/unity_cli.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index 55032393..c18837f4 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -245,12 +245,16 @@ namespace { void OnTerminate(int, int status) override { timer.Stop(); - progress.reset(); - // report outside of wxWidgets' process bookkeeping, which may show dialogs - wxTheApp->CallAfter([done = std::move(done), status, cancelled = cancelled, output = ReadOutput()] { + // Close the progress window, report and delete this later, from the event loop. + // This can run inside progress->Pulse(): it yields to the event loop, which may + // handle the command's exit then, and both still use the window and this object + // once it returns. Reporting later also keeps any dialogs that done() shows out + // of wxWidgets' process bookkeeping. + wxTheApp->CallAfter([this, status, output = ReadOutput()] { + progress.reset(); done(status, cancelled, output); + delete this; }); - delete this; } private: From 12f91d9cb30cfd5056df62a725b7ea45a7937179 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:46:16 +0100 Subject: [PATCH 06/16] feat: Escape closes the Unity CLI install prompt as Not Now --- source/interface_derived.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index 9d79fcad..99053a8d 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -578,6 +578,8 @@ void MainFrameDerived::OfferCliInstall(){ wxRichMessageDialog dialog(this, message, "Open Projects Signed In", wxYES_NO | wxICON_INFORMATION); dialog.SetYesNoLabels(cli ? "Update CLI" : "Install CLI", "Not Now"); dialog.ShowCheckBox("Don't show this again"); + // Escape (and closing the window) answers Not Now; with no Cancel button it would do nothing + dialog.SetEscapeId(wxID_NO); const bool install = dialog.ShowModal() == wxID_YES; if (dialog.IsCheckBoxChecked()) { config.Write(config_hideCliPrompt.data(), true); From 89b526f380ca7daac1d732697379a9383170492a Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:46:51 +0100 Subject: [PATCH 07/16] refactor: Moved the window minimum size code out of the main window constructor --- source/interface_derived.cpp | 50 +++++++++++++++++++----------------- source/interface_derived.hpp | 1 + 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index 99053a8d..5ae263d5 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -119,36 +119,38 @@ MainFrameDerived::MainFrameDerived() : MainFrame(NULL){ projSearchCtrl->Bind(wxEVT_KEY_UP, &MainFrameDerived::Filter, this); projSearchCtrl->SetFocus(); - // Don't let the window get smaller than its tabs need to show all of their buttons - { - // Lists scroll, so they shouldn't make the window wider for long names and paths - constexpr int listMinWidth = 150; - for (wxWindow* list : {static_cast(projectsList), static_cast(installsList), static_cast(installsPathsList)}) { - list->SetMinSize(wxSize(listMinWidth, list->GetMinSize().y)); - } - wxSize pageMin; - for (size_t i = 0; i < notebook->GetPageCount(); i++) { - if (const auto sizer = notebook->GetPage(i)->GetSizer()) { - pageMin.IncTo(sizer->GetMinSize()); - } - } - constexpr int notebookBorder = 5; // around the notebook in the frame's sizer - const wxSize clientMin = notebook->CalcSizeFromPage(pageMin) + wxSize(2 * notebookBorder, 2 * notebookBorder); - wxSize windowMin = ClientToWindowSize(clientMin); - windowMin.IncTo(GetMinSize()); - SetMinSize(windowMin); - wxSize size = GetSize(); - if (!(size.x >= windowMin.x && size.y >= windowMin.y)) { - size.IncTo(windowMin); - SetSize(size); - } - } + FitMinSizeToTabs(); RefreshCliState(); // once the window is on screen CallAfter(&MainFrameDerived::OfferCliInstall); } +/** + Keeps the window from getting smaller than its tabs need to show all of their buttons + */ +void MainFrameDerived::FitMinSizeToTabs(){ + // Lists scroll, so they shouldn't make the window wider for long names and paths + constexpr int listMinWidth = 150; + for (wxWindow* list : std::initializer_list{projectsList, installsList, installsPathsList}) { + list->SetMinSize(wxSize(listMinWidth, list->GetMinSize().y)); + } + wxSize pageMin; + for (size_t i = 0; i < notebook->GetPageCount(); i++) { + if (const auto sizer = notebook->GetPage(i)->GetSizer()) { + pageMin.IncTo(sizer->GetMinSize()); + } + } + constexpr int notebookBorder = 5; // around the notebook in the frame's sizer + const wxSize clientMin = notebook->CalcSizeFromPage(pageMin) + wxSize(2 * notebookBorder, 2 * notebookBorder); + wxSize windowMin = ClientToWindowSize(clientMin); + windowMin.IncTo(GetMinSize()); + SetMinSize(windowMin); + wxSize size = GetSize(); + size.IncTo(windowMin); + SetSize(size); +} + void MainFrameDerived::OnSelectProject(wxListEvent&){ for (auto ptr : projectActionItems){ ptr->Enable(); diff --git a/source/interface_derived.hpp b/source/interface_derived.hpp index b1308ce3..aa466300 100644 --- a/source/interface_derived.hpp +++ b/source/interface_derived.hpp @@ -61,6 +61,7 @@ class MainFrameDerived : public MainFrame{ void OnSelectEditorPath(wxCommandEvent&); void OnQuit(wxCommandEvent&); + void FitMinSizeToTabs(); // Unity CLI: opens projects signed in without the official Hub void OnInstallCli(wxCommandEvent&); From 49731b0b63ea2caa62a20e050a03795b6e927475 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:47:23 +0100 Subject: [PATCH 08/16] docs: Recorded the local wxWidgets wake-up pipe patch Marks the patched code and keeps the patch under patches/wxWidgets, so a future wxWidgets update doesn't silently drop it. --- patches/wxWidgets/README.md | 26 +++++++ .../wxWidgets/wakeuppipe-lost-wakeup.patch | 68 +++++++++++++++++++ .../include/wx/unix/private/wakeuppipe.h | 3 + wxWidgets/src/unix/wakeuppipe.cpp | 1 + 4 files changed, 98 insertions(+) create mode 100644 patches/wxWidgets/README.md create mode 100644 patches/wxWidgets/wakeuppipe-lost-wakeup.patch diff --git a/patches/wxWidgets/README.md b/patches/wxWidgets/README.md new file mode 100644 index 00000000..18dbf517 --- /dev/null +++ b/patches/wxWidgets/README.md @@ -0,0 +1,26 @@ +# Local wxWidgets patches + +`wxWidgets/` is a copy of the wxWidgets sources with the changes below applied on top. +When you update wxWidgets, re-apply each patch from the repository root, then check that +it is still needed: + +```sh +git apply patches/wxWidgets/.patch +``` + +Each patched spot in the wxWidgets sources is marked with a `UnityHubNative patch` comment. + +## wakeuppipe-lost-wakeup.patch + +Files: `src/unix/wakeuppipe.cpp`, `include/wx/unix/private/wakeuppipe.h` (macOS and Linux). + +`wxWakeUpPipe::WakeUpNoLock()` wrote to the pipe and only then marked it as not empty. If the +main thread drained the pipe in between, the flag stayed "not empty" for good, and every later +wake up was skipped. Signals such as SIGCHLD wake the event loop through this pipe, so wxWidgets +stopped noticing that child processes had exited, and Unity CLI commands seemed to hang forever. + +The patch makes the flag atomic, because signal handlers also call `WakeUpNoLock()`, and claims +it before writing. + +Without it, asynchronous `wxExecute` calls (all Unity CLI commands, see `source/unity_cli.cpp`) +can stop reporting that they finished. diff --git a/patches/wxWidgets/wakeuppipe-lost-wakeup.patch b/patches/wxWidgets/wakeuppipe-lost-wakeup.patch new file mode 100644 index 00000000..665f9f87 --- /dev/null +++ b/patches/wxWidgets/wakeuppipe-lost-wakeup.patch @@ -0,0 +1,68 @@ +diff --git a/wxWidgets/include/wx/unix/private/wakeuppipe.h b/wxWidgets/include/wx/unix/private/wakeuppipe.h +index d6bf4c33..db46e90e 100644 +--- a/wxWidgets/include/wx/unix/private/wakeuppipe.h ++++ b/wxWidgets/include/wx/unix/private/wakeuppipe.h +@@ -13,6 +13,8 @@ + #include "wx/unix/pipe.h" + #include "wx/evtloopsrc.h" + ++#include ++ + // ---------------------------------------------------------------------------- + // wxWakeUpPipe: allows to wake up the event loop by writing to it + // ---------------------------------------------------------------------------- +@@ -51,11 +53,20 @@ public: + private: + wxPipe m_pipe; + +- // This flag is set to true after writing to the pipe and reset to false ++ // UnityHubNative patch (see patches/wxWidgets/README.md): made atomic and ++ // cleared before writing, to fix a lost wake up. ++ // ++ // This flag is set to false before writing to the pipe and reset to true + // after reading from it in the main thread. Having it allows us to avoid + // overflowing the pipe with too many writes if the main thread can't keep + // up with reading from it. +- bool m_pipeIsEmpty; ++ // ++ // It is atomic because WakeUpNoLock() is also called from signal handlers, ++ // which can run on any thread, concurrently with OnReadWaiting(). It must ++ // be cleared before writing: clearing it after the write let the main ++ // thread drain the pipe in between, leaving the flag wrongly false for good, ++ // so that no later wake up (e.g. SIGCHLD) was ever written to the pipe. ++ std::atomic m_pipeIsEmpty; + }; + + // ---------------------------------------------------------------------------- +diff --git a/wxWidgets/src/unix/wakeuppipe.cpp b/wxWidgets/src/unix/wakeuppipe.cpp +index d3af838f..b6bddfab 100644 +--- a/wxWidgets/src/unix/wakeuppipe.cpp ++++ b/wxWidgets/src/unix/wakeuppipe.cpp +@@ -67,8 +67,10 @@ wxWakeUpPipe::wxWakeUpPipe() + + void wxWakeUpPipe::WakeUpNoLock() + { +- // No need to do anything if the pipe already contains something. +- if ( !m_pipeIsEmpty ) ++ // UnityHubNative patch (see patches/wxWidgets/README.md): fixes a lost wake up ++ // No need to do anything if the pipe already contains something. Claim the ++ // right to write before writing, see the comment on m_pipeIsEmpty. ++ if ( !m_pipeIsEmpty.exchange(false) ) + return; + + if ( write(m_pipe[wxPipe::Write], "s", 1) != 1 ) +@@ -76,11 +78,9 @@ void wxWakeUpPipe::WakeUpNoLock() + // don't use wxLog here, we can be in another thread and this could + // result in dead locks + perror("write(wake up pipe)"); +- } +- else +- { +- // We just wrote to it, so it's not empty any more. +- m_pipeIsEmpty = false; ++ ++ // Nothing was written, so allow trying again. ++ m_pipeIsEmpty = true; + } + } + diff --git a/wxWidgets/include/wx/unix/private/wakeuppipe.h b/wxWidgets/include/wx/unix/private/wakeuppipe.h index 3cafaf2c..db46e90e 100644 --- a/wxWidgets/include/wx/unix/private/wakeuppipe.h +++ b/wxWidgets/include/wx/unix/private/wakeuppipe.h @@ -53,6 +53,9 @@ class wxWakeUpPipe : public wxEventLoopSourceHandler private: wxPipe m_pipe; + // UnityHubNative patch (see patches/wxWidgets/README.md): made atomic and + // cleared before writing, to fix a lost wake up. + // // This flag is set to false before writing to the pipe and reset to true // after reading from it in the main thread. Having it allows us to avoid // overflowing the pipe with too many writes if the main thread can't keep diff --git a/wxWidgets/src/unix/wakeuppipe.cpp b/wxWidgets/src/unix/wakeuppipe.cpp index 4073001c..b6bddfab 100644 --- a/wxWidgets/src/unix/wakeuppipe.cpp +++ b/wxWidgets/src/unix/wakeuppipe.cpp @@ -67,6 +67,7 @@ wxWakeUpPipe::wxWakeUpPipe() void wxWakeUpPipe::WakeUpNoLock() { + // UnityHubNative patch (see patches/wxWidgets/README.md): fixes a lost wake up // No need to do anything if the pipe already contains something. Claim the // right to write before writing, see the comment on m_pipeIsEmpty. if ( !m_pipeIsEmpty.exchange(false) ) From 328b57c10a8937b078f1b06a935268279c7005f1 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:47:35 +0100 Subject: [PATCH 09/16] build: Existing build directories pick up new source files --- CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 13a55dd8..f5cf5233 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,8 @@ endif() set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/wxWidgets/build/cmake/modules" CACHE INTERNAL "") message("module path = ${CMAKE_MODULE_PATH}") -file(GLOB source "source/*.cpp" "source/*.hpp" "source/*.h") +# CONFIGURE_DEPENDS: existing build directories pick up added or removed source files +file(GLOB source CONFIGURE_DEPENDS "source/*.cpp" "source/*.hpp" "source/*.h") if (APPLE) enable_language(C CXX OBJCXX) set(OBJCPP "source/AppleUtilities.h" "source/AppleUtilities.mm") From 662b7fcdb1fab65df9f9f88fb5e8cd3df1db3442 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:48:25 +0100 Subject: [PATCH 10/16] refactor: Shared one home folder lookup on macOS and Linux globals.h looked the home folder up three times and the Unity CLI code a fourth time, none checking that getpwuid() succeeded. Look it up once, falling back to $HOME. --- source/globals.h | 24 ++++++++++++++++-------- source/unity_cli.cpp | 4 +++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/source/globals.h b/source/globals.h index 961f0561..c65e0000 100644 --- a/source/globals.h +++ b/source/globals.h @@ -19,13 +19,23 @@ static constexpr std::string_view AppVersion = "v1.59"; struct wxListCtrl; struct wxWindow; -#if defined __APPLE__ +#if defined __APPLE__ || defined __linux__ #include - #include + #include + #include + //the user's home folder + static const std::filesystem::path homedir = [] { + const passwd* user = getpwuid(getuid()); + const char* home = user ? user->pw_dir : getenv("HOME"); + return std::filesystem::path(home ? home : ""); + }(); +#endif + +#if defined __APPLE__ //the location to store application data - static const std::filesystem::path datapath = std::filesystem::path(getpwuid(getuid())->pw_dir) / "Library/Application Support/UnityHubNative"; + static const std::filesystem::path datapath = homedir / "Library/Application Support/UnityHubNative"; - static const std::filesystem::path cachedir = std::filesystem::path(getpwuid(getuid())->pw_dir) / "/Library/Caches/com.ravbug.UnityHubNative/"; + static const std::filesystem::path cachedir = homedir / "/Library/Caches/com.ravbug.UnityHubNative/"; static constexpr std::string_view installerExt = "dmg"; //where to find various Unity things on macOS @@ -68,13 +78,11 @@ struct wxWindow; void dpi_scale(wxWindow* window); #elif defined __linux__ - #include - #include - static const std::filesystem::path datapath = std::filesystem::path(getpwuid(getuid())->pw_dir) / "UnityHubNative"; + static const std::filesystem::path datapath = homedir / "UnityHubNative"; static constexpr std::string_view null_device = ">/dev/null 2>&1"; static const std::filesystem::path executable = "Editor/Unity"; - static const std::vector defaultInstall = {std::filesystem::path(getpwuid(getuid())->pw_dir) / "Unity/Hub/Editor"}; + static const std::vector defaultInstall = {homedir / "Unity/Hub/Editor"}; //TODO: make this a preference? static const std::filesystem::path hubDefault = ""; static const std::filesystem::path templatesDir = "Editor/Data/Resources/PackageManager/ProjectTemplates/"; diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index c18837f4..29de8b5d 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -74,12 +74,14 @@ namespace { #endif } + // The user's home folder. On Windows, USERPROFILE is more reliable than globals.h's + // HOMEDRIVE and HOMEPATH, which can point at a network share. std::filesystem::path HomeDirectory() { #if _WIN32 const wchar_t* profile = _wgetenv(L"USERPROFILE"); return profile ? std::filesystem::path(profile) : homedir; #else - return std::filesystem::path(getpwuid(getuid())->pw_dir); + return homedir; #endif } From 81fd1527a6c0d9bc38e0f8a85ec5804f9c0786a0 Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:49:33 +0100 Subject: [PATCH 11/16] fix: Read Unity CLI JSON values by their full path The JSON helpers took the first matching key anywhere in the document, so a nested "path" in cli-install.json could shadow the real one. Look values up by the path of member names instead, e.g. data.loggedIn for auth status. --- source/unity_cli.cpp | 89 +++++++++++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index 29de8b5d..85fff132 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -111,30 +111,77 @@ namespace { #endif } - // Finds where `"key"` and its following colon end, in a small JSON document - std::optional JsonValueStart(const std::string& json, std::string_view key) { - const std::string quotedKey = "\"" + std::string(key) + "\""; - auto pos = json.find(quotedKey); - if (pos == std::string::npos) { - return std::nullopt; + /* + A minimal JSON reader, enough for cli-install.json and `unity auth status --json`. + Values are found by the path of member names leading to them, so a nested member + with the same name is never mistaken for the one asked for. + */ + + std::optional SkipJsonSpace(const std::string& json, size_t pos) { + pos = json.find_first_not_of(" \t\r\n", pos); + return pos == std::string::npos ? std::nullopt : std::optional(pos); + } + + // Finds the closing quote of the JSON string whose opening quote is at open + std::optional JsonStringEnd(const std::string& json, size_t open) { + for (auto pos = open + 1; pos < json.size(); ++pos) { + if (json[pos] == '\\') { + ++pos; + } + else if (json[pos] == '"') { + return pos; + } } - pos = json.find_first_not_of(" \t\r\n", pos + quotedKey.size()); - if (pos == std::string::npos || json[pos] != ':') { - return std::nullopt; + return std::nullopt; + } + + // Finds where the value of the object's own member key starts, in the JSON object whose '{' is at objectStart + std::optional JsonMemberStart(const std::string& json, size_t objectStart, std::string_view key) { + int depth = 0; + for (auto pos = objectStart; pos < json.size(); ++pos) { + const char c = json[pos]; + if (c == '{' || c == '[') { + ++depth; + } + else if (c == '}' || c == ']') { + if (--depth == 0) { + return std::nullopt; + } + } + else if (c == '"') { + const auto end = JsonStringEnd(json, pos); + if (!end) { + return std::nullopt; + } + // a member name is followed by a colon, a string value is not + const auto next = SkipJsonSpace(json, *end + 1); + if (depth == 1 && next && json[*next] == ':' && json.compare(pos + 1, *end - pos - 1, key) == 0) { + return SkipJsonSpace(json, *next + 1); + } + pos = *end; + } } - pos = json.find_first_not_of(" \t\r\n", pos + 1); - if (pos == std::string::npos) { - return std::nullopt; + return std::nullopt; + } + + // Finds where the value at path starts, e.g. {"data", "loggedIn"} for {"data": {"loggedIn": true}} + std::optional JsonValueStart(const std::string& json, std::initializer_list path) { + auto pos = SkipJsonSpace(json, 0); + for (const auto key : path) { + if (!pos || json[*pos] != '{') { + return std::nullopt; + } + pos = JsonMemberStart(json, *pos, key); } return pos; } /** - Reads a string value from a small JSON document, enough for cli-install.json - @return the unescaped value, or nullopt if the key is missing or not a string + Reads a string value from a JSON document + @return the unescaped value, or nullopt if it is missing or not a string */ - std::optional JsonStringField(const std::string& json, std::string_view key) { - const auto start = JsonValueStart(json, key); + std::optional JsonStringField(const std::string& json, std::initializer_list path) { + const auto start = JsonValueStart(json, path); if (!start || json[*start] != '"') { return std::nullopt; } @@ -162,9 +209,9 @@ namespace { return std::nullopt; } - // Reads a boolean value from a small JSON document - std::optional JsonBoolField(const std::string& json, std::string_view key) { - const auto start = JsonValueStart(json, key); + // Reads a boolean value from a JSON document + std::optional JsonBoolField(const std::string& json, std::initializer_list path) { + const auto start = JsonValueStart(json, path); if (!start) { return std::nullopt; } @@ -353,7 +400,7 @@ std::optional UnityCli::Locate() { // the Hub and the CLI's installer record where the CLI is if (std::ifstream file{HubDataDirectory() / "cli-install.json"}) { const std::string json{std::istreambuf_iterator(file), std::istreambuf_iterator()}; - if (const auto recorded = JsonStringField(json, "path")) { + if (const auto recorded = JsonStringField(json, {"path"})) { candidates.emplace_back(std::u8string(recorded->begin(), recorded->end())); } } @@ -478,7 +525,7 @@ void UnityCli::Open(const project& p, const editor& e, std::string_view buildTar void UnityCli::CheckSignedIn(std::function done) const { RunCommand({ToWx(executable), "auth", "status", "--json", "--non-interactive", "--no-banner", "--no-color"}, [done](int status, bool, const wxString& output) { - done(status == 0 && JsonBoolField(output.utf8_string(), "loggedIn").value_or(false)); + done(status == 0 && JsonBoolField(output.utf8_string(), {"data", "loggedIn"}).value_or(false)); }); } From 9bf5250dbda272be4ad93be7f249071b8912fddb Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:50:25 +0100 Subject: [PATCH 12/16] refactor: Wrote the Unity CLI install script as a raw string literal The script was spliced together from many string literals around the manifest URL. Pass the URL as an argument instead. --- source/unity_cli.cpp | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index 85fff132..986fdc79 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -464,18 +464,24 @@ void UnityCli::Install(wxWindow* parent, std::function done) { // Download with curl or wget, whichever is there (stock Ubuntu desktops have only wget), // and download first, so a failed download fails the install instead of running an // empty script. UNITY_NON_INTERACTIVE skips the installer's terminal-only setup questions. - std::vector args{"/bin/sh", "-c", - "fetch() { " - "if command -v curl >/dev/null 2>&1; then curl -fsSL \"$1\" -o \"$2\"; " - "elif command -v wget >/dev/null 2>&1; then wget -qO \"$2\" \"$1\"; " - "else echo 'Installing the Unity CLI needs curl or wget.' >&2; return 127; fi; " - "}; " - "if [ -z \"${UNITY_CLI_CHANNEL:-}\" ] && ! fetch " + wxString(stableManifestUrl) + " /dev/null 2>/dev/null; then " - "export UNITY_CLI_CHANNEL=beta; " - "fi; " - "f=$(mktemp) || exit 1; " - "fetch https://unity.com/install.sh \"$f\" && UNITY_NON_INTERACTIVE=1 bash \"$f\"; " - "s=$?; rm -f \"$f\"; exit $s"}; + constexpr const char* script = R"sh( + stable_manifest="$1" + fetch() { + if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1" + else echo 'Installing the Unity CLI needs curl or wget.' >&2; return 127 + fi + } + if [ -z "${UNITY_CLI_CHANNEL:-}" ] && ! fetch "$stable_manifest" /dev/null 2>/dev/null; then + export UNITY_CLI_CHANNEL=beta + fi + f=$(mktemp) || exit 1 + fetch https://unity.com/install.sh "$f" && UNITY_NON_INTERACTIVE=1 bash "$f" + s=$? + rm -f "$f" + exit $s + )sh"; + std::vector args{"/bin/sh", "-c", script, "sh", stableManifestUrl}; #endif // a plain literal holding "…" converts to an empty wxString unless the locale is UTF-8, so convert explicitly const wxString message = wxString::FromUTF8( From 50dd785b3350bcc3f4945e53fd085c09894cddae Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:53:27 +0100 Subject: [PATCH 13/16] refactor: Modelled the Unity CLI as one typed status, probed once per refresh Locate(), Find() and recentEnough described three states (missing, too old, ready) that every caller rebuilt by hand, and each lookup ran `unity --version` again: twice at startup and once per project open. Probe() now returns a UnityCliStatus that holds the CLI only when it is ready, and the main window keeps the last one, refreshed on startup, Reload and after installing or signing in. --- source/interface_derived.cpp | 46 +++++++++++++++++++----------------- source/interface_derived.hpp | 4 ++++ source/unity_cli.cpp | 33 +++++++++++--------------- source/unity_cli.hpp | 30 +++++++++++++---------- 4 files changed, 60 insertions(+), 53 deletions(-) diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index 5ae263d5..23ed32f8 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -517,31 +517,35 @@ void MainFrameDerived::OpenProject(const project& p, const editor& e, TargetPlat // Launched directly, the Editor starts signed out and starts the official Hub to sign in. // The Unity CLI serves the Editor's sign-in itself, without the Hub. - if (UnityCli::CanOpen(p, e)) { - if (const auto cli = UnityCli::Find()) { - cli->Open(p, e, buildTarget, launchDirectly); - return; - } + if (cliStatus.cli && UnityCli::CanOpen(p, e)) { + cliStatus.cli->Open(p, e, buildTarget, launchDirectly); + return; } launchDirectly(); } /** - Updates the Install CLI and Login CLI buttons. Install is available while there is no - recent enough CLI, Login while the CLI is not signed in. + The label for installing the CLI, or updating it if it is too old + */ +const char* MainFrameDerived::InstallCliLabel() const{ + return cliStatus.state == UnityCliStatus::State::Outdated ? "Update CLI" : "Install CLI"; +} + +/** + Looks for the Unity CLI again, and updates the Install CLI and Login CLI buttons. Install + is available while there is no recent enough CLI, Login while the CLI is not signed in. */ void MainFrameDerived::RefreshCliState(){ - const auto cli = UnityCli::Locate(); - const bool usable = cli && cli->recentEnough; - installCliBtn->SetLabel(cli && !usable ? "Update CLI" : "Install CLI"); - installCliBtn->Enable(!usable); + cliStatus = UnityCli::Probe(); + installCliBtn->SetLabel(InstallCliLabel()); + installCliBtn->Enable(!cliStatus.cli); loginCliBtn->Enable(false); installCliBtn->GetParent()->Layout(); - if (!usable) { + if (!cliStatus.cli) { return; } wxWeakRef self(this); - cli->CheckSignedIn([self](bool signedIn){ + cliStatus.cli->CheckSignedIn([self](bool signedIn){ if (self) { self->loginCliBtn->Enable(!signedIn); } @@ -556,13 +560,12 @@ void MainFrameDerived::OfferCliInstall(){ if (config.ReadBool(config_hideCliPrompt.data(), false)) { return; } - const auto cli = UnityCli::Locate(); - if (cli && cli->recentEnough) { + if (cliStatus.cli) { return; } - const wxString status = cli - ? wxString::Format("The Unity CLI on this computer (version %s) is too old for this.", cli->version.empty() ? "unknown" : cli->version) + const wxString status = cliStatus.state == UnityCliStatus::State::Outdated + ? wxString::Format("The Unity CLI on this computer (version %s) is too old for this.", cliStatus.version.empty() ? "unknown" : cliStatus.version) : wxString("The Unity CLI isn't installed yet."); // wxRichMessageDialog lays this out with wxTextSizerWrapper, which drops empty // lines, so a blank line has to contain a character or the gap disappears. @@ -578,7 +581,7 @@ void MainFrameDerived::OfferCliInstall(){ "You can also do this later from the Editor Versions tab." + paragraphGap; wxRichMessageDialog dialog(this, message, "Open Projects Signed In", wxYES_NO | wxICON_INFORMATION); - dialog.SetYesNoLabels(cli ? "Update CLI" : "Install CLI", "Not Now"); + dialog.SetYesNoLabels(InstallCliLabel(), "Not Now"); dialog.ShowCheckBox("Don't show this again"); // Escape (and closing the window) answers Not Now; with no Cancel button it would do nothing dialog.SetEscapeId(wxID_NO); @@ -604,7 +607,7 @@ void MainFrameDerived::InstallCli(){ if (!installed) { return; } - const auto cli = UnityCli::Find(); + const auto& cli = self->cliStatus.cli; if (!cli) { wxMessageBox("The installer finished, but Unity Hub Native cannot find a recent enough Unity CLI.", "Cannot Install Unity CLI", wxOK | wxICON_ERROR, self.get()); return; @@ -627,13 +630,12 @@ void MainFrameDerived::InstallCli(){ Signs the Unity CLI in to the user's Unity account through the browser */ void MainFrameDerived::LoginCli(){ - const auto cli = UnityCli::Find(); - if (!cli) { + if (!cliStatus.cli) { RefreshCliState(); return; } wxWeakRef self(this); - cli->SignIn(this, [self](bool){ + cliStatus.cli->SignIn(this, [self](bool){ if (self) { self->RefreshCliState(); } diff --git a/source/interface_derived.hpp b/source/interface_derived.hpp index aa466300..05236b11 100644 --- a/source/interface_derived.hpp +++ b/source/interface_derived.hpp @@ -10,6 +10,7 @@ #include "globals.h" #include "interface.h" +#include "unity_cli.hpp" #include #include #include @@ -70,6 +71,9 @@ class MainFrameDerived : public MainFrame{ void LoginCli(); void OfferCliInstall(); void RefreshCliState(); + const char* InstallCliLabel() const; + // the Unity CLI found by the last RefreshCliState() + UnityCliStatus cliStatus; wxWindow* const projectActionItems[3]{ revealProjBtn, diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index 986fdc79..4ac0da88 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -224,7 +224,8 @@ namespace { return std::nullopt; } - std::optional QueryVersion(const std::filesystem::path& cli) { + // Runs the CLI to read its version + std::optional> QueryVersion(const std::filesystem::path& cli) { wxArrayString output, errors; const auto command = "\"" + ToWx(cli) + "\" --version"; if (wxExecute(command, output, errors, wxEXEC_NODISABLE | wxEXEC_HIDE_CONSOLE) != 0) { @@ -232,8 +233,8 @@ namespace { } for (auto& line : output) { const auto text = line.Trim().Trim(false).utf8_string(); - if (ParseCliVersion(text)) { - return text; + if (const auto version = ParseCliVersion(text)) { + return std::pair(*version, text); } } return std::nullopt; @@ -395,7 +396,7 @@ namespace { } } -std::optional UnityCli::Locate() { +UnityCliStatus UnityCli::Probe() { std::vector candidates; // the Hub and the CLI's installer record where the CLI is if (std::ifstream file{HubDataDirectory() / "cli-install.json"}) { @@ -411,22 +412,16 @@ std::optional UnityCli::Locate() { if (!std::filesystem::is_regular_file(candidate, ec)) { continue; } - UnityCli cli{candidate}; - if (const auto version = QueryVersion(candidate)) { - cli.version = *version; - cli.recentEnough = IsAtLeast(*ParseCliVersion(*version), minimumVersion); + const auto version = QueryVersion(candidate); + if (!version) { + return {UnityCliStatus::State::Outdated}; } - return cli; - } - return std::nullopt; -} - -std::optional UnityCli::Find() { - auto cli = Locate(); - if (cli && cli->recentEnough) { - return cli; + if (!IsAtLeast(version->first, minimumVersion)) { + return {UnityCliStatus::State::Outdated, version->second}; + } + return {UnityCliStatus::State::Ready, version->second, UnityCli{candidate}}; } - return std::nullopt; + return {UnityCliStatus::State::Missing}; } bool UnityCli::CanOpen(const project& p, const editor& e) { @@ -448,7 +443,7 @@ bool UnityCli::CanOpen(const project& p, const editor& e) { void UnityCli::Install(wxWindow* parent, std::function done) { // Unity's official installer, as documented for the CLI. It also records where it - // put the CLI in cli-install.json, which Locate() reads. + // put the CLI in cli-install.json, which Probe() reads. // // The installer defaults to the stable channel, but Unity has published only beta // versions of the CLI so far, so the stable manifest is missing and that install fails. diff --git a/source/unity_cli.hpp b/source/unity_cli.hpp index 4366f5ae..7b653a63 100644 --- a/source/unity_cli.hpp +++ b/source/unity_cli.hpp @@ -16,23 +16,15 @@ class wxWindow; +struct UnityCliStatus; + struct UnityCli { std::filesystem::path executable; - std::string version; - // whether this version can sign the Editor in without the Hub - bool recentEnough = false; - - /** - Finds the installed Unity CLI, whatever its version - @return the CLI, or nullopt if it is not installed - */ - static std::optional Locate(); /** - Finds an installed Unity CLI recent enough to sign the Editor in without the Hub - @return the CLI, or nullopt if it is not installed or too old + Finds the installed Unity CLI and checks its version. This runs the CLI, briefly blocking. */ - static std::optional Find(); + static UnityCliStatus Probe(); /** Whether project p should be opened with editor e through the CLI. Older Editors @@ -68,3 +60,17 @@ struct UnityCli { */ void SignIn(wxWindow* parent, std::function done) const; }; + +// What Unity CLI there is on this computer +struct UnityCliStatus { + enum class State { + Missing, // not installed + Outdated, // installed, but too old to sign the Editor in without the Hub + Ready, // installed and recent enough + }; + State state = State::Missing; + // the installed CLI's version, empty if it is missing or its version could not be read + std::string version; + // the CLI to use, set only when it is Ready + std::optional cli; +}; From cd995303644c8b659a6ca4e7f7d63c6c006efd0b Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:54:43 +0100 Subject: [PATCH 14/16] refactor: Moved the choice between the Unity CLI and a direct Editor launch into one function OpenProjectInEditor() decides whether to open a project through the CLI, and falls back to launch_editor(), which now owns the Editor's command line. The main window no longer builds the command line or passes a fallback callback, and UnityCli no longer exposes CanOpen() and Open(). --- source/globals.cpp | 8 +++ source/globals.h | 7 +++ source/interface_derived.cpp | 17 +----- source/unity_cli.cpp | 102 +++++++++++++++++++---------------- source/unity_cli.hpp | 26 ++++----- 5 files changed, 83 insertions(+), 77 deletions(-) diff --git a/source/globals.cpp b/source/globals.cpp index c6a21b12..a722a66d 100644 --- a/source/globals.cpp +++ b/source/globals.cpp @@ -50,6 +50,14 @@ void launch_process(const std::string& command, int flags) { #endif } +void launch_editor(const project& p, const editor& e, std::string_view buildTarget) { + std::string cmd = "\"" + e.executablePath().string() + "\" -projectpath \"" + p.path.string() + "\""; + if (!buildTarget.empty()) { + cmd += std::format(" -buildTarget {}", buildTarget); + } + launch_process(cmd); +} + void reveal_in_explorer(const std::filesystem::path& path) { #if defined __APPLE__ RevealFile(path); diff --git a/source/globals.h b/source/globals.h index c65e0000..2dcf447c 100644 --- a/source/globals.h +++ b/source/globals.h @@ -154,3 +154,10 @@ struct ParsedVersion { }; ParsedVersion parseVersion(const std::string_view version); + +/** + Launches editor e to open project p, directly rather than through the Unity CLI. The Editor + then starts signed out, and may start the official Unity Hub to sign in. + @param buildTarget the value to pass to -buildTarget, or empty for none + */ +void launch_editor(const project& p, const editor& e, std::string_view buildTarget = {}); diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index 23ed32f8..691caae8 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -506,22 +506,7 @@ const char* const PlatToStr(TargetPlatform plat) { @param e the editor version to use when opening the project */ void MainFrameDerived::OpenProject(const project& p, const editor& e, TargetPlatform plat){ - const std::string_view buildTarget = PlatToStr(plat); - auto launchDirectly = [p, e, buildTarget]{ - string cmd = "\"" + e.executablePath().string() + "\" -projectpath \"" + p.path.string() + "\""; - if (!buildTarget.empty()) { - cmd += std::format(" -buildTarget {}", buildTarget); - } - launch_process(cmd); - }; - - // Launched directly, the Editor starts signed out and starts the official Hub to sign in. - // The Unity CLI serves the Editor's sign-in itself, without the Hub. - if (cliStatus.cli && UnityCli::CanOpen(p, e)) { - cliStatus.cli->Open(p, e, buildTarget, launchDirectly); - return; - } - launchDirectly(); + OpenProjectInEditor(cliStatus, p, e, PlatToStr(plat)); } /** diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index 4ac0da88..0926cf5a 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -394,6 +394,54 @@ namespace { done(-1, false, "Could not start " + program); } } + + // Whether project p should be opened with editor e through the CLI. Older Editors + // sign in on their own, and the CLI refuses some version changes. + bool CliCanOpen(const project& p, const editor& e) { + const auto ev = parseVersion(e.name); + // Editors before 2019.1 sign in on their own and never look for a Hub. This also + // rules out editors whose version could not be read. + if (std::tuple(ev.major, ev.minor) < std::tuple(2019, 1)) { + return false; + } + // `unity open` refuses to move an older project onto 6.7 or later (PROJECT_UPGRADE_REQUIRED) + if (std::tuple(ev.major, ev.minor) >= std::tuple(6000, 7)) { + const auto pv = parseVersion(p.version); + if (std::tuple(pv.major, pv.minor, pv.patch, pv.build) < std::tuple(ev.major, ev.minor, ev.patch, ev.build)) { + return false; + } + } + return true; + } + + // Opens a project with `unity open`. If the CLI fails, shows why and offers to launch the Editor directly. + void OpenThroughCli(const UnityCli& cli, const project& p, const editor& e, std::string_view buildTarget) { +#if __APPLE__ + const auto editorPath = e.path; // the Unity.app bundle +#else + const auto editorPath = e.executablePath(); +#endif + std::vector args{ToWx(cli.executable), "open", ToWx(p.path), "--editor-path", ToWx(editorPath)}; + if (!buildTarget.empty()) { + args.insert(args.end(), {"--build-target", wxString::FromUTF8(buildTarget.data(), buildTarget.size())}); + } + // never wait on a prompt nobody can see + args.insert(args.end(), {"--non-interactive", "--no-banner", "--no-color"}); + + RunCommand(args, [p, e, buildTarget = std::string(buildTarget)](int status, bool, const wxString& output) { + if (status == 0) { + return; + } + auto message = wxString::Format("Unity CLI could not open \"%s\" (exit code %d).", wxString::FromUTF8(p.name), status); + if (!output.empty()) { + message += "\n\n" + output; + } + message += "\n\nOpen it directly with the Editor instead? Unity may then start Unity Hub in the background to sign in."; + if (wxMessageBox(message, "Cannot Open Project", wxYES_NO | wxICON_WARNING) == wxYES) { + launch_editor(p, e, buildTarget); + } + }); + } } UnityCliStatus UnityCli::Probe() { @@ -424,23 +472,6 @@ UnityCliStatus UnityCli::Probe() { return {UnityCliStatus::State::Missing}; } -bool UnityCli::CanOpen(const project& p, const editor& e) { - const auto ev = parseVersion(e.name); - // Editors before 2019.1 sign in on their own and never look for a Hub. This also - // rules out editors whose version could not be read. - if (std::tuple(ev.major, ev.minor) < std::tuple(2019, 1)) { - return false; - } - // `unity open` refuses to move an older project onto 6.7 or later (PROJECT_UPGRADE_REQUIRED) - if (std::tuple(ev.major, ev.minor) >= std::tuple(6000, 7)) { - const auto pv = parseVersion(p.version); - if (std::tuple(pv.major, pv.minor, pv.patch, pv.build) < std::tuple(ev.major, ev.minor, ev.patch, ev.build)) { - return false; - } - } - return true; -} - void UnityCli::Install(wxWindow* parent, std::function done) { // Unity's official installer, as documented for the CLI. It also records where it // put the CLI in cli-install.json, which Probe() reads. @@ -495,34 +526,6 @@ void UnityCli::Install(wxWindow* parent, std::function done) { }, Progress{parent, "Installing Unity CLI", message}); } -void UnityCli::Open(const project& p, const editor& e, std::string_view buildTarget, std::function launchDirectly) const { -#if __APPLE__ - const auto editorPath = e.path; // the Unity.app bundle -#else - const auto editorPath = e.executablePath(); -#endif - std::vector args{ToWx(executable), "open", ToWx(p.path), "--editor-path", ToWx(editorPath)}; - if (!buildTarget.empty()) { - args.insert(args.end(), {"--build-target", wxString::FromUTF8(buildTarget.data(), buildTarget.size())}); - } - // never wait on a prompt nobody can see - args.insert(args.end(), {"--non-interactive", "--no-banner", "--no-color"}); - - RunCommand(args, [projectName = wxString::FromUTF8(p.name), launchDirectly](int status, bool, const wxString& output) { - if (status == 0) { - return; - } - auto message = wxString::Format("Unity CLI could not open \"%s\" (exit code %d).", projectName, status); - if (!output.empty()) { - message += "\n\n" + output; - } - message += "\n\nOpen it directly with the Editor instead? Unity may then start Unity Hub in the background to sign in."; - if (wxMessageBox(message, "Cannot Open Project", wxYES_NO | wxICON_WARNING) == wxYES) { - launchDirectly(); - } - }); -} - void UnityCli::CheckSignedIn(std::function done) const { RunCommand({ToWx(executable), "auth", "status", "--json", "--non-interactive", "--no-banner", "--no-color"}, [done](int status, bool, const wxString& output) { @@ -540,3 +543,12 @@ void UnityCli::SignIn(wxWindow* parent, std::function done) const { }, Progress{parent, "Sign In to Unity", "Your browser opens to sign in to your Unity account.\nFinish signing in there; this window closes when you're done."}); } + +void OpenProjectInEditor(const UnityCliStatus& cli, const project& p, const editor& e, std::string_view buildTarget) { + if (cli.cli && CliCanOpen(p, e)) { + OpenThroughCli(*cli.cli, p, e, buildTarget); + } + else { + launch_editor(p, e, buildTarget); + } +} diff --git a/source/unity_cli.hpp b/source/unity_cli.hpp index 7b653a63..1a864d8a 100644 --- a/source/unity_cli.hpp +++ b/source/unity_cli.hpp @@ -26,28 +26,12 @@ struct UnityCli { */ static UnityCliStatus Probe(); - /** - Whether project p should be opened with editor e through the CLI. Older Editors - sign in on their own, and the CLI refuses some version changes. - */ - static bool CanOpen(const project& p, const editor& e); - /** Installs or updates the CLI with Unity's official installer, showing a progress window @param done called with whether the installer succeeded */ static void Install(wxWindow* parent, std::function done); - /** - Opens a project with `unity open` without blocking. If the CLI fails, shows its - message and offers to launch the Editor directly instead. - @param p the project to open - @param e the editor to open it with - @param buildTarget the value to pass to -buildTarget, or empty for none - @param launchDirectly opens the project without the CLI - */ - void Open(const project& p, const editor& e, std::string_view buildTarget, std::function launchDirectly) const; - /** Checks whether the CLI is signed in to a Unity account, without blocking @param done called with true if it is signed in @@ -74,3 +58,13 @@ struct UnityCliStatus { // the CLI to use, set only when it is Ready std::optional cli; }; + +/** + Opens project p with editor e, without blocking. Launched directly, the Editor starts signed out + and starts the official Hub to sign in, so this opens it through the Unity CLI when there is a + Ready one that can open p with e. If the CLI fails, it shows why and offers to launch the + Editor directly instead. + @param cli the Unity CLI on this computer + @param buildTarget the value to pass to -buildTarget, or empty for none + */ +void OpenProjectInEditor(const UnityCliStatus& cli, const project& p, const editor& e, std::string_view buildTarget = {}); From 7e5fa9bfbbba63f0547fa7a5f400b9ffd352025f Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:56:42 +0100 Subject: [PATCH 15/16] feat: New projects open signed in through the Unity CLI Creating a project launched the Editor directly, so it started signed out and started the official Hub. With a ready CLI, the Editor now creates the project in batch mode, which needs no sign-in, behind a progress window that can cancel it, and the project then opens through the CLI. If creating fails, it offers to create the project directly instead. The create dialog now hands back the editor and template instead of a command line, which launch_editor_create() builds. --- source/create_dialog_derived.cpp | 35 +++----------------------------- source/globals.cpp | 4 ++++ source/globals.h | 7 +++++++ source/interface_derived.cpp | 5 ++--- source/interface_derived.hpp | 3 ++- source/unity_cli.cpp | 30 +++++++++++++++++++++++++++ source/unity_cli.hpp | 10 +++++++++ 7 files changed, 58 insertions(+), 36 deletions(-) diff --git a/source/create_dialog_derived.cpp b/source/create_dialog_derived.cpp index aab07d8c..129a6e76 100644 --- a/source/create_dialog_derived.cpp +++ b/source/create_dialog_derived.cpp @@ -68,11 +68,7 @@ void CreateProjectDialogD::OnCreate(wxCommandEvent& event){ if (message == ""){ - //assemble the command that will create the project described by the dialog - editor& e = editors[GetSelectedEditorIndex()]; - auto executablePath = e.executablePath(); - auto executableTemplatesPath = e.templatePath(); string projName = projNameTxt->GetValue().ToStdString(); string projPath = projLocTxt->GetValue().ToStdString(); @@ -95,35 +91,10 @@ void CreateProjectDialogD::OnCreate(wxCommandEvent& event){ if (itemIndex > -1){ templateName = templateCtrl->GetItemText(itemIndex); } + auto templatePath = e.templatePath() / std::format("{}.{}", templatePrefix, templateName); - //create the command string - #if defined __APPLE__ - string command = std::format("\"{}\" -createProject \"{}\" -cloneFromTemplate \"{}{}.{}\"", - executablePath.string(), - outPath.string(), - executableTemplatesPath.string(), - templatePrefix, - templateName - ); - #elif defined _WIN32 - auto fullProj = filesystem::path(projPath) / projName; - auto fullTemplate = executableTemplatesPath / std::format("{}.{}",templatePrefix,templateName); - string command = std::format("\"{}\" -createproject \"{}\" -cloneFromTemplate \"{}\"", - executablePath.string(), - fullProj.string(), - fullTemplate.string()); - #elif defined __linux__ - string command = std::format("\"{}\" -createproject \"{}\" -cloneFromTemplate \"{}{}.{}\"", - executablePath.string(), - (filesystem::path(projPath) / projName).string(), - executableTemplatesPath.string(), - templatePrefix, - templateName - ); - #endif - //TODO: return this command to what summoned this dialog - project p = {projName,e.name,0,filesystem::path(projPath) / filesystem::path(projName)}; - this->callback(command,p); + project p = {projName,e.name,0,outPath}; + this->callback(p, e, templatePath); //close and dispose self this->EndModal(0); diff --git a/source/globals.cpp b/source/globals.cpp index a722a66d..f7fd3f30 100644 --- a/source/globals.cpp +++ b/source/globals.cpp @@ -58,6 +58,10 @@ void launch_editor(const project& p, const editor& e, std::string_view buildTarg launch_process(cmd); } +void launch_editor_create(const project& p, const editor& e, const std::filesystem::path& templatePath) { + launch_process(std::format("\"{}\" -createProject \"{}\" -cloneFromTemplate \"{}\"", e.executablePath().string(), p.path.string(), templatePath.string())); +} + void reveal_in_explorer(const std::filesystem::path& path) { #if defined __APPLE__ RevealFile(path); diff --git a/source/globals.h b/source/globals.h index 2dcf447c..3014baa1 100644 --- a/source/globals.h +++ b/source/globals.h @@ -161,3 +161,10 @@ ParsedVersion parseVersion(const std::string_view version); @param buildTarget the value to pass to -buildTarget, or empty for none */ void launch_editor(const project& p, const editor& e, std::string_view buildTarget = {}); + +/** + Launches editor e to create project p from a template and open it, directly rather than + through the Unity CLI. The Editor then starts signed out, and may start the official Unity Hub to sign in. + @param templatePath the template package to create the project from + */ +void launch_editor_create(const project& p, const editor& e, const std::filesystem::path& templatePath); diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index 691caae8..a0b1ee18 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -404,12 +404,11 @@ void MainFrameDerived::OnRemoveInstallPath(wxCommandEvent& event){ void MainFrameDerived::OnCreateProject(wxCommandEvent& event){ //create a dialog and show it if (editors.size() > 0){ - DialogCallback d = [&](string str, project p){ + DialogCallback d = [&](const project& p, const editor& e, const std::filesystem::path& templatePath){ //add the project this->AddProject(p,"",true, true); - //launch the process - launch_process(str); + CreateProjectInEditor(this, cliStatus, p, e, templatePath); }; CreateProjectDialogD* dialog = new CreateProjectDialogD(this,editors,d); dialog->show(); diff --git a/source/interface_derived.hpp b/source/interface_derived.hpp index 05236b11..26bc0c05 100644 --- a/source/interface_derived.hpp +++ b/source/interface_derived.hpp @@ -200,7 +200,8 @@ class MainFrameDerived : public MainFrame{ CreateProjectDialog derived class Defines the functionality for the project creation dialog */ -typedef std::function DialogCallback; +// called with the project to create, the editor to create it with, and the template to create it from +typedef std::function DialogCallback; class CreateProjectDialogD : public CreateProjectDialog{ public: CreateProjectDialogD(wxWindow* parent, const std::vector& versions, const DialogCallback& callback); diff --git a/source/unity_cli.cpp b/source/unity_cli.cpp index 0926cf5a..c487efcf 100644 --- a/source/unity_cli.cpp +++ b/source/unity_cli.cpp @@ -552,3 +552,33 @@ void OpenProjectInEditor(const UnityCliStatus& cli, const project& p, const edit launch_editor(p, e, buildTarget); } } + +void CreateProjectInEditor(wxWindow* parent, const UnityCliStatus& cli, const project& p, const editor& e, const std::filesystem::path& templatePath) { + if (!cli.cli || !CliCanOpen(p, e)) { + launch_editor_create(p, e, templatePath); + return; + } + // `unity open` needs an existing project, and an Editor that creates one without the + // CLI starts signed out. So create it in batch mode, which needs no sign-in, then open it. + // The Editor writes its log to its usual Editor.log, keeping the output short. + std::vector args{ToWx(e.executablePath()), "-batchmode", "-quit", "-createProject", ToWx(p.path), "-cloneFromTemplate", ToWx(templatePath)}; + const auto message = wxString::Format(wxString::FromUTF8( + "Unity is creating \"%s\". This can take a few minutes; it opens when it's ready."), wxString::FromUTF8(p.name)); + RunCommand(args, [cliTool = *cli.cli, p, e, templatePath](int status, bool cancelled, const wxString& output) { + if (cancelled) { + return; + } + if (status == 0) { + OpenThroughCli(cliTool, p, e, {}); + return; + } + auto summary = wxString::Format("Unity could not create \"%s\" (exit code %d). Its Editor.log has the details.", wxString::FromUTF8(p.name), status); + if (!output.empty()) { + summary += "\n\n" + output; + } + summary += "\n\nCreate it directly with the Editor instead? Unity may then start Unity Hub in the background to sign in."; + if (wxMessageBox(summary, "Cannot Create Project", wxYES_NO | wxICON_WARNING) == wxYES) { + launch_editor_create(p, e, templatePath); + } + }, Progress{parent, "Creating Project", message}); +} diff --git a/source/unity_cli.hpp b/source/unity_cli.hpp index 1a864d8a..e8db5301 100644 --- a/source/unity_cli.hpp +++ b/source/unity_cli.hpp @@ -68,3 +68,13 @@ struct UnityCliStatus { @param buildTarget the value to pass to -buildTarget, or empty for none */ void OpenProjectInEditor(const UnityCliStatus& cli, const project& p, const editor& e, std::string_view buildTarget = {}); + +/** + Creates project p with editor e from a template, then opens it, without blocking. Through a + Ready Unity CLI that can open p with e, the Editor creates the project in batch mode, showing a + progress window, and the project then opens through the CLI, signed in. Otherwise the Editor + creates and opens it directly. + @param cli the Unity CLI on this computer + @param templatePath the template package to create the project from + */ +void CreateProjectInEditor(wxWindow* parent, const UnityCliStatus& cli, const project& p, const editor& e, const std::filesystem::path& templatePath); From 4743aea883874c756cffe1fa900c35f1c3548d7f Mon Sep 17 00:00:00 2001 From: Ilia Petrov-Komotskii Date: Thu, 5 Nov 2026 13:58:20 +0100 Subject: [PATCH 16/16] refactor: Moved the Unity CLI buttons and prompts out of the main window UnityCliController owns the Install CLI and Login CLI buttons, the install prompt and the install-then-sign-in flow, and keeps the last CLI status. The main window only creates it and forwards its button events. --- source/globals.h | 2 + source/interface_derived.cpp | 136 ++--------------------------------- source/interface_derived.hpp | 13 ++-- source/unity_cli_ui.cpp | 120 +++++++++++++++++++++++++++++++ source/unity_cli_ui.hpp | 60 ++++++++++++++++ 5 files changed, 193 insertions(+), 138 deletions(-) create mode 100644 source/unity_cli_ui.cpp create mode 100644 source/unity_cli_ui.hpp diff --git a/source/globals.h b/source/globals.h index 3014baa1..9c16e94b 100644 --- a/source/globals.h +++ b/source/globals.h @@ -15,6 +15,8 @@ static constexpr std::string_view projectsFile = "projects.txt"; static constexpr std::string_view editorPathsFile = "editorPaths.txt"; static constexpr std::string_view templatePrefix = "com.unity.template"; static constexpr std::string_view AppVersion = "v1.59"; +//the name of the app's settings store (wxConfig) +static constexpr std::string_view config_name = "com.ravbug.unityhubnative.config"; struct wxListCtrl; struct wxWindow; diff --git a/source/interface_derived.cpp b/source/interface_derived.cpp index a0b1ee18..a106178a 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -8,13 +8,10 @@ #include "interface_derived.hpp" #include "activation.hpp" -#include "unity_cli.hpp" #include #include #include #include -#include -#include #include @@ -30,10 +27,8 @@ using namespace std::filesystem; //the web view unloads after 5 minutes of page hidden const int TIMER_LENGTH = 5 * 1000 * 60; -constexpr std::string_view config_name = "com.ravbug.unityhubnative.config"; constexpr std::string_view config_sortCol = "sortColIndex"; constexpr std::string_view config_sortAscending = "sortAscending"; -constexpr std::string_view config_hideCliPrompt = "hideUnityCliPrompt"; //Declare events here wxBEGIN_EVENT_TABLE(MainFrameDerived, wxFrame) @@ -121,9 +116,10 @@ MainFrameDerived::MainFrameDerived() : MainFrame(NULL){ FitMinSizeToTabs(); - RefreshCliState(); + cliController = std::make_unique(this, installCliBtn, loginCliBtn); + cliController->Refresh(); // once the window is on screen - CallAfter(&MainFrameDerived::OfferCliInstall); + CallAfter([this]{ cliController->OfferInstall(); }); } /** @@ -408,7 +404,7 @@ void MainFrameDerived::OnCreateProject(wxCommandEvent& event){ //add the project this->AddProject(p,"",true, true); - CreateProjectInEditor(this, cliStatus, p, e, templatePath); + CreateProjectInEditor(this, cliController->Status(), p, e, templatePath); }; CreateProjectDialogD* dialog = new CreateProjectDialogD(this,editors,d); dialog->show(); @@ -505,133 +501,15 @@ const char* const PlatToStr(TargetPlatform plat) { @param e the editor version to use when opening the project */ void MainFrameDerived::OpenProject(const project& p, const editor& e, TargetPlatform plat){ - OpenProjectInEditor(cliStatus, p, e, PlatToStr(plat)); -} - -/** - The label for installing the CLI, or updating it if it is too old - */ -const char* MainFrameDerived::InstallCliLabel() const{ - return cliStatus.state == UnityCliStatus::State::Outdated ? "Update CLI" : "Install CLI"; -} - -/** - Looks for the Unity CLI again, and updates the Install CLI and Login CLI buttons. Install - is available while there is no recent enough CLI, Login while the CLI is not signed in. - */ -void MainFrameDerived::RefreshCliState(){ - cliStatus = UnityCli::Probe(); - installCliBtn->SetLabel(InstallCliLabel()); - installCliBtn->Enable(!cliStatus.cli); - loginCliBtn->Enable(false); - installCliBtn->GetParent()->Layout(); - if (!cliStatus.cli) { - return; - } - wxWeakRef self(this); - cliStatus.cli->CheckSignedIn([self](bool signedIn){ - if (self) { - self->loginCliBtn->Enable(!signedIn); - } - }); -} - -/** - On launch, explains the Unity CLI and offers to install it, unless the user turned this off - */ -void MainFrameDerived::OfferCliInstall(){ - wxConfig config(config_name.data()); - if (config.ReadBool(config_hideCliPrompt.data(), false)) { - return; - } - if (cliStatus.cli) { - return; - } - - const wxString status = cliStatus.state == UnityCliStatus::State::Outdated - ? wxString::Format("The Unity CLI on this computer (version %s) is too old for this.", cliStatus.version.empty() ? "unknown" : cliStatus.version) - : wxString("The Unity CLI isn't installed yet."); - // wxRichMessageDialog lays this out with wxTextSizerWrapper, which drops empty - // lines, so a blank line has to contain a character or the gap disappears. - const wxString paragraphGap = "\n \n"; - const wxString message = - "Unity Hub Native can open your projects signed in to your Unity account, so the Asset Store, " - "Package Manager and other Unity services work, without starting the official Unity Hub. " - "It does this through Unity's official command-line tool, the Unity CLI." - + paragraphGap + " > " + status + paragraphGap + - "Installing runs Unity's installer from unity.com. It puts the unity command in your user folder " - "and adds it to your shell's PATH. After that, you sign in to your Unity account once in your browser." - + paragraphGap + - "You can also do this later from the Editor Versions tab." + paragraphGap; - - wxRichMessageDialog dialog(this, message, "Open Projects Signed In", wxYES_NO | wxICON_INFORMATION); - dialog.SetYesNoLabels(InstallCliLabel(), "Not Now"); - dialog.ShowCheckBox("Don't show this again"); - // Escape (and closing the window) answers Not Now; with no Cancel button it would do nothing - dialog.SetEscapeId(wxID_NO); - const bool install = dialog.ShowModal() == wxID_YES; - if (dialog.IsCheckBoxChecked()) { - config.Write(config_hideCliPrompt.data(), true); - } - if (install) { - InstallCli(); - } -} - -/** - Installs or updates the Unity CLI, then helps the user sign in - */ -void MainFrameDerived::InstallCli(){ - wxWeakRef self(this); - UnityCli::Install(this, [self](bool installed){ - if (!self) { - return; - } - self->RefreshCliState(); - if (!installed) { - return; - } - const auto& cli = self->cliStatus.cli; - if (!cli) { - wxMessageBox("The installer finished, but Unity Hub Native cannot find a recent enough Unity CLI.", "Cannot Install Unity CLI", wxOK | wxICON_ERROR, self.get()); - return; - } - cli->CheckSignedIn([self](bool signedIn){ - if (!self) { - return; - } - if (signedIn) { - wxMessageBox("The Unity CLI is installed and signed in. Projects now open signed in to your Unity account.", "Unity CLI Installed", wxOK | wxICON_INFORMATION, self.get()); - } - else if (wxMessageBox("The Unity CLI is installed. Sign in to your Unity account now, so projects open signed in?", "Unity CLI Installed", wxYES_NO | wxICON_QUESTION, self.get()) == wxYES) { - self->LoginCli(); - } - }); - }); -} - -/** - Signs the Unity CLI in to the user's Unity account through the browser - */ -void MainFrameDerived::LoginCli(){ - if (!cliStatus.cli) { - RefreshCliState(); - return; - } - wxWeakRef self(this); - cliStatus.cli->SignIn(this, [self](bool){ - if (self) { - self->RefreshCliState(); - } - }); + OpenProjectInEditor(cliController->Status(), p, e, PlatToStr(plat)); } void MainFrameDerived::OnInstallCli(wxCommandEvent&){ - InstallCli(); + cliController->Install(); } void MainFrameDerived::OnLoginCli(wxCommandEvent&){ - LoginCli(); + cliController->SignIn(); } MainFrameDerived::~MainFrameDerived() diff --git a/source/interface_derived.hpp b/source/interface_derived.hpp index 26bc0c05..c1460b3a 100644 --- a/source/interface_derived.hpp +++ b/source/interface_derived.hpp @@ -10,7 +10,8 @@ #include "globals.h" #include "interface.h" -#include "unity_cli.hpp" +#include "unity_cli_ui.hpp" +#include #include #include #include @@ -67,13 +68,7 @@ class MainFrameDerived : public MainFrame{ // Unity CLI: opens projects signed in without the official Hub void OnInstallCli(wxCommandEvent&); void OnLoginCli(wxCommandEvent&); - void InstallCli(); - void LoginCli(); - void OfferCliInstall(); - void RefreshCliState(); - const char* InstallCliLabel() const; - // the Unity CLI found by the last RefreshCliState() - UnityCliStatus cliStatus; + std::unique_ptr cliController; wxWindow* const projectActionItems[3]{ revealProjBtn, @@ -188,7 +183,7 @@ class MainFrameDerived : public MainFrame{ void OnOpenHub(wxCommandEvent& event); void OnReloadEditors(wxCommandEvent& event){ this->LoadEditorVersions(); - RefreshCliState(); + cliController->Refresh(); } void OnColumnClick(wxListEvent& event); static int wxCALLBACK CompareItems(wxIntPtr item1, wxIntPtr item2, wxIntPtr sortData); diff --git a/source/unity_cli_ui.cpp b/source/unity_cli_ui.cpp new file mode 100644 index 00000000..d0b6d6a3 --- /dev/null +++ b/source/unity_cli_ui.cpp @@ -0,0 +1,120 @@ +// +// unity_cli_ui.cpp +// Unity Hub Native +// + +#include "unity_cli_ui.hpp" +#include +#include +#include +#include +#include + +namespace { + constexpr std::string_view config_hideCliPrompt = "hideUnityCliPrompt"; +} + +UnityCliController::UnityCliController(wxWindow* parent, wxButton* installButton, wxButton* loginButton) + : parent(parent), installButton(installButton), loginButton(loginButton) {} + +const char* UnityCliController::InstallLabel() const { + return status.state == UnityCliStatus::State::Outdated ? "Update CLI" : "Install CLI"; +} + +void UnityCliController::Refresh() { + status = UnityCli::Probe(); + installButton->SetLabel(InstallLabel()); + installButton->Enable(!status.cli); + loginButton->Enable(false); + installButton->GetParent()->Layout(); + if (!status.cli) { + return; + } + wxWeakRef self(this); + status.cli->CheckSignedIn([self](bool signedIn) { + if (self) { + self->loginButton->Enable(!signedIn); + } + }); +} + +void UnityCliController::OfferInstall() { + wxConfig config(config_name.data()); + if (config.ReadBool(config_hideCliPrompt.data(), false)) { + return; + } + if (status.cli) { + return; + } + + const wxString current = status.state == UnityCliStatus::State::Outdated + ? wxString::Format("The Unity CLI on this computer (version %s) is too old for this.", status.version.empty() ? "unknown" : status.version) + : wxString("The Unity CLI isn't installed yet."); + // wxRichMessageDialog lays this out with wxTextSizerWrapper, which drops empty + // lines, so a blank line has to contain a character or the gap disappears. + const wxString paragraphGap = "\n \n"; + const wxString message = + "Unity Hub Native can open your projects signed in to your Unity account, so the Asset Store, " + "Package Manager and other Unity services work, without starting the official Unity Hub. " + "It does this through Unity's official command-line tool, the Unity CLI." + + paragraphGap + " > " + current + paragraphGap + + "Installing runs Unity's installer from unity.com. It puts the unity command in your user folder " + "and adds it to your shell's PATH. After that, you sign in to your Unity account once in your browser." + + paragraphGap + + "You can also do this later from the Editor Versions tab." + paragraphGap; + + wxRichMessageDialog dialog(parent, message, "Open Projects Signed In", wxYES_NO | wxICON_INFORMATION); + dialog.SetYesNoLabels(InstallLabel(), "Not Now"); + dialog.ShowCheckBox("Don't show this again"); + // Escape (and closing the window) answers Not Now; with no Cancel button it would do nothing + dialog.SetEscapeId(wxID_NO); + const bool install = dialog.ShowModal() == wxID_YES; + if (dialog.IsCheckBoxChecked()) { + config.Write(config_hideCliPrompt.data(), true); + } + if (install) { + Install(); + } +} + +void UnityCliController::Install() { + wxWeakRef self(this); + UnityCli::Install(parent, [self](bool installed) { + if (!self) { + return; + } + self->Refresh(); + if (!installed) { + return; + } + const auto& cli = self->status.cli; + if (!cli) { + wxMessageBox("The installer finished, but Unity Hub Native cannot find a recent enough Unity CLI.", "Cannot Install Unity CLI", wxOK | wxICON_ERROR, self->parent); + return; + } + cli->CheckSignedIn([self](bool signedIn) { + if (!self) { + return; + } + if (signedIn) { + wxMessageBox("The Unity CLI is installed and signed in. Projects now open signed in to your Unity account.", "Unity CLI Installed", wxOK | wxICON_INFORMATION, self->parent); + } + else if (wxMessageBox("The Unity CLI is installed. Sign in to your Unity account now, so projects open signed in?", "Unity CLI Installed", wxYES_NO | wxICON_QUESTION, self->parent) == wxYES) { + self->SignIn(); + } + }); + }); +} + +void UnityCliController::SignIn() { + if (!status.cli) { + Refresh(); + return; + } + wxWeakRef self(this); + status.cli->SignIn(parent, [self](bool) { + if (self) { + self->Refresh(); + } + }); +} diff --git a/source/unity_cli_ui.hpp b/source/unity_cli_ui.hpp new file mode 100644 index 00000000..911ef3fc --- /dev/null +++ b/source/unity_cli_ui.hpp @@ -0,0 +1,60 @@ +// +// unity_cli_ui.hpp +// Unity Hub Native +// +// The main window's Unity CLI buttons and prompts: installing or updating the CLI, +// and signing it in to the user's Unity account. +// + +#pragma once + +#include "unity_cli.hpp" +#include + +class wxButton; +class wxWindow; + +class UnityCliController : public wxTrackable { +public: + /** + @param parent the window that the controller's dialogs belong to + @param installButton installs or updates the CLI + @param loginButton signs the CLI in + */ + UnityCliController(wxWindow* parent, wxButton* installButton, wxButton* loginButton); + + // The Unity CLI found by the last Refresh() + const UnityCliStatus& Status() const { + return status; + } + + /** + Looks for the Unity CLI again, and updates the buttons. Install is available while there + is no recent enough CLI, Login while the CLI is not signed in. + */ + void Refresh(); + + /** + Explains the Unity CLI and offers to install it, unless it is ready or the user turned this off + */ + void OfferInstall(); + + /** + Installs or updates the Unity CLI, then helps the user sign in + */ + void Install(); + + /** + Signs the Unity CLI in to the user's Unity account through the browser + */ + void SignIn(); + +private: + // The label for installing the CLI, or updating it if it is too old + const char* InstallLabel() const; + + wxWindow* parent; + wxButton* installButton; + wxButton* loginButton; + UnityCliStatus status; +};