hyprpm: add full nix integration (#13189)
Adds nix integration to hyprpm: hyprpm will now detect nix'd hyprland and use nix develop instead --------- Co-authored-by: Mihai Fufezan <mihai@fufexan.net>
This commit is contained in:
parent
339661229d
commit
857a78ce4e
5 changed files with 150 additions and 34 deletions
|
|
@ -94,15 +94,18 @@ SHyprlandVersion CPluginManager::getHyprlandVersion(bool running) {
|
||||||
auto hldate = (*jsonQuery)["commit_date"].get_string();
|
auto hldate = (*jsonQuery)["commit_date"].get_string();
|
||||||
auto hlcommits = (*jsonQuery)["commits"].get_string();
|
auto hlcommits = (*jsonQuery)["commits"].get_string();
|
||||||
|
|
||||||
|
auto flags = (*jsonQuery)["flags"].get_array();
|
||||||
|
bool isNix = std::ranges::any_of(flags, [](const auto& f) { return f.is_string() && f.get_string() == std::string_view{"nix"}; });
|
||||||
|
|
||||||
size_t commits = 0;
|
size_t commits = 0;
|
||||||
try {
|
try {
|
||||||
commits = std::stoull(hlcommits);
|
commits = std::stoull(hlcommits);
|
||||||
} catch (...) { ; }
|
} catch (...) { ; }
|
||||||
|
|
||||||
if (m_bVerbose)
|
if (m_bVerbose)
|
||||||
std::println("{}", verboseString("parsed commit {} at branch {} on {}, commits {}", hlcommit, hlbranch, hldate, commits));
|
std::println("{}", verboseString("parsed commit {} at branch {} on {}, commits {}, nix: {}", hlcommit, hlbranch, hldate, commits, isNix));
|
||||||
|
|
||||||
auto ver = SHyprlandVersion{hlbranch, hlcommit, hldate, abiHash, commits};
|
auto ver = SHyprlandVersion{hlbranch, hlcommit, hldate, abiHash, commits, isNix};
|
||||||
|
|
||||||
if (running)
|
if (running)
|
||||||
verRunning = ver;
|
verRunning = ver;
|
||||||
|
|
@ -302,8 +305,14 @@ bool CPluginManager::addNewPluginRepo(const std::string& url, const std::string&
|
||||||
progress.printMessageAbove(infoString("Building {}", p.name));
|
progress.printMessageAbove(infoString("Building {}", p.name));
|
||||||
|
|
||||||
for (auto const& bs : p.buildSteps) {
|
for (auto const& bs : p.buildSteps) {
|
||||||
const std::string& cmd = std::format("cd {} && PKG_CONFIG_PATH='{}' {}", m_szWorkingPluginDirectory, getPkgConfigPath(), bs);
|
const auto CMD_RAW = nixDevelopIfNeeded(std::format("cd {} && PKG_CONFIG_PATH=\"{}\" {}", m_szWorkingPluginDirectory, getPkgConfigPath(), bs), HLVER);
|
||||||
out += " -> " + cmd + "\n" + execAndGet(cmd) + "\n";
|
|
||||||
|
if (!CMD_RAW) {
|
||||||
|
progress.printMessageAbove(failureString("Failed to build {}: {}", p.name, CMD_RAW.error()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += " -> " + *CMD_RAW + "\n" + execAndGet(*CMD_RAW) + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_bVerbose)
|
if (m_bVerbose)
|
||||||
|
|
@ -388,7 +397,7 @@ eHeadersErrors CPluginManager::headersValid() {
|
||||||
return HEADERS_MISSING;
|
return HEADERS_MISSING;
|
||||||
|
|
||||||
// find headers commit
|
// find headers commit
|
||||||
const std::string& cmd = std::format("PKG_CONFIG_PATH='{}' pkgconf --cflags --keep-system-cflags hyprland", getPkgConfigPath());
|
const std::string& cmd = std::format("PKG_CONFIG_PATH=\"{}\" pkgconf --cflags --keep-system-cflags hyprland", getPkgConfigPath());
|
||||||
auto headers = execAndGet(cmd);
|
auto headers = execAndGet(cmd);
|
||||||
|
|
||||||
if (!headers.contains("-I/"))
|
if (!headers.contains("-I/"))
|
||||||
|
|
@ -541,8 +550,17 @@ bool CPluginManager::updateHeaders(bool force) {
|
||||||
if (m_bVerbose)
|
if (m_bVerbose)
|
||||||
progress.printMessageAbove(verboseString("setting PREFIX for cmake to {}", DataState::getHeadersPath()));
|
progress.printMessageAbove(verboseString("setting PREFIX for cmake to {}", DataState::getHeadersPath()));
|
||||||
|
|
||||||
ret = execAndGet(std::format("cd {} && cmake --no-warn-unused-cli -DCMAKE_BUILD_TYPE:STRING=Release -DCMAKE_INSTALL_PREFIX:STRING=\"{}\" -S . -B ./build", WORKINGDIR,
|
const auto CONFIGURE_CMD =
|
||||||
DataState::getHeadersPath()));
|
nixDevelopIfNeeded(std::format("cd {} && cmake --no-warn-unused-cli -DCMAKE_BUILD_TYPE:STRING=Release -DCMAKE_INSTALL_PREFIX:STRING=\"{}\" -S . -B ./build", WORKINGDIR,
|
||||||
|
DataState::getHeadersPath()),
|
||||||
|
HLVER);
|
||||||
|
|
||||||
|
if (!CONFIGURE_CMD) {
|
||||||
|
std::println(stderr, "\n{}", failureString("Could not configure hyprland: {}", CONFIGURE_CMD.error()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = execAndGet(*CONFIGURE_CMD);
|
||||||
if (m_bVerbose)
|
if (m_bVerbose)
|
||||||
progress.printMessageAbove(verboseString("cmake returned: {}", ret));
|
progress.printMessageAbove(verboseString("cmake returned: {}", ret));
|
||||||
|
|
||||||
|
|
@ -740,8 +758,14 @@ bool CPluginManager::updatePlugins(bool forceUpdateAll) {
|
||||||
progress.printMessageAbove(infoString("Building {}", p.name));
|
progress.printMessageAbove(infoString("Building {}", p.name));
|
||||||
|
|
||||||
for (auto const& bs : p.buildSteps) {
|
for (auto const& bs : p.buildSteps) {
|
||||||
const std::string& cmd = std::format("cd {} && PKG_CONFIG_PATH='{}' {}", m_szWorkingPluginDirectory, getPkgConfigPath(), bs);
|
const auto CMD_RAW = nixDevelopIfNeeded(std::format("cd {} && PKG_CONFIG_PATH=\"{}\" {}", m_szWorkingPluginDirectory, getPkgConfigPath(), bs), HLVER);
|
||||||
out += " -> " + cmd + "\n" + execAndGet(cmd) + "\n";
|
|
||||||
|
if (!CMD_RAW) {
|
||||||
|
progress.printMessageAbove(failureString("Failed to build {}: {}", p.name, CMD_RAW.error()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += " -> " + *CMD_RAW + "\n" + execAndGet(*CMD_RAW) + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_bVerbose)
|
if (m_bVerbose)
|
||||||
|
|
@ -771,8 +795,8 @@ bool CPluginManager::updatePlugins(bool forceUpdateAll) {
|
||||||
repohash.pop_back();
|
repohash.pop_back();
|
||||||
newrepo.hash = repohash;
|
newrepo.hash = repohash;
|
||||||
for (auto const& p : pManifest->m_plugins) {
|
for (auto const& p : pManifest->m_plugins) {
|
||||||
const auto OLDPLUGINIT = std::find_if(repo.plugins.begin(), repo.plugins.end(), [&](const auto& other) { return other.name == p.name; });
|
const auto OLDPLUGINIT = std::ranges::find_if(repo.plugins, [&](const auto& other) { return other.name == p.name; });
|
||||||
newrepo.plugins.push_back(SPlugin{p.name, m_szWorkingPluginDirectory + "/" + p.output, OLDPLUGINIT != repo.plugins.end() ? OLDPLUGINIT->enabled : false});
|
newrepo.plugins.emplace_back(SPlugin{p.name, m_szWorkingPluginDirectory + "/" + p.output, OLDPLUGINIT != repo.plugins.end() ? OLDPLUGINIT->enabled : false});
|
||||||
}
|
}
|
||||||
DataState::removePluginRepo(SPluginRepoIdentifier::fromName(newrepo.name));
|
DataState::removePluginRepo(SPluginRepoIdentifier::fromName(newrepo.name));
|
||||||
DataState::addNewPluginRepo(newrepo);
|
DataState::addNewPluginRepo(newrepo);
|
||||||
|
|
@ -898,7 +922,7 @@ ePluginLoadStateReturn CPluginManager::ensurePluginsLoadState(bool forceReload)
|
||||||
if (!p.enabled)
|
if (!p.enabled)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!forceReload && std::find_if(loadedPlugins.begin(), loadedPlugins.end(), [&](const auto& other) { return other == p.name; }) != loadedPlugins.end())
|
if (!forceReload && std::ranges::find_if(loadedPlugins, [&](const auto& other) { return other == p.name; }) != loadedPlugins.end())
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!loadUnloadPlugin(HYPRPMPATH / repoForName(p.name) / p.filename, true)) {
|
if (!loadUnloadPlugin(HYPRPMPATH / repoForName(p.name) / p.filename, true)) {
|
||||||
|
|
@ -986,6 +1010,9 @@ std::string CPluginManager::headerErrorShort(const eHeadersErrors err) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CPluginManager::hasDeps() {
|
bool CPluginManager::hasDeps() {
|
||||||
|
if (!m_bNoNix && getHyprlandVersion().isNix)
|
||||||
|
return true; // dep check not needed if we are on nix
|
||||||
|
|
||||||
bool hasAllDeps = true;
|
bool hasAllDeps = true;
|
||||||
std::vector<std::string> deps = {"cpio", "cmake", "pkg-config", "g++", "gcc", "git"};
|
std::vector<std::string> deps = {"cpio", "cmake", "pkg-config", "g++", "gcc", "git"};
|
||||||
|
|
||||||
|
|
@ -1000,16 +1027,90 @@ bool CPluginManager::hasDeps() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string& CPluginManager::getPkgConfigPath() {
|
const std::string& CPluginManager::getPkgConfigPath() {
|
||||||
static bool once = true;
|
static const auto str = std::format("{}/share/pkgconfig:$PKG_CONFIG_PATH", DataState::getHeadersPath());
|
||||||
static std::string res;
|
return str;
|
||||||
if (once) {
|
}
|
||||||
once = false;
|
|
||||||
|
|
||||||
if (const auto E = getenv("PKG_CONFIG_PATH"); E && E[0])
|
static std::expected<std::string, std::string> getNixDevelopFromPath(const std::string& argv0) {
|
||||||
res = std::format("{}/share/pkgconfig:{}", DataState::getHeadersPath(), E);
|
std::string fullStorePath;
|
||||||
else
|
|
||||||
res = std::format("{}/share/pkgconfig", DataState::getHeadersPath());
|
if (argv0.starts_with("/")) {
|
||||||
|
// we can use this directly
|
||||||
|
fullStorePath = argv0;
|
||||||
|
} else {
|
||||||
|
// use hyprpm, find in path
|
||||||
|
auto exe = NSys::findInPath("hyprpm");
|
||||||
|
if (!exe)
|
||||||
|
return std::unexpected("hyprpm not found in PATH");
|
||||||
|
|
||||||
|
fullStorePath = *exe;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
if (fullStorePath.empty() || !fullStorePath.ends_with("/bin/hyprpm"))
|
||||||
|
return std::unexpected("couldn't get a real path for hyprpm (1)");
|
||||||
|
|
||||||
|
// canonicalize to get the real nix-store path
|
||||||
|
std::error_code ec;
|
||||||
|
fullStorePath = std::filesystem::canonical(fullStorePath, ec);
|
||||||
|
|
||||||
|
if (ec || fullStorePath.empty() || !fullStorePath.starts_with("/nix"))
|
||||||
|
return std::unexpected("couldn't get a real path for hyprpm");
|
||||||
|
|
||||||
|
fullStorePath = fullStorePath.substr(0, fullStorePath.length() - std::string_view{"/bin/hyprpm"}.length());
|
||||||
|
|
||||||
|
auto deriver = trim(execAndGet(std::format("echo \"$(nix-store --query --deriver '{}')\"", fullStorePath)));
|
||||||
|
|
||||||
|
if (deriver.starts_with("unknown"))
|
||||||
|
return std::unexpected("couldn't nix deriver");
|
||||||
|
|
||||||
|
return deriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::expected<std::string, std::string> getNixDevelopFromProfile() {
|
||||||
|
const auto NIX_PROFILE_STR = execAndGet("nix profile list --json");
|
||||||
|
|
||||||
|
auto rawJson = glz::read_json<glz::generic>(NIX_PROFILE_STR);
|
||||||
|
|
||||||
|
if (!rawJson)
|
||||||
|
return std::unexpected("failed to parse nix profile list --json");
|
||||||
|
|
||||||
|
auto& json = *rawJson;
|
||||||
|
|
||||||
|
if (!json.contains("elements") || !json["elements"].is_object())
|
||||||
|
return std::unexpected("nix profile list --json returned a wonky json");
|
||||||
|
|
||||||
|
if (!json["elements"].contains("hyprland") && !json["elements"].contains("Hyprland"))
|
||||||
|
return std::unexpected("nix profile list --json doesn't contain Hyprland (did you uninstall?)");
|
||||||
|
|
||||||
|
auto& hyprlandJson = json["elements"].contains("hyprland") ? json["elements"]["hyprland"] : json["elements"]["Hyprland"];
|
||||||
|
|
||||||
|
if (!hyprlandJson.contains("originalUrl"))
|
||||||
|
return std::unexpected("nix profile list --json's hyprland doesn't contain originalUrl?");
|
||||||
|
|
||||||
|
return hyprlandJson["originalUrl"].get_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<std::string, std::string> CPluginManager::nixDevelopIfNeeded(const std::string& cmd, const SHyprlandVersion& ver) {
|
||||||
|
if (m_bNoNix || !ver.isNix)
|
||||||
|
return cmd;
|
||||||
|
|
||||||
|
// Escape single quotes
|
||||||
|
std::string newCmd = cmd;
|
||||||
|
replaceInString(newCmd, "'", "\\'");
|
||||||
|
|
||||||
|
auto NIX_DEVELOP = getNixDevelopFromPath(m_szArgv0);
|
||||||
|
|
||||||
|
if (NIX_DEVELOP)
|
||||||
|
return std::format("nix develop '{}' --command bash -c $'{}'", *NIX_DEVELOP, newCmd);
|
||||||
|
else if (m_bVerbose)
|
||||||
|
std::println("{}", verboseString("Failed nix from path: {}", NIX_DEVELOP.error()));
|
||||||
|
|
||||||
|
NIX_DEVELOP = getNixDevelopFromProfile();
|
||||||
|
|
||||||
|
if (NIX_DEVELOP)
|
||||||
|
return std::format("nix develop '{}' --command bash -c $'{}'", *NIX_DEVELOP, newCmd);
|
||||||
|
else if (m_bVerbose)
|
||||||
|
std::println("{}", verboseString("Failed nix from profile: {}", NIX_DEVELOP.error()));
|
||||||
|
|
||||||
|
return std::unexpected("hyprland is nix, but hyprpm failed to obtain a nix develop shell for build cmd");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <expected>
|
||||||
#include "Plugin.hpp"
|
#include "Plugin.hpp"
|
||||||
|
|
||||||
enum eHeadersErrors {
|
enum eHeadersErrors {
|
||||||
|
|
@ -41,6 +41,7 @@ struct SHyprlandVersion {
|
||||||
std::string date;
|
std::string date;
|
||||||
std::string abiHash;
|
std::string abiHash;
|
||||||
int commits = 0;
|
int commits = 0;
|
||||||
|
bool isNix = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
class CPluginManager {
|
class CPluginManager {
|
||||||
|
|
@ -71,16 +72,19 @@ class CPluginManager {
|
||||||
|
|
||||||
bool m_bVerbose = false;
|
bool m_bVerbose = false;
|
||||||
bool m_bNoShallow = false;
|
bool m_bNoShallow = false;
|
||||||
std::string m_szCustomHlUrl, m_szUsername;
|
bool m_bNoNix = false;
|
||||||
|
std::string m_szCustomHlUrl, m_szUsername, m_szArgv0;
|
||||||
|
|
||||||
// will delete recursively if exists!!
|
// will delete recursively if exists!!
|
||||||
bool createSafeDirectory(const std::string& path);
|
bool createSafeDirectory(const std::string& path);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::string headerError(const eHeadersErrors err);
|
std::string headerError(const eHeadersErrors err);
|
||||||
std::string headerErrorShort(const eHeadersErrors err);
|
std::string headerErrorShort(const eHeadersErrors err);
|
||||||
|
|
||||||
std::string m_szWorkingPluginDirectory;
|
std::expected<std::string, std::string> nixDevelopIfNeeded(const std::string& cmd, const SHyprlandVersion& ver);
|
||||||
|
|
||||||
|
std::string m_szWorkingPluginDirectory;
|
||||||
};
|
};
|
||||||
|
|
||||||
inline std::unique_ptr<CPluginManager> g_pPluginManager;
|
inline std::unique_ptr<CPluginManager> g_pPluginManager;
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,13 @@ static std::string validSubinsAsStr() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool executableExistsInPath(const std::string& exe) {
|
static bool executableExistsInPath(const std::string& exe) {
|
||||||
|
return NSys::findInPath(exe).has_value();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> NSys::findInPath(const std::string& exe) {
|
||||||
const char* PATHENV = std::getenv("PATH");
|
const char* PATHENV = std::getenv("PATH");
|
||||||
if (!PATHENV)
|
if (!PATHENV)
|
||||||
return false;
|
return std::nullopt;
|
||||||
|
|
||||||
CVarList paths(PATHENV, 0, ':', true);
|
CVarList paths(PATHENV, 0, ':', true);
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
|
|
@ -52,10 +56,10 @@ static bool executableExistsInPath(const std::string& exe) {
|
||||||
if (ec)
|
if (ec)
|
||||||
continue;
|
continue;
|
||||||
if ((perms & std::filesystem::perms::others_exec) != std::filesystem::perms::none)
|
if ((perms & std::filesystem::perms::others_exec) != std::filesystem::perms::none)
|
||||||
return true;
|
return candidate.string();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
static std::string subin() {
|
static std::string subin() {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
namespace NSys {
|
namespace NSys {
|
||||||
bool isSuperuser();
|
bool isSuperuser();
|
||||||
int getUID();
|
int getUID();
|
||||||
int getEUID();
|
int getEUID();
|
||||||
|
std::optional<std::string> findInPath(const std::string& exe);
|
||||||
|
|
||||||
// NOLINTNEXTLINE
|
// NOLINTNEXTLINE
|
||||||
namespace root {
|
namespace root {
|
||||||
|
|
@ -20,4 +22,4 @@ namespace NSys {
|
||||||
// Do not use this unless absolutely necessary!
|
// Do not use this unless absolutely necessary!
|
||||||
std::string runAsSuperuserUnsafe(const std::string& cmd);
|
std::string runAsSuperuserUnsafe(const std::string& cmd);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ constexpr std::string_view HELP = R"#(┏ hyprpm, a Hyprland Plugin Manager
|
||||||
┃
|
┃
|
||||||
┣ Flags:
|
┣ Flags:
|
||||||
┃
|
┃
|
||||||
|
┣ --no-nix | → Disable `nix develop` for build commands, even if Hyprland is nix.
|
||||||
┣ --notify | -n → Send a hyprland notification confirming successful plugin load.
|
┣ --notify | -n → Send a hyprland notification confirming successful plugin load.
|
||||||
┃ Warnings/Errors trigger notifications regardless of this flag.
|
┃ Warnings/Errors trigger notifications regardless of this flag.
|
||||||
┣ --help | -h → Show this menu.
|
┣ --help | -h → Show this menu.
|
||||||
|
|
@ -47,7 +48,7 @@ int main(int argc, char** argv, char** envp) {
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> command;
|
std::vector<std::string> command;
|
||||||
bool notify = false, verbose = false, force = false, noShallow = false;
|
bool notify = false, verbose = false, force = false, noShallow = false, noNix = false;
|
||||||
std::string customHlUrl;
|
std::string customHlUrl;
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
|
|
@ -63,6 +64,8 @@ int main(int argc, char** argv, char** envp) {
|
||||||
g_pPluginManager->notify(ICON_INFO, 0, 10000, "[hyprpm] -n flag is deprecated, see hyprpm --help.");
|
g_pPluginManager->notify(ICON_INFO, 0, 10000, "[hyprpm] -n flag is deprecated, see hyprpm --help.");
|
||||||
} else if (ARGS[i] == "--verbose" || ARGS[i] == "-v") {
|
} else if (ARGS[i] == "--verbose" || ARGS[i] == "-v") {
|
||||||
verbose = true;
|
verbose = true;
|
||||||
|
} else if (ARGS[i] == "--no-nix") {
|
||||||
|
noNix = true;
|
||||||
} else if (ARGS[i] == "--no-shallow" || ARGS[i] == "-s") {
|
} else if (ARGS[i] == "--no-shallow" || ARGS[i] == "-s") {
|
||||||
noShallow = true;
|
noShallow = true;
|
||||||
} else if (ARGS[i] == "--hl-url") {
|
} else if (ARGS[i] == "--hl-url") {
|
||||||
|
|
@ -91,7 +94,9 @@ int main(int argc, char** argv, char** envp) {
|
||||||
g_pPluginManager = std::make_unique<CPluginManager>();
|
g_pPluginManager = std::make_unique<CPluginManager>();
|
||||||
g_pPluginManager->m_bVerbose = verbose;
|
g_pPluginManager->m_bVerbose = verbose;
|
||||||
g_pPluginManager->m_bNoShallow = noShallow;
|
g_pPluginManager->m_bNoShallow = noShallow;
|
||||||
|
g_pPluginManager->m_bNoNix = noNix;
|
||||||
g_pPluginManager->m_szCustomHlUrl = customHlUrl;
|
g_pPluginManager->m_szCustomHlUrl = customHlUrl;
|
||||||
|
g_pPluginManager->m_szArgv0 = argv[0];
|
||||||
|
|
||||||
if (command[0] == "add") {
|
if (command[0] == "add") {
|
||||||
if (command.size() < 2) {
|
if (command.size() < 2) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue