Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions include/moonbase/http_curl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class curl_http_transport : public http_transport {
{
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl(curl_easy_init(), curl_easy_cleanup);
if (!curl) {
throw api_error(0, "Could not initialize curl");
throw api_error(0, "HTTP request to " + request.url + " failed: could not initialize curl");
}

http_response response;
Expand Down Expand Up @@ -64,7 +64,7 @@ class curl_http_transport : public http_transport {

const auto result = curl_easy_perform(curl.get());
if (result != CURLE_OK) {
throw api_error(0, curl_easy_strerror(result));
throw api_error(0, "HTTP request to " + request.url + " failed: " + curl_easy_strerror(result));
}
curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status_code);
return response;
Expand Down
48 changes: 42 additions & 6 deletions include/moonbase/store.hpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#pragma once

#include <cerrno>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <system_error>

#include <nlohmann/json.hpp>

Expand Down Expand Up @@ -105,40 +107,63 @@ class file_license_store : public license_store {
return std::nullopt;
}

errno = 0;
std::ifstream file(path_);
if (!file) {
throw storage_error("Could not open local license file for reading");
const int err = errno;
throw storage_error("Could not open local license file for reading: " + path_.string()
+ errno_suffix(err));
}

try {
nlohmann::json json;
file >> json;
return json.get<license>();
} catch (const std::exception& ex) {
throw storage_error(std::string("Could not parse local license file: ") + ex.what());
} catch (const std::exception&) {
// A corrupt / unparseable license file is useless and would otherwise
// throw on every load, blocking re-activation. Remove it (best-effort,
// after closing the handle so Windows can unlink it) and report no
// stored license, so a fresh activation can be written in its place.
file.close();
std::error_code ec;
std::filesystem::remove(path_, ec);
return std::nullopt;
}
}

void store_local_license(const license& value) override
{
const auto parent = path_.parent_path();
if (!parent.empty()) {
std::filesystem::create_directories(parent);
std::error_code ec;
std::filesystem::create_directories(parent, ec);
if (ec) {
throw storage_error("Could not create license directory " + parent.string()
+ ": " + ec.message());
}
}

errno = 0;
std::ofstream file(path_, std::ios::trunc);
if (!file) {
throw storage_error("Could not open local license file for writing");
const int err = errno;
throw storage_error("Could not open local license file for writing: " + path_.string()
+ errno_suffix(err));
}
file << nlohmann::json(value).dump(2);
file.flush();
if (!file) {
throw storage_error("Could not write local license file: " + path_.string());
}
}

void delete_local_license() override
{
std::error_code error;
std::filesystem::remove(path_, error);
if (error) {
throw storage_error("Could not delete local license file: " + error.message());
throw storage_error("Could not delete local license file " + path_.string() + ": "
+ error.message());
}
}

Expand All @@ -157,6 +182,17 @@ class file_license_store : public license_store {
}

private:
// Best-effort OS reason for a failed std::fstream open. Stream failures don't
// portably set errno, so callers reset errno to 0 first and we only append a
// reason when something was actually recorded.
static std::string errno_suffix(int err)
{
if (err == 0) {
return {};
}
return " (" + std::generic_category().message(err) + ")";
}

class file_store_lock : public store_lock_guard {
public:
explicit file_store_lock(const std::filesystem::path& path) : lock_(path) {}
Expand Down
30 changes: 23 additions & 7 deletions modules/moonbase_licensing/juce/ActivationController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ namespace moonbase::juce_integration {

namespace {
constexpr int kPollIntervalMs = 1500;

// Diagnostic-only error text. For transport failures (moonbase::api_error) the
// SDK stashes actionable guidance (e.g. the macOS network entitlement hint) in
// the detail field; append it so it reaches onDiagnostic without ever appearing
// in the friendly, user-facing screen text.
juce::String describeError(const std::exception& ex)
{
juce::String message = ex.what();
if (const auto* api = dynamic_cast<const moonbase::api_error*>(&ex))
if (! api->detail().empty())
message << " (" << api->detail() << ")";
return message;
}
} // namespace

ActivationController::ActivationController(ActivationConfig config)
Expand Down Expand Up @@ -177,15 +190,15 @@ void ActivationController::start()
catch (const std::exception& ex)
{
// Invalid / unreachable-past-grace -> locked.
diag = juce::String("Re-validating stored license failed: ") + ex.what();
diag = juce::String("Re-validating stored license failed: ") + describeError(ex);
result = std::nullopt;
}
}
}
}
catch (const std::exception& ex)
{
diag = juce::String("Loading stored license failed: ") + ex.what();
diag = juce::String("Loading stored license failed: ") + describeError(ex);
result = std::nullopt;
}

