fetch mod info from server on installed mods

This commit is contained in:
HJfod 2024-03-02 21:57:40 +02:00
parent 0a17fcc985
commit abe41a79a3
6 changed files with 185 additions and 42 deletions

View file

@ -6,19 +6,53 @@ using namespace server;
#define GEODE_GD_VERSION_STR GEODE_STR(GEODE_GD_VERSION)
static const char* jsonTypeToString(matjson::Type const& type) {
switch (type) {
case matjson::Type::Object: return "object";
case matjson::Type::Array: return "array";
case matjson::Type::Bool: return "boolean";
case matjson::Type::Number: return "number";
case matjson::Type::String: return "string";
case matjson::Type::Null: return "null";
default: return "unknown";
}
}
static Result<matjson::Value, ServerError> parseServerPayload(web::WebResponse&& response) {
auto asJson = response.json();
if (!asJson) {
return Err(ServerError(response.code(), "Response was not valid JSON: {}", asJson.unwrapErr()));
}
auto json = std::move(asJson).unwrap();
if (!json.is_object()) {
return Err(ServerError(response.code(), "Expected object, got {}", jsonTypeToString(json.type())));
}
auto obj = json.as_object();
if (!obj.contains("payload")) {
return Err(ServerError(response.code(), "Object does not contain \"payload\" key - got {}", json.dump()));
}
return Ok(obj["payload"]);
}
static void parseServerError(auto reject, auto error) {
// 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")
));
if (auto asJson = error.json()) {
auto json = asJson.unwrap();
if (json.is_object() && json.contains("error")) {
reject(ServerError(
error.code(),
"{}", json.template get<std::string>("error")
));
}
else {
reject(ServerError(error.code(), "Unknown (not valid JSON)"));
}
}
// 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)")
error.code(),
"{}", error.string().unwrapOr("Unknown (not a valid string)")
));
}
}
@ -178,7 +212,7 @@ Result<ServerModMetadata> ServerModMetadata::parse(matjson::Value const& raw) {
Result<ServerModsList> ServerModsList::parse(matjson::Value const& raw) {
auto json = raw;
JsonChecker checker(json);
auto payload = checker.root("ServerModsList").obj().needs("payload").obj();
auto payload = checker.root("ServerModsList").obj();
auto list = ServerModsList();
for (auto item : payload.needs("data").iterate()) {
@ -251,16 +285,18 @@ ServerPromise<ServerModsList> server::getMods(ModsQuery const& query) {
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()));
}
// Store the code, since the value is moved afterwards
auto code = value.code();
// Parse payload
auto payload = parseServerPayload(std::move(value));
if (!payload) {
return reject(payload.unwrapErr());
}
// Parse response
auto list = ServerModsList::parse(asJson.unwrap());
auto list = ServerModsList::parse(payload.unwrap());
if (!list) {
return reject(ServerError("Unable to parse response: {}", list.unwrapErr()));
return reject(ServerError(code, "Unable to parse response: {}", list.unwrapErr()));
}
resolve(list.unwrap());
})
@ -278,11 +314,40 @@ ServerPromise<ServerModsList> server::getMods(ModsQuery const& query) {
});
}
ServerPromise<ServerModMetadata> server::getMod(std::string const& id) {
auto req = web::WebRequest();
req.userAgent(getServerUserAgent());
return ServerPromise<ServerModMetadata>([req = std::move(req), id](auto resolve, auto reject, auto progress, auto cancel) mutable {
req.get(getServerAPIBaseURL() + "/mods/" + id)
.then([resolve, reject](auto value) {
// Store the code, since the value is moved afterwards
auto code = value.code();
// Parse payload
auto payload = parseServerPayload(std::move(value));
if (!payload) {
return reject(payload.unwrapErr());
}
// Parse response
auto list = ServerModMetadata::parse(payload.unwrap());
if (!list) {
return reject(ServerError(code, "Unable to parse response: {}", list.unwrapErr()));
}
resolve(list.unwrap());
})
.expect([reject](auto error) {
parseServerError(reject, error);
})
.progress([progress, id](auto prog) {
parseServerProgress(progress, prog, "Downloading logo for " + id);
})
.link(cancel);
});
}
ServerPromise<ByteVector> server::getModLogo(std::string const& id) {
auto req = web::WebRequest();
req.userAgent(getServerUserAgent());
req.param("id", id);
return ServerPromise<ByteVector>([req = std::move(req), id](auto resolve, auto reject, auto progress, auto cancel) mutable {
req.get(getServerAPIBaseURL() + "/mods/" + id + "/logo")
.then([resolve](auto response) {

View file

@ -61,15 +61,17 @@ namespace server {
};
struct ServerError {
int code;
std::string details;
ServerError() = default;
template <class... Args>
ServerError(
int code,
fmt::string_view format,
Args&&... args
) : details(fmt::vformat(format, fmt::make_format_args(args...))) {}
) : code(code), details(fmt::vformat(format, fmt::make_format_args(args...))) {}
};
template <class T>
using ServerPromise = Promise<T, ServerError>;
@ -77,5 +79,6 @@ namespace server {
std::string getServerAPIBaseURL();
std::string getServerUserAgent();
ServerPromise<ServerModsList> getMods(ModsQuery const& query);
ServerPromise<ServerModMetadata> getMod(std::string const& id);
ServerPromise<ByteVector> getModLogo(std::string const& id);
}

View file

@ -58,18 +58,18 @@ bool ModPopup::setup(ModSource&& src) {
statsBG->setContentSize(statsContainer->getContentSize() / statsBG->getScale());
statsContainer->addChildAtPosition(statsBG, Anchor::Center);
auto statsLayout = CCNode::create();
statsLayout->setContentSize(statsContainer->getContentSize() - ccp(10, 10));
statsLayout->setAnchorPoint({ .5f, .5f });
m_stats = CCNode::create();
m_stats->setContentSize(statsContainer->getContentSize() - ccp(10, 10));
m_stats->setAnchorPoint({ .5f, .5f });
for (auto stat : std::initializer_list<std::tuple<const char*, const char*, std::string, ccColor3B>> {
{ "GJ_downloadsIcon_001.png", "Downloads", "TODO", { 255, 255, 255 } },
{ "GJ_timeIcon_001.png", "Released", "TODO", { 105, 155, 255 } },
{ "GJ_timeIcon_001.png", "Updated", "TODO", { 105, 155, 255 } },
{ "version.png"_spr, "Version", src.getMetadata().getVersion().toString(), { 105, 255, 155 } },
for (auto stat : std::initializer_list<std::tuple<const char*, const char*, const char*, std::optional<std::string>>> {
{ "GJ_downloadsIcon_001.png", "Downloads", "downloads", std::nullopt },
{ "GJ_timeIcon_001.png", "Released", "release-date", std::nullopt },
{ "GJ_timeIcon_001.png", "Updated", "update-date", std::nullopt },
{ "version.png"_spr, "Version", "version", src.getMetadata().getVersion().toString() },
}) {
auto container = CCNode::create();
container->setContentSize({ statsLayout->getContentWidth(), 10 });
container->setContentSize({ m_stats->getContentWidth(), 10 });
auto iconSize = container->getContentHeight();
auto icon = CCSprite::createWithSpriteFrameName(std::get<0>(stat));
@ -88,29 +88,30 @@ bool ModPopup::setup(ModSource&& src) {
);
container->addChildAtPosition(title, Anchor::Left, ccp(container->getContentHeight() + 5, 0));
auto value = CCLabelBMFont::create(std::get<2>(stat).c_str(), "bigFont.fnt");
value->setAnchorPoint({ 1.f, .5f });
value->setColor(std::get<3>(stat));
limitNodeSize(
value,
{
container->getContentWidth() / 2.2f,
container->getContentHeight()
},
.3f, .1f
);
container->addChildAtPosition(value, Anchor::Right);
if (auto value = std::get<3>(stat)) {
this->setStatValue(container, value.value());
}
// Loading indicator for stats that need to be fetched from the server
else {
// todo: refactor these spinners into a reusable class that's not the ass LoadingCircle is
auto spinner = CCSprite::create("loadingCircle.png");
spinner->setBlendFunc({ GL_ONE, GL_ONE });
spinner->runAction(CCRepeatForever::create(CCRotateBy::create(1.f, 360.f)));
limitNodeSize(spinner, { iconSize, iconSize }, 1.f, .1f);
spinner->setID("loading-spinner");
container->addChildAtPosition(spinner, Anchor::Right, ccp(-iconSize / 2, 0));
}
statsLayout->addChild(container);
m_stats->addChild(container);
}
statsLayout->setLayout(
m_stats->setLayout(
ColumnLayout::create()
->setAxisReverse(true)
->setDefaultScaleLimits(.1f, 1)
->setAxisAlignment(AxisAlignment::Even)
);
statsContainer->addChildAtPosition(statsLayout, Anchor::Center);
statsContainer->addChildAtPosition(m_stats, Anchor::Center);
leftColumn->addChild(statsContainer);
@ -161,9 +162,53 @@ bool ModPopup::setup(ModSource&& src) {
// Select details tab
this->loadTab(Tab::Details);
// Load stats from server (or just from the source if it already has them)
m_statsListener.bind(this, &ModPopup::onLoadServerInfo);
m_statsListener.setFilter(m_source.fetchServerInfo().listen());
return true;
}
void ModPopup::setStatValue(CCNode* stat, std::string const& value) {
// Remove old value if it exists
stat->removeChildByID("value-label");
auto valueLabel = CCLabelBMFont::create(value.c_str(), "bigFont.fnt");
valueLabel->setAnchorPoint({ 1.f, .5f });
limitNodeSize(
valueLabel,
{
stat->getContentWidth() / 2.2f,
stat->getContentHeight()
},
.3f, .1f
);
valueLabel->setID("value-label");
stat->addChildAtPosition(valueLabel, Anchor::Right);
}
void ModPopup::onLoadServerInfo(PromiseEvent<server::ServerModMetadata, server::ServerError>* event) {
if (auto data = event->getResolve()) {
for (auto id : std::initializer_list<std::pair<const char*, std::string>> {
{ "downloads", std::to_string(data->downloadCount) },
{ "release-date", "todo" },
{ "update-date", "todo" },
}) {
auto stat = m_stats->getChildByID(id.first);
stat->removeChildByID("loading-spinner");
this->setStatValue(stat, id.second);
}
}
else if (auto err = event->getReject()) {
for (auto child : CCArrayExt<CCNode*>(m_stats->getChildren())) {
if (auto spinner = child->getChildByID("loading-spinner")) {
spinner->removeFromParent();
this->setStatValue(child, "N/A");
}
}
}
}
void ModPopup::loadTab(ModPopup::Tab tab) {
// Remove current page
if (m_currentTabPage) {

View file

@ -16,12 +16,17 @@ protected:
};
ModSource m_source;
CCNode* m_stats;
CCNode* m_rightColumn;
CCNode* m_currentTabPage = nullptr;
std::unordered_map<Tab, std::pair<GeodeTabSprite*, Ref<CCNode>>> m_tabs;
EventListener<PromiseEventFilter<server::ServerModMetadata, server::ServerError>> m_statsListener;
bool setup(ModSource&& src) override;
void setStatValue(CCNode* stat, std::string const& value);
void onLoadServerInfo(PromiseEvent<server::ServerModMetadata, server::ServerError>* event);
void loadTab(Tab tab);
void onTab(CCObject* sender);

View file

@ -44,3 +44,17 @@ Mod* ModSource::asMod() const {
server::ServerModMetadata const* ModSource::asServer() const {
return std::get_if<server::ServerModMetadata>(&m_value);
}
server::ServerPromise<server::ServerModMetadata> ModSource::fetchServerInfo() const {
return std::visit(makeVisitor {
[](Mod* mod) {
// todo: cache
return server::getMod(mod->getID());
},
[](server::ServerModMetadata const& metadata) {
return server::ServerPromise<server::ServerModMetadata>([&metadata](auto resolve, auto) {
resolve(metadata);
});
}
}, m_value);
}

View file

@ -24,4 +24,15 @@ public:
Mod* asMod() const;
server::ServerModMetadata const* asServer() const;
/**
* Fetches the server info for this mod source. If the mod source already
* is the `ServerModMetadata` variant, then it just immediately resolves to
* that. Otherwise, it uses the mod ID to fetch the server info.
*
* In other words, this does NOT mean that the ModSource is converted to
* `ServerModMetadata` or that it only works for that, or that it's required
* for that
*/
server::ServerPromise<server::ServerModMetadata> fetchServerInfo() const;
};