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") 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/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/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/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/globals.cpp b/source/globals.cpp index c6a21b12..f7fd3f30 100644 --- a/source/globals.cpp +++ b/source/globals.cpp @@ -50,6 +50,18 @@ 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 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 961f0561..9c16e94b 100644 --- a/source/globals.h +++ b/source/globals.h @@ -15,17 +15,29 @@ 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; -#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 +80,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/"; @@ -146,3 +156,17 @@ 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 = {}); + +/** + 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.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..a106178a 100644 --- a/source/interface_derived.cpp +++ b/source/interface_derived.cpp @@ -27,7 +27,6 @@ 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"; @@ -55,6 +54,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 +113,38 @@ MainFrameDerived::MainFrameDerived() : MainFrame(NULL){ this->SetLabel(std::format("Unity Hub Native {}",AppVersion)); projSearchCtrl->Bind(wxEVT_KEY_UP, &MainFrameDerived::Filter, this); projSearchCtrl->SetFocus(); + + FitMinSizeToTabs(); + + cliController = std::make_unique(this, installCliBtn, loginCliBtn); + cliController->Refresh(); + // once the window is on screen + CallAfter([this]{ cliController->OfferInstall(); }); +} + +/** + 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&){ @@ -367,12 +400,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, cliController->Status(), p, e, templatePath); }; CreateProjectDialogD* dialog = new CreateProjectDialogD(this,editors,d); dialog->show(); @@ -432,12 +464,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 +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){ - string cmd = "\"" + e.executablePath().string() + "\" -projectpath \"" + p.path.string() + "\""; - if (plat != TargetPlatform::CurrentPlatform) { - auto str = PlatToStr(plat); - cmd += std::format(" -buildTarget {}", str); - } - launch_process(cmd); + OpenProjectInEditor(cliController->Status(), p, e, PlatToStr(plat)); +} + +void MainFrameDerived::OnInstallCli(wxCommandEvent&){ + cliController->Install(); +} + +void MainFrameDerived::OnLoginCli(wxCommandEvent&){ + cliController->SignIn(); } MainFrameDerived::~MainFrameDerived() diff --git a/source/interface_derived.hpp b/source/interface_derived.hpp index 9eb67783..c1460b3a 100644 --- a/source/interface_derived.hpp +++ b/source/interface_derived.hpp @@ -10,6 +10,8 @@ #include "globals.h" #include "interface.h" +#include "unity_cli_ui.hpp" +#include #include #include #include @@ -61,6 +63,12 @@ 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&); + void OnLoginCli(wxCommandEvent&); + std::unique_ptr cliController; wxWindow* const projectActionItems[3]{ revealProjBtn, @@ -175,6 +183,7 @@ class MainFrameDerived : public MainFrame{ void OnOpenHub(wxCommandEvent& event); void OnReloadEditors(wxCommandEvent& event){ this->LoadEditorVersions(); + cliController->Refresh(); } void OnColumnClick(wxListEvent& event); static int wxCALLBACK CompareItems(wxIntPtr item1, wxIntPtr item2, wxIntPtr sortData); @@ -186,7 +195,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 new file mode 100644 index 00000000..c487efcf --- /dev/null +++ b/source/unity_cli.cpp @@ -0,0 +1,584 @@ +// +// 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 + } + + // 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 homedir; +#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 + } + + /* + 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; + } + } + 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; + } + } + 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 JSON document + @return the unescaped value, or nullopt if it is missing or not a string + */ + std::optional JsonStringField(const std::string& json, std::initializer_list path) { + const auto start = JsonValueStart(json, path); + 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 JSON document + std::optional JsonBoolField(const std::string& json, std::initializer_list path) { + const auto start = JsonValueStart(json, path); + 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; + } + + // 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) { + return std::nullopt; + } + for (auto& line : output) { + const auto text = line.Trim().Trim(false).utf8_string(); + if (const auto version = ParseCliVersion(text)) { + return std::pair(*version, 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 = wxString::FromUTF8("…") + 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(); + // 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; + }); + } + + 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); + } + } + + // 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() { + 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; + } + const auto version = QueryVersion(candidate); + if (!version) { + return {UnityCliStatus::State::Outdated}; + } + if (!IsAtLeast(version->first, minimumVersion)) { + return {UnityCliStatus::State::Outdated, version->second}; + } + return {UnityCliStatus::State::Ready, version->second, UnityCli{candidate}}; + } + return {UnityCliStatus::State::Missing}; +} + +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. + // + // 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. + 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( +#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", message}); +} + +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(), {"data", "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."}); +} + +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); + } +} + +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 new file mode 100644 index 00000000..e8db5301 --- /dev/null +++ b/source/unity_cli.hpp @@ -0,0 +1,80 @@ +// +// 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 UnityCliStatus; + +struct UnityCli { + std::filesystem::path executable; + + /** + Finds the installed Unity CLI and checks its version. This runs the CLI, briefly blocking. + */ + static UnityCliStatus Probe(); + + /** + 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); + + /** + 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; +}; + +// 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; +}; + +/** + 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 = {}); + +/** + 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); 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; +}; 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 @@ class wxWakeUpPipe : public wxEventLoopSourceHandler 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; } }