Expand Down Expand Up @@ -228,7 +241,7 @@ void ActivationController::beginOnlineActivation()
}
catch (const std::exception& ex)
{
error = ex.what();
error = describeError(ex);
}

juce::MessageManager::callAsync([safe, generation, request, error]() mutable
Expand All @@ -239,9 +252,12 @@ void ActivationController::beginOnlineActivation()

if (! request)
{
// Full reason (incl. the entitlement hint) goes to the developer
// sink; the user sees a friendly, fixed prompt to retry.
self->emitDiagnostic("request_activation failed: " + error);
self->setScreen(Screen::Error,
"Couldn't reach Moonbase to start activation. " + error);
"Couldn't reach Moonbase to start activation. "
"Check your internet connection and try again.");
return;
}

Expand Down Expand Up @@ -315,7 +331,7 @@ void ActivationController::refreshLicense(bool force, std::function<void(bool)>
}
catch (const std::exception& ex)
{
diag = ex.what();
diag = describeError(ex);
}

juce::MessageManager::callAsync([safe, generation, refreshed, expired, currentLicense, wasTrial,
Expand Down Expand Up @@ -395,7 +411,7 @@ void ActivationController::timerCallback()
catch (const std::exception& ex)
{
// Transient transport/5xx error — keep polling.
transient = ex.what();
transient = describeError(ex);
}

juce::MessageManager::callAsync([safe, generation, fulfilled, fatal, error, transient]() mutable
Expand Down Expand Up @@ -563,7 +579,7 @@ void ActivationController::deactivate()
catch (const moonbase::operation_not_supported_error& ex) { outcome = Outcome::NotRevokable; diag = ex.what(); }
catch (const moonbase::license_invalid_error&) { outcome = Outcome::Revoked; }
catch (const moonbase::license_expired_error&) { outcome = Outcome::Revoked; }
catch (const std::exception& ex) { outcome = Outcome::Unreachable; diag = ex.what(); }
catch (const std::exception& ex) { outcome = Outcome::Unreachable; diag = describeError(ex); }

const int outcomeCode = static_cast<int>(outcome);
juce::MessageManager::callAsync([safe, generation, outcomeCode, activationId, diag]() mutable
Expand Down
15 changes: 13 additions & 2 deletions modules/moonbase_licensing/juce/juce_http_transport.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,19 @@ class juce_http_transport : public moonbase::http_transport
// Match curl_http_transport: a transport-level failure surfaces as
// api_error with status 0, which the SDK's grace-period logic treats
// as "unreachable" (this also covers a cancelled connect()).
throw moonbase::api_error(
0, "HTTP request to " + request.url + " failed (could not connect)");
//
// The friendly message stays user-safe; the macOS sandbox hint goes in
// the detail field so it reaches developers (via onDiagnostic) without
// surfacing in the plugin UI. A status-0 connect failure inside a
// sandboxed Apple build is most often the missing network entitlement.
juce::String message = "Couldn't connect to " + request.url
+ " (check the internet connection or firewall)";
juce::String detail;
#if JUCE_MAC || JUCE_IOS
detail = "If this build runs sandboxed (AUv3 / Mac App Store), enable the "
"com.apple.security.network.client entitlement.";
#endif
throw moonbase::api_error(0, message.toStdString(), {}, detail.toStdString());
}

moonbase::http_response response;
Expand Down
4 changes: 2 additions & 2 deletions modules/moonbase_licensing/moonbase/http_curl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class curl_http_transport : public http_transport {
{
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl(curl_easy_init(), curl_easy_cleanup);
if (!curl) {
throw api_error(0, "Could not initialize curl");
throw api_error(0, "HTTP request to " + request.url + " failed: could not initialize curl");
}

http_response response;
Expand Down Expand Up @@ -64,7 +64,7 @@ class curl_http_transport : public http_transport {

const auto result = curl_easy_perform(curl.get());
if (result != CURLE_OK) {
throw api_error(0, curl_easy_strerror(result));
throw api_error(0, "HTTP request to " + request.url + " failed: " + curl_easy_strerror(result));
}
curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status_code);
return response;
Expand Down
48 changes: 42 additions & 6 deletions modules/moonbase_licensing/moonbase/store.hpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#pragma once

#include <cerrno>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <system_error>

#include <nlohmann/json.hpp>

Expand Down Expand Up @@ -105,40 +107,63 @@ class file_license_store : public license_store {
return std::nullopt;
}

errno = 0;
std::ifstream file(path_);
if (!file) {
throw storage_error("Could not open local license file for reading");
const int err = errno;
throw storage_error("Could not open local license file for reading: " + path_.string()
+ errno_suffix(err));
}

try {
nlohmann::json json;
file >> json;
return json.get<license>();
} catch (const std::exception& ex) {
throw storage_error(std::string("Could not parse local license file: ") + ex.what());
} catch (const std::exception&) {
// A corrupt / unparseable license file is useless and would otherwise
// throw on every load, blocking re-activation. Remove it (best-effort,
// after closing the handle so Windows can unlink it) and report no
// stored license, so a fresh activation can be written in its place.
file.close();
std::error_code ec;
std::filesystem::remove(path_, ec);
return std::nullopt;
}
}

void store_local_license(const license& value) override
{
const auto parent = path_.parent_path();
if (!parent.empty()) {
std::filesystem::create_directories(parent);
std::error_code ec;
std::filesystem::create_directories(parent, ec);
if (ec) {
throw storage_error("Could not create license directory " + parent.string()
+ ": " + ec.message());
}
}

errno = 0;
std::ofstream file(path_, std::ios::trunc);
if (!file) {
throw storage_error("Could not open local license file for writing");
const int err = errno;
throw storage_error("Could not open local license file for writing: " + path_.string()
+ errno_suffix(err));
}
file << nlohmann::json(value).dump(2);
file.flush();
if (!file) {
throw storage_error("Could not write local license file: " + path_.string());
}
}

void delete_local_license() override
{
std::error_code error;
std::filesystem::remove(path_, error);
if (error) {
throw storage_error("Could not delete local license file: " + error.message());
throw storage_error("Could not delete local license file " + path_.string() + ": "
+ error.message());
}
}

Expand All @@ -157,6 +182,17 @@ class file_license_store : public license_store {
}

private:
// Best-effort OS reason for a failed std::fstream open. Stream failures don't
// portably set errno, so callers reset errno to 0 first and we only append a
// reason when something was actually recorded.
static std::string errno_suffix(int err)
{
if (err == 0) {
return {};
}
return " (" + std::generic_category().message(err) + ")";
}

class file_store_lock : public store_lock_guard {
public:
explicit file_store_lock(const std::filesystem::path& path) : lock_(path) {}
Expand Down
36 changes: 36 additions & 0 deletions tests/juce/controller_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,42 @@ TEST_CASE("diagnostics carry the underlying detail behind a friendly error")
responseFile.deleteFile();
}

namespace {
// Models a connect failure that carries developer guidance in the api_error
// detail field - exactly what juce_http_transport sets for the macOS sandbox
// network entitlement. Lets us assert the hint is routed to diagnostics only.
struct hinted_failure_transport : moonbase::http_transport
{
moonbase::http_response send(const moonbase::http_request&) override
{
throw moonbase::api_error(
0, "Couldn't connect to https://demo.moonbase.sh", {},
"enable the com.apple.security.network.client entitlement");
}
};
} // namespace

TEST_CASE("a transport failure routes the entitlement hint to diagnostics, not the user screen")
{
controller_fixture fx;
juce::StringArray diags;
fx.config.onDiagnostic = [&](const juce::String& m) { diags.add(m); };

auto licensing = std::make_shared<moonbase::licensing>(
fx.config.toLicensingOptions(), fx.store, fx.fingerprint,
std::make_shared<hinted_failure_transport>());

ActivationController controller(fx.config, licensing);
controller.beginOnlineActivation();

REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Error; }));
// The user sees a friendly, fixed prompt - never the entitlement detail.
CHECK(controller.statusMessage().containsIgnoreCase("Couldn't reach Moonbase"));
CHECK_FALSE(controller.statusMessage().containsIgnoreCase("entitlement"));
// The developer sink gets the full reason, including the detail hint.
CHECK(diags.joinIntoString(" ").containsIgnoreCase("entitlement"));
}

//==============================================================================
// Online re-validation (refresh entitlements after a purchase)
//==============================================================================
Expand Down
Loading
Loading