le new index :3

This commit is contained in:
HJfod 2024-02-26 22:14:53 +02:00
parent dd57a39157
commit 2689320116
12 changed files with 578 additions and 125 deletions

View file

@ -61,6 +61,7 @@ file(GLOB SOURCES CONFIGURE_DEPENDS
src/hooks/*.cpp
src/ids/*.cpp
src/internal/*.cpp
src/server/*.cpp
src/loader/*.cpp
src/load.cpp
src/utils/*.cpp

View file

@ -678,55 +678,3 @@ ModMetadata& ModMetadata::operator=(ModMetadata&& other) noexcept {
}
ModMetadata::~ModMetadata() = default;
template <>
struct matjson::Serialize<geode::ModMetadata::Dependency::Importance> {
static matjson::Value GEODE_DLL to_json(geode::ModMetadata::Dependency::Importance const& importance) {
switch (importance) {
case geode::ModMetadata::Dependency::Importance::Required: return {"required"};
case geode::ModMetadata::Dependency::Importance::Recommended: return {"recommended"};
case geode::ModMetadata::Dependency::Importance::Suggested: return {"suggested"};
default: return {"unknown"};
}
}
static geode::ModMetadata::Dependency::Importance GEODE_DLL from_json(matjson::Value const& importance) {
auto impStr = importance.as_string();
if (impStr == "required")
return geode::ModMetadata::Dependency::Importance::Required;
if (impStr == "recommended")
return geode::ModMetadata::Dependency::Importance::Recommended;
if (impStr == "suggested")
return geode::ModMetadata::Dependency::Importance::Suggested;
throw matjson::JsonException(R"(Expected importance to be "required", "recommended" or "suggested")");
}
static bool is_json(matjson::Value const& value) {
return value.is_string();
}
};
template <>
struct matjson::Serialize<geode::ModMetadata::Incompatibility::Importance> {
static matjson::Value GEODE_DLL to_json(geode::ModMetadata::Incompatibility::Importance const& importance) {
switch (importance) {
case geode::ModMetadata::Incompatibility::Importance::Breaking: return {"breaking"};
case geode::ModMetadata::Incompatibility::Importance::Conflicting: return {"conflicting"};
case geode::ModMetadata::Incompatibility::Importance::Superseded: return {"superseded"};
default: return {"unknown"};
}
}
static geode::ModMetadata::Incompatibility::Importance GEODE_DLL from_json(matjson::Value const& importance) {
auto impStr = importance.as_string();
if (impStr == "breaking")
return geode::ModMetadata::Incompatibility::Importance::Breaking;
if (impStr == "conflicting")
return geode::ModMetadata::Incompatibility::Importance::Conflicting;
if (impStr == "superseded")
return geode::ModMetadata::Incompatibility::Importance::Superseded;
throw matjson::JsonException(R"(Expected importance to be "breaking", "conflicting", or "superseded")");
}
static bool is_json(matjson::Value const& value) {
return value.is_string();
}
};

View file

@ -60,3 +60,55 @@ namespace geode {
static ModMetadata::Impl& getImpl(ModMetadata& info);
};
}
template <>
struct matjson::Serialize<geode::ModMetadata::Dependency::Importance> {
static matjson::Value GEODE_DLL to_json(geode::ModMetadata::Dependency::Importance const& importance) {
switch (importance) {
case geode::ModMetadata::Dependency::Importance::Required: return {"required"};
case geode::ModMetadata::Dependency::Importance::Recommended: return {"recommended"};
case geode::ModMetadata::Dependency::Importance::Suggested: return {"suggested"};
default: return {"unknown"};
}
}
static geode::ModMetadata::Dependency::Importance GEODE_DLL from_json(matjson::Value const& importance) {
auto impStr = importance.as_string();
if (impStr == "required")
return geode::ModMetadata::Dependency::Importance::Required;
if (impStr == "recommended")
return geode::ModMetadata::Dependency::Importance::Recommended;
if (impStr == "suggested")
return geode::ModMetadata::Dependency::Importance::Suggested;
throw matjson::JsonException(R"(Expected importance to be "required", "recommended" or "suggested")");
}
static bool is_json(matjson::Value const& value) {
return value.is_string();
}
};
template <>
struct matjson::Serialize<geode::ModMetadata::Incompatibility::Importance> {
static matjson::Value GEODE_DLL to_json(geode::ModMetadata::Incompatibility::Importance const& importance) {
switch (importance) {
case geode::ModMetadata::Incompatibility::Importance::Breaking: return {"breaking"};
case geode::ModMetadata::Incompatibility::Importance::Conflicting: return {"conflicting"};
case geode::ModMetadata::Incompatibility::Importance::Superseded: return {"superseded"};
default: return {"unknown"};
}
}
static geode::ModMetadata::Incompatibility::Importance GEODE_DLL from_json(matjson::Value const& importance) {
auto impStr = importance.as_string();
if (impStr == "breaking")
return geode::ModMetadata::Incompatibility::Importance::Breaking;
if (impStr == "conflicting")
return geode::ModMetadata::Incompatibility::Importance::Conflicting;
if (impStr == "superseded")
return geode::ModMetadata::Incompatibility::Importance::Superseded;
throw matjson::JsonException(R"(Expected importance to be "breaking", "conflicting", or "superseded")");
}
static bool is_json(matjson::Value const& value) {
return value.is_string();
}
};

View file

@ -0,0 +1,256 @@
#include "Server.hpp"
#include <Geode/utils/JsonValidation.hpp>
#include <loader/ModMetadataImpl.hpp>
using namespace server;
// Helpers for getting current GD version as a string for URL params
#define GEODE_GD_VERSION_STRINGIFY(version) # version
#define GEODE_GD_VERSION_STRINGIFY_2(version) GEODE_GD_VERSION_STRINGIFY(version)
#define GEODE_GD_VERSION_STR GEODE_GD_VERSION_STRINGIFY_2(GEODE_GD_VERSION)
const char* server::sortToString(ModsSort sorting) {
switch (sorting) {
default:
case ModsSort::Downloads: return "downloads";
case ModsSort::RecentlyUpdated: return "recently_updated";
case ModsSort::RecentlyPublished: return "recently_published";
}
}
Result<ServerModVersion> ServerModVersion::parse(matjson::Value const& raw) {
auto json = raw;
JsonChecker checker(json);
auto root = checker.root("ServerModVersion").obj();
auto res = ServerModVersion();
// Verify target Geode version
auto version = root.needs("geode").template get<VersionInfo>();
if (!semverCompare(Loader::get()->getVersion(), version)) {
return Err(
"Mod targets version {} but Geode is version {}",
version, Loader::get()->getVersion()
);
}
// Verify target GD version
auto gd = root.needs("gd").obj().needs(GEODE_PLATFORM_SHORT_IDENTIFIER).template get<std::string>();
if (gd != GEODE_GD_VERSION_STR) {
return Err(
"Mod targets GD version {} but current is version {}",
gd, GEODE_GD_VERSION_STR
);
}
// Get server info
root.needs("download_link").into(res.downloadURL);
root.needs("download_count").into(res.downloadCount);
root.needs("hash").into(res.hash);
// Get mod metadata info
res.metadata.setID(root.needs("mod_id").template get<std::string>());
res.metadata.setName(root.needs("name").template get<std::string>());
res.metadata.setDescription(root.needs("description").template get<std::string>());
res.metadata.setVersion(root.needs("version").template get<VersionInfo>());
res.metadata.setIsAPI(root.needs("api").template get<bool>());
std::vector<ModMetadata::Dependency> dependencies {};
for (auto dep : root.has("dependencies").iterate()) {
// todo: this should probably be generalized to use the same function as mod.json
auto obj = dep.obj();
bool onThisPlatform = !obj.has("platforms");
for (auto& plat : obj.has("platforms").iterate()) {
if (PlatformID::from(plat.get<std::string>()) == GEODE_PLATFORM_TARGET) {
onThisPlatform = true;
}
}
if (!onThisPlatform) {
continue;
}
ModMetadata::Dependency dependency;
obj.needs("id").validate(MiniFunction<bool(std::string const&)>(&ModMetadata::validateID)).into(dependency.id);
obj.needs("version").into(dependency.version);
obj.has("importance").into(dependency.importance);
dependencies.push_back(dependency);
}
res.metadata.setDependencies(dependencies);
std::vector<ModMetadata::Incompatibility> incompatibilities {};
for (auto& incompat : root.has("incompatibilities").iterate()) {
auto obj = incompat.obj();
ModMetadata::Incompatibility incompatibility;
obj.needs("id").validate(MiniFunction<bool(std::string const&)>(&ModMetadata::validateID)).into(incompatibility.id);
obj.needs("version").into(incompatibility.version);
obj.has("importance").into(incompatibility.importance);
incompatibilities.push_back(incompatibility);
}
// Check for errors and return result
if (root.isError()) {
return Err(root.getError());
}
return Ok(res);
}
Result<ServerModMetadata> ServerModMetadata::parse(matjson::Value const& raw) {
auto json = raw;
JsonChecker checker(json);
auto root = checker.root("ServerModMetadata").obj();
auto res = ServerModMetadata();
root.needs("id").into(res.id);
root.needs("latest_version").into(res.latestVersion);
root.needs("featured").into(res.featured);
root.needs("download_count").into(res.downloadCount);
root.has("about").into(res.about);
root.has("changelog").into(res.changelog);
for (auto item : root.needs("developers").iterate()) {
auto obj = item.obj();
auto dev = ServerDeveloper();
obj.needs("username").into(dev.username);
obj.needs("display_name").into(dev.displayName);
res.developers.push_back(dev);
}
for (auto item : root.needs("versions").iterate()) {
auto versionRes = ServerModVersion::parse(item.json());
if (versionRes) {
auto version = versionRes.unwrap();
version.metadata.setDetails(res.about);
version.metadata.setChangelog(res.changelog);
res.versions.push_back(version);
}
else {
log::error("Unable to parse mod '{}' version from the server: {}", res.id, versionRes.unwrapErr());
}
}
// Ensure there's at least one valid version
if (res.versions.empty()) {
return Err("Mod '{}' has no (valid) versions", res.id);
}
for (auto item : root.has("tags").iterate()) {
res.tags.insert(item.template get<std::string>());
}
root.needs("download_count").into(res.downloadCount);
// Check for errors and return result
if (root.isError()) {
return Err(root.getError());
}
return Ok(res);
}
Result<ServerModsList> ServerModsList::parse(matjson::Value const& raw) {
auto json = raw;
JsonChecker checker(json);
auto payload = checker.root("ServerModsList").obj().needs("payload").obj();
auto list = ServerModsList();
for (auto item : payload.needs("data").iterate()) {
GEODE_UNWRAP_INTO(auto mod, ServerModMetadata::parse(item.json()));
list.mods.push_back(mod);
}
payload.needs("count").into(list.totalModCount);
// Check for errors and return result
if (payload.isError()) {
return Err(payload.getError());
}
return Ok(list);
}
std::string server::getServerAPIBaseURL() {
return "https://api.geode-sdk.org/v1";
}
ServerPromise<ServerModsList> server::getMods(ModsQuery query) {
auto req = web::WebRequest();
// Always target current GD version and Loader version
req.param("gd", GEODE_GD_VERSION_STR);
req.param("geode", Loader::get()->getVersion().toString());
// Add search params
if (query.query) {
req.param("query", *query.query);
}
if (query.platforms.size()) {
std::string plats = "";
bool first = true;
for (auto plat : query.platforms) {
if (!first) plats += ",";
plats += PlatformID::toShortString(plat.m_value);
first = false;
}
req.param("platforms", plats);
}
if (query.tags.size()) {
req.param("tags", ranges::join(query.tags, ","));
}
req.param("featured", query.featuredOnly ? "true" : "false");
req.param("sort", sortToString(query.sorting));
if (query.developer) {
req.param("developer", *query.developer);
}
// Paging (1-based on server, 0-based locally)
req.param("page", std::to_string(query.page + 1));
req.param("per_page", std::to_string(query.pageSize));
return ServerPromise<ServerModsList>([req = std::move(req)](auto resolve, auto reject, auto progress, auto cancel) mutable {
req.get(getServerAPIBaseURL() + "/mods")
.then([resolve, reject](auto value) {
// Validate that the response was JSON
auto asJson = value.json();
if (!asJson) {
return reject(ServerError("Response was not valid JSON: {}", asJson.unwrapErr()));
}
// Parse response
auto list = ServerModsList::parse(asJson.unwrap());
if (!list) {
return reject(ServerError("Unable to parse response: {}", list.unwrapErr()));
}
resolve(list.unwrap());
})
.expect([resolve, reject](auto error) {
// Treat a 404 as empty mods list
if (error.code() == 404) {
return resolve(ServerModsList());
}
// The server should return errors as `{ "error": "...", "payload": "" }`
if (auto json = error.json()) {
reject(ServerError(
"Error code: {}; details: {}",
error.code(), json.unwrap().template get<std::string>("error")
));
}
// But if we get something else for some reason, return that
else {
reject(ServerError(
"Error code: {}; details: {}",
error.code(), error.string().unwrapOr("Unknown (not a valid string)")
));
}
})
.progress([progress](auto prog) {
if (auto per = prog.downloadProgress()) {
progress({ "Downloading mods", static_cast<uint8_t>(*per) });
}
else {
progress({ "Downloading mods" });
}
})
.link(cancel);
});
}

View file

@ -0,0 +1,80 @@
#pragma once
#include <Geode/DefaultInclude.hpp>
#include <Geode/utils/Promise.hpp>
#include <Geode/utils/web2.hpp>
using namespace geode::prelude;
namespace server {
struct ServerDeveloper {
std::string username;
std::string displayName;
};
struct ServerModVersion {
ModMetadata metadata;
std::string downloadURL;
std::string hash;
size_t downloadCount;
static Result<ServerModVersion> parse(matjson::Value const& json);
};
struct ServerModMetadata {
std::string id;
VersionInfo latestVersion;
bool featured;
size_t downloadCount;
std::vector<ServerDeveloper> developers;
std::vector<ServerModVersion> versions;
std::unordered_set<std::string> tags;
std::optional<std::string> about;
std::optional<std::string> changelog;
static Result<ServerModMetadata> parse(matjson::Value const& json);
};
struct ServerModsList {
std::vector<ServerModMetadata> mods;
size_t totalModCount = 0;
static Result<ServerModsList> parse(matjson::Value const& json);
};
enum class ModsSort {
Downloads,
RecentlyUpdated,
RecentlyPublished,
};
static const char* sortToString(ModsSort sorting);
struct ModsQuery {
std::optional<std::string> query;
std::unordered_set<PlatformID> platforms = { GEODE_PLATFORM_TARGET };
std::unordered_set<std::string> tags;
bool featuredOnly = false;
ModsSort sorting = ModsSort::Downloads;
std::optional<std::string> developer;
size_t page = 0;
size_t pageSize = 10;
};
struct ServerError {
std::string details;
ServerError() = default;
template <class... Args>
ServerError(
fmt::string_view format,
Args&&... args
) : details(fmt::vformat(format, fmt::make_format_args(args...))) {}
};
template <class T>
using ServerPromise = Promise<T, ServerError>;
std::string getServerAPIBaseURL();
ServerPromise<ServerModsList> getMods(ModsQuery query);
}

View file

@ -113,3 +113,35 @@ CCNode* InstalledModItem::createModLogo() const {
bool InstalledModItem::wantsRestart() const {
return m_mod->getRequestedAction() != ModRequestedAction::None;
}
bool ServerModItem::init(server::ServerModMetadata const& metadata) {
m_metadata = metadata;
if (!BaseModItem::init())
return false;
return true;
}
ServerModItem* ServerModItem::create(server::ServerModMetadata const& metadata) {
auto ret = new ServerModItem();
if (ret && ret->init(metadata)) {
ret->autorelease();
return ret;
}
CC_SAFE_DELETE(ret);
return nullptr;
}
ModMetadata ServerModItem::getMetadata() const {
return m_metadata.versions.front().metadata;
}
CCNode* ServerModItem::createModLogo() const {
return CCSprite::create("loadingCircle.png");
}
bool ServerModItem::wantsRestart() const {
// todo: request restart after install
return false;
}

View file

@ -1,6 +1,7 @@
#pragma once
#include <Geode/ui/General.hpp>
#include <server/Server.hpp>
using namespace geode::prelude;
@ -42,3 +43,20 @@ public:
CCNode* createModLogo() const override;
bool wantsRestart() const override;
};
class ServerModItem : public BaseModItem {
protected:
server::ServerModMetadata m_metadata;
bool init(server::ServerModMetadata const& metadata);
public:
/**
* @note Make sure to call `updateSize` afterwards
*/
static ServerModItem* create(server::ServerModMetadata const& metadata);
ModMetadata getMetadata() const override;
CCNode* createModLogo() const override;
bool wantsRestart() const override;
};

View file

@ -1,7 +1,12 @@
#include "ModListSource.hpp"
#include <Geode/utils/web2.hpp>
#include <server/Server.hpp>
static size_t INSTALLED_MODS_PAGE_SIZE = 10;
static size_t PAGE_SIZE = 10;
static size_t ceildiv(size_t a, size_t b) {
// https://stackoverflow.com/questions/2745074/fast-ceiling-of-an-integer-division-in-c-c
return a / b + (a % b != 0);
}
#define GEODE_GD_VERSION_STRINGIFY(version) # version
#define GEODE_GD_VERSION_STRINGIFY_2(version) GEODE_GD_VERSION_STRINGIFY(version)
@ -33,18 +38,22 @@ typename ModListSource::PagePromise ModListSource::loadPage(size_t page, bool up
}
std::optional<size_t> ModListSource::getPageCount() const {
return m_cachedPageCount;
return m_cachedItemCount ? std::optional(ceildiv(m_cachedItemCount.value(), PAGE_SIZE)) : std::nullopt;
}
std::optional<size_t> ModListSource::getItemCount() const {
return m_cachedItemCount;
}
typename ModListSource::PagePromise InstalledModsList::reloadPage(size_t page) {
m_cachedPageCount = Loader::get()->getAllMods().size() / INSTALLED_MODS_PAGE_SIZE + 1;
m_cachedItemCount = Loader::get()->getAllMods().size();
return PagePromise([page](auto resolve, auto, auto, auto const&) {
Loader::get()->queueInMainThread([page, resolve = std::move(resolve)] {
auto content = Page();
auto all = Loader::get()->getAllMods();
for (
size_t i = page * INSTALLED_MODS_PAGE_SIZE;
i < all.size() && i < (page + 1) * INSTALLED_MODS_PAGE_SIZE;
size_t i = page * PAGE_SIZE;
i < all.size() && i < (page + 1) * PAGE_SIZE;
i += 1
) {
content.push_back(InstalledModItem::create(all.at(i)));
@ -59,46 +68,30 @@ InstalledModsList* InstalledModsList::get() {
return inst;
}
Result<typename FeaturedModsList::Page> FeaturedModsList::parseModsListResponse(matjson::Value const& json) {
log::info("mods list: {}", json);
return Ok(Page());
}
typename ModListSource::PagePromise FeaturedModsList::reloadPage(size_t page) {
return PagePromise([](auto resolve, auto reject, auto progress, auto cancelled) {
web::WebRequest()
.param("gd", GEODE_GD_VERSION_STR)
.param("featured", "true")
.get("https://api.geode-sdk.org/v1/mods")
.then([resolve, reject](web::WebResponse value) {
// Validate that the response was JSON
auto json = value.json();
if (!json) {
return reject(LoadPageError(
"Error loading mods",
"Response was not valid JSON: " + json.unwrapErr()
));
}
// Parse the response into a mods list
auto page = parseModsListResponse(json.unwrap());
if (!page) {
return reject(LoadPageError("Error loading mods", page.unwrapErr()));
}
resolve(page.unwrap());
})
.expect([reject](web::WebResponse value) {
// Specialize 404 for a "No mods found" error
if (value.code() == 404) {
return reject(LoadPageError("No mods found :("));
}
// Otherwise generic error
reject(LoadPageError("Error loading mods", value.string().ok()));
})
.progress([progress](web::WebProgress info) {
progress(info.downloadProgress());
})
.link(cancelled);
return PagePromise([this, page](auto resolve, auto reject, auto progress, auto cancelled) {
server::getMods(server::ModsQuery {
.page = page,
.pageSize = PAGE_SIZE,
})
.then([this, resolve, reject](server::ServerModsList list) {
m_cachedItemCount = list.totalModCount;
if (list.totalModCount == 0) {
return reject(LoadPageError("No mods found :("));
}
auto content = Page();
for (auto mod : list.mods) {
content.push_back(ServerModItem::create(mod));
}
resolve(content);
})
.expect([reject](auto error) {
reject(LoadPageError("Error loading mods", error.details));
})
.progress([progress](auto prog) {
progress(prog.percentage);
})
.link(cancelled);
});
}
@ -108,7 +101,7 @@ FeaturedModsList* FeaturedModsList::get() {
}
typename ModListSource::PagePromise ModPacksModsList::reloadPage(size_t page) {
m_cachedPageCount = 0;
m_cachedItemCount = 0;
return PagePromise([](auto, auto reject) {
reject(LoadPageError("Coming soon! ;)"));
});

View file

@ -19,14 +19,14 @@ public:
};
using Page = std::vector<Ref<BaseModItem>>;
using PageLoadEvent = PromiseEvent<Page, LoadPageError, std::optional<float>>;
using PageLoadEventFilter = PromiseEventFilter<Page, LoadPageError, std::optional<float>>;
using PageLoadEvent = PromiseEvent<Page, LoadPageError, std::optional<uint8_t>>;
using PageLoadEventFilter = PromiseEventFilter<Page, LoadPageError, std::optional<uint8_t>>;
using PageLoadEventListener = EventListener<PageLoadEventFilter>;
using PagePromise = Promise<Page, LoadPageError, std::optional<float>>;
using PagePromise = Promise<Page, LoadPageError, std::optional<uint8_t>>;
protected:
std::unordered_map<size_t, Page> m_cachedPages;
std::optional<size_t> m_cachedPageCount;
std::optional<size_t> m_cachedItemCount;
// Load/reload a page. This should also set/update the page count
virtual PagePromise reloadPage(size_t page) = 0;
@ -35,6 +35,7 @@ public:
// Load page, uses cache if possible unless `update` is true
PagePromise loadPage(size_t page, bool update = false);
std::optional<size_t> getPageCount() const;
std::optional<size_t> getItemCount() const;
};
class InstalledModsList : public ModListSource {
@ -49,8 +50,6 @@ class FeaturedModsList : public ModListSource {
protected:
PagePromise reloadPage(size_t page) override;
static Result<Page> parseModsListResponse(matjson::Value const& json);
public:
static FeaturedModsList* get();
};

View file

@ -77,14 +77,44 @@ bool ModList::init(ModListSource* src, CCSize const& size) {
pageLabelMenu->setLayout(RowLayout::create());
this->addChildAtPosition(pageLabelMenu, Anchor::Bottom, ccp(0, -5));
m_statusText = SimpleTextArea::create("", "bigFont.fnt", .6f);
m_statusText->setAlignment(kCCTextAlignmentCenter);
this->addChildAtPosition(m_statusText, Anchor::Center, ccp(0, 40));
m_statusContainer = CCMenu::create();
m_statusContainer->setScale(.5f);
m_statusContainer->setContentHeight(size.height / m_statusContainer->getScale());
m_statusContainer->setAnchorPoint({ .5f, .5f });
m_statusContainer->ignoreAnchorPointForPosition(false);
m_statusTitle = CCLabelBMFont::create("", "bigFont.fnt");
m_statusTitle->setAlignment(kCCTextAlignmentCenter);
m_statusContainer->addChild(m_statusTitle);
m_statusDetailsBtn = CCMenuItemSpriteExtra::create(
ButtonSprite::create("Details", "bigFont.fnt", "GJ_button_05.png", .75f),
this, menu_selector(ModList::onShowStatusDetails)
);
m_statusContainer->addChild(m_statusDetailsBtn);
m_statusDetails = SimpleTextArea::create("", "chatFont.fnt", .6f);
m_statusDetails->setAlignment(kCCTextAlignmentCenter);
m_statusContainer->addChild(m_statusDetails);
m_statusLoadingCircle = CCSprite::create("loadingCircle.png");
m_statusLoadingCircle->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
m_statusLoadingCircle->setBlendFunc({ GL_ONE, GL_ONE });
m_statusLoadingCircle->setScale(.6f);
this->addChildAtPosition(m_statusLoadingCircle, Anchor::Center, ccp(0, -40));
m_statusContainer->addChild(m_statusLoadingCircle);
m_statusLoadingBar = Slider::create(this, nullptr);
m_statusLoadingBar->m_touchLogic->m_thumb->setVisible(false);
m_statusLoadingBar->setValue(0);
m_statusLoadingBar->updateBar();
m_statusLoadingBar->setAnchorPoint({ 0, 0 });
m_statusContainer->addChild(m_statusLoadingBar);
m_statusContainer->setLayout(
ColumnLayout::create()
->setAxisReverse(true)
);
m_statusContainer->getLayout()->ignoreInvisibleChildren(true);
this->addChildAtPosition(m_statusContainer, Anchor::Center);
m_listener.bind(this, &ModList::onPromise);
@ -96,8 +126,7 @@ bool ModList::init(ModListSource* src, CCSize const& size) {
void ModList::onPromise(typename ModListSource::PageLoadEvent* event) {
if (auto resolved = event->getResolve()) {
// Hide status
m_statusText->setVisible(false);
m_statusLoadingCircle->setVisible(false);
m_statusContainer->setVisible(false);
// Create items
for (auto item : *resolved) {
@ -116,10 +145,17 @@ void ModList::onPromise(typename ModListSource::PageLoadEvent* event) {
}
else if (auto progress = event->getProgress()) {
// todo: percentage in a loading bar
this->showStatus("Loading...", true);
if (progress->has_value()) {
this->showStatus(ModListProgressStatus {
.percentage = progress->value(),
}, "Loading...");
}
else {
this->showStatus(ModListUnkProgressStatus(), "Loading...");
}
}
else if (auto rejected = event->getReject()) {
this->showStatus(rejected->message, false);
this->showStatus(ModListErrorStatus(), rejected->message, rejected->details);
// todo: details
this->updatePageUI(true);
}
@ -157,6 +193,11 @@ void ModList::onPage(CCObject* sender) {
this->gotoPage(m_page);
}
void ModList::onShowStatusDetails(CCObject*) {
m_statusDetails->setVisible(!m_statusDetails->isVisible());
m_statusContainer->updateLayout();
}
void ModList::updatePageUI(bool hide) {
auto pageCount = m_source->getPageCount();
@ -168,7 +209,10 @@ void ModList::updatePageUI(bool hide) {
m_pageNextBtn->setVisible(!hide && m_page < pageCount.value() - 1);
m_pageLabelBtn->setVisible(!hide);
if (pageCount > 0u) {
auto fmt = fmt::format("Page {}/{}", m_page + 1, pageCount.value());
auto fmt = fmt::format(
"Page {}/{} (Total {})",
m_page + 1, pageCount.value(), m_source->getItemCount().value()
);
m_pageLabel->setString(fmt.c_str());
}
}
@ -195,7 +239,7 @@ void ModList::gotoPage(size_t page, bool update) {
m_page = page;
// Start loading new page with generic loading message
this->showStatus("Loading...", true);
this->showStatus(ModListUnkProgressStatus(), "Loading...");
m_listener.setFilter(m_source->loadPage(page, update).listen());
// Do initial eager update on page UI (to prevent user spamming arrows
@ -203,17 +247,33 @@ void ModList::gotoPage(size_t page, bool update) {
this->updatePageUI();
}
void ModList::showStatus(std::string const& status, bool loading) {
void ModList::showStatus(ModListStatus status, std::string const& message, std::optional<std::string> const& details) {
// Clear list contents
m_list->m_contentLayer->removeAllChildren();
// Update status
m_statusText->setText(status);
m_statusText->updateAnchoredPosition(Anchor::Center, (loading ? ccp(0, 40) : ccp(0, 0)));
m_statusTitle->setString(message.c_str());
m_statusDetails->setText(details.value_or(""));
// Make status visible
m_statusText->setVisible(true);
m_statusLoadingCircle->setVisible(loading);
// Update status visibility
m_statusContainer->setVisible(true);
m_statusDetails->setVisible(false);
m_statusDetailsBtn->setVisible(details.has_value());
m_statusLoadingCircle->setVisible(std::holds_alternative<ModListUnkProgressStatus>(status));
m_statusLoadingBar->setVisible(std::holds_alternative<ModListProgressStatus>(status));
// The loading circle action gets stopped for some reason so just reactivate it
if (m_statusLoadingCircle->isVisible()) {
m_statusLoadingCircle->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
}
// Update progress bar
if (auto per = std::get_if<ModListProgressStatus>(&status)) {
m_statusLoadingBar->setValue(per->percentage / 100.f);
m_statusLoadingBar->updateBar();
}
// Update layout to automatically rearrange everything neatly in the status
m_statusContainer->updateLayout();
}
ModList* ModList::create(ModListSource* src, CCSize const& size) {

View file

@ -8,13 +8,24 @@
using namespace geode::prelude;
struct ModListErrorStatus {};
struct ModListUnkProgressStatus {};
struct ModListProgressStatus {
uint8_t percentage;
};
using ModListStatus = std::variant<ModListErrorStatus, ModListUnkProgressStatus, ModListProgressStatus>;
class ModList : public CCNode, public SetTextPopupDelegate {
protected:
ModListSource* m_source;
size_t m_page = 0;
ScrollLayer* m_list;
SimpleTextArea* m_statusText;
CCMenu* m_statusContainer;
CCLabelBMFont* m_statusTitle;
SimpleTextArea* m_statusDetails;
CCMenuItemSpriteExtra* m_statusDetailsBtn;
CCSprite* m_statusLoadingCircle;
Slider* m_statusLoadingBar;
ModListSource::PageLoadEventListener m_listener;
CCMenuItemSpriteExtra* m_pagePrevBtn;
CCMenuItemSpriteExtra* m_pageNextBtn;
@ -26,6 +37,7 @@ protected:
void onPromise(ModListSource::PageLoadEvent* event);
void onPage(CCObject*);
void onGoToPage(CCObject*);
void onShowStatusDetails(CCObject*);
void setTextPopupClosed(SetTextPopup*, gd::string value) override;
@ -36,7 +48,7 @@ public:
void reloadPage();
void gotoPage(size_t page, bool update = false);
void showStatus(std::string const& status, bool loading);
void showStatus(ModListStatus status, std::string const& message, std::optional<std::string> const& details = std::nullopt);
};
class ModsLayer : public CCLayer {

View file

@ -127,7 +127,7 @@ WebPromise WebRequest::send(std::string_view method, std::string_view url) {
// Store downloaded response data into a byte vector
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseData);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* data, size_t size, size_t nmemb, void* ptr) {
auto target = static_cast<ResponseData*>(ptr)->response.m_impl->m_data;
auto& target = static_cast<ResponseData*>(ptr)->response.m_impl->m_data;
target.insert(target.end(), data, data + size * nmemb);
return size * nmemb;
});
@ -148,8 +148,10 @@ WebPromise WebRequest::send(std::string_view method, std::string_view url) {
// Add parameters to the URL and pass it to curl
auto url = impl->m_url;
bool first = true;
for (auto param : impl->m_urlParameters) {
url += "&" + param.first + "=" + param.second;
url += (first ? "?" : "&") + param.first + "=" + param.second;
first = false;
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());