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
9 changes: 7 additions & 2 deletions docs/juce-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ The screens:
`onDiagnostic`. If the request can't be started, the welcome screen's message
says whether Moonbase was unreachable, busy (rate limiting or a server error), or
refused it for this product, or that the machine has no identity to activate.
A failure that is none of those (a bug in a custom device id resolver, say) is
shown in its own words rather than passed off as a connection problem.
- **Success** — animated confirmation with a mini license card.
- **Offline** — two-step machine-file flow: save the request (`generate_device_token`),
then load the response file (`read_offline_license`, validated locally).
Expand Down Expand Up @@ -386,8 +388,11 @@ builds the module and runs the behavioral suite on macOS.

## Diagnostics

The UI shows friendly, end-user-facing copy. To see the underlying reason behind a failure
(bad config, rejected token, unreachable server, persist failure), wire a diagnostic sink:
The UI shows friendly, end-user-facing copy for every failure it can name. One it can't (an
exception that is neither Moonbase's answer nor a failed connection) is shown verbatim, so a
customer's screenshot carries the actual error. To see the underlying reason behind any
failure (bad config, rejected token, unreachable server, persist failure), wire a diagnostic
sink:

```cpp
config.onDiagnostic = [] (const juce::String& message) {
Expand Down
29 changes: 26 additions & 3 deletions include/moonbase/client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,26 @@ class activation_poll_gate {
clock::time_point hold_until_{};
};

// `text` with each byte that is not part of valid UTF-8 replaced by U+FFFD.
// nlohmann::json's own validator decides, so the result is exactly what a strict
// dump() accepts rather than a second opinion on it.
[[nodiscard]] inline std::string replace_invalid_utf8(const std::string& text)
{
return nlohmann::json::parse(
nlohmann::json(text).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace))
.get<std::string>();
}

// A device id is never repaired the way a device name is. The server would bind
// the repaired id, which the resolver never returns, so the license would take a
// seat and then fail to validate on this very device. Refuse it unsent instead.
inline void require_utf8_device_id(const std::string& device_id)
{
if (replace_invalid_utf8(device_id) != device_id) {
throw configuration_error("The device id resolver returned a device id that is not valid UTF-8");
}
}

} // namespace detail

class license_client {
Expand Down Expand Up @@ -443,16 +463,19 @@ class license_client {

// The API refuses a blank device name or id outright. The name is only a
// label, so stand in for one the host could not supply (a failed host
// name lookup, a custom resolver). The id is the binding itself, and a
// resolver that returns none is misconfigured.
auto device_name = device_ids_->device_name();
// name lookup, a custom resolver), and let a byte in it that is not UTF-8
// cost a replacement character rather than the activation. The id is the
// binding itself, and a resolver that returns none, or one that is not
// UTF-8, is misconfigured.
auto device_name = detail::replace_invalid_utf8(device_ids_->device_name());
if (detail::trim_ascii_whitespace(device_name).empty()) {
device_name = "Unknown device";
}
const auto device_id = device_ids_->device_id();
if (detail::trim_ascii_whitespace(device_id).empty()) {
throw configuration_error("The device id resolver returned an empty device id");
}
detail::require_utf8_device_id(device_id);

const auto payload = nlohmann::json{
{"deviceName", device_name},
Expand Down
59 changes: 59 additions & 0 deletions include/moonbase/detail/unicode/utf16.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#pragma once

// UTF-16 to UTF-8, for the strings Windows only hands out faithfully through its
// wide APIs. The narrow ("A") variants answer in the ANSI code page instead, so a
// computer name such as "Björn-PC" comes back as bytes that are not UTF-8, and
// nlohmann::json refuses to serialize those.
//
// Plain C++ with no OS headers, so it is tested on every platform rather than
// only on the one it runs on.

#include <cstddef>
#include <string>
#include <string_view>

namespace moonbase::detail::unicode {

/// Transcode UTF-16 to UTF-8.
///
/// Never fails: an unpaired surrogate becomes U+FFFD, as WideCharToMultiByte
/// does, so the result is always valid UTF-8 and always safe to serialize.
[[nodiscard]] inline std::string utf16_to_utf8(std::u16string_view text)
{
std::string out;
out.reserve(text.size());

for (std::size_t index = 0; index != text.size(); ++index) {
char32_t code_point = text[index];

const bool high_surrogate = code_point >= 0xD800 && code_point <= 0xDBFF;
const bool low_surrogate = code_point >= 0xDC00 && code_point <= 0xDFFF;
if (high_surrogate && index + 1 != text.size() && text[index + 1] >= 0xDC00
&& text[index + 1] <= 0xDFFF) {
code_point = 0x10000 + ((code_point - 0xD800) << 10U) + (text[index + 1] - 0xDC00U);
++index;
} else if (high_surrogate || low_surrogate) {
code_point = 0xFFFD;
}

if (code_point < 0x80) {
out.push_back(static_cast<char>(code_point));
} else if (code_point < 0x800) {
out.push_back(static_cast<char>(0xC0U | (code_point >> 6U)));
out.push_back(static_cast<char>(0x80U | (code_point & 0x3FU)));
} else if (code_point < 0x10000) {
out.push_back(static_cast<char>(0xE0U | (code_point >> 12U)));
out.push_back(static_cast<char>(0x80U | ((code_point >> 6U) & 0x3FU)));
out.push_back(static_cast<char>(0x80U | (code_point & 0x3FU)));
} else {
out.push_back(static_cast<char>(0xF0U | (code_point >> 18U)));
out.push_back(static_cast<char>(0x80U | ((code_point >> 12U) & 0x3FU)));
out.push_back(static_cast<char>(0x80U | ((code_point >> 6U) & 0x3FU)));
out.push_back(static_cast<char>(0x80U | (code_point & 0x3FU)));
}
}

return out;
}

} // namespace moonbase::detail::unicode
8 changes: 8 additions & 0 deletions include/moonbase/http.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ struct http_response {
class http_transport {
public:
virtual ~http_transport() = default;

/// Perform one exchange and return the response, whatever its status.
///
/// When no response arrives at all (DNS, a refused connection, TLS, a timeout,
/// cancellation), throw api_error with status code 0, as both bundled
/// transports do. That is what tells "Moonbase could not be reached" apart from
/// a failure on this machine: the JUCE activation screen asks the user to check
/// their connection for the first, and shows any other exception as it is.
[[nodiscard]] virtual http_response send(const http_request& request) = 0;
};

Expand Down
45 changes: 35 additions & 10 deletions include/moonbase/legacy_fingerprint.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
#endif

#include "moonbase/detail/crypto/crypto.hpp"
#include "moonbase/detail/unicode/utf16.hpp"
#include "moonbase/device_id_resolver.hpp"

namespace moonbase {
Expand Down Expand Up @@ -146,8 +147,42 @@ class legacy_cpp_device_id_resolver : public device_id_resolver {
return parameters;
}

// Only a label, so unlike the rest of this class it is free to be correct: read
// through the wide API as UTF-8 on Windows, where the ANSI reading is not UTF-8
// and cannot be serialized. device_id() still hashes that ANSI reading.
[[nodiscard]] std::string device_name() const override
{
#if defined(_WIN32)
wchar_t buffer[128]{};
auto size = static_cast<DWORD>(sizeof(buffer) / sizeof(buffer[0]));
if (!GetComputerNameExW(ComputerNamePhysicalDnsHostname, buffer, &size)) {
return {};
}
std::u16string name;
name.reserve(size);
for (DWORD index = 0; index != size; ++index) {
name.push_back(static_cast<char16_t>(buffer[index]));
}
return detail::unicode::utf16_to_utf8(name);
#else
return hashed_host_name();
#endif
}

[[nodiscard]] std::string device_id() const override
{
auto parameters = identity_parameters();
if (parameters.empty()) {
append_parameter(parameters, "deviceName", hashed_host_name());
}
return hash_identity_parameters(parameters);
}

private:
// The host name as device_id() hashes it. On Windows that is the ANSI code
// page reading, byte for byte.
[[nodiscard]] static std::string hashed_host_name()
{
#if defined(_WIN32)
char buffer[128]{};
DWORD size = static_cast<DWORD>(sizeof(buffer)) - 1;
Expand Down Expand Up @@ -177,16 +212,6 @@ class legacy_cpp_device_id_resolver : public device_id_resolver {
#endif
}

[[nodiscard]] std::string device_id() const override
{
auto parameters = identity_parameters();
if (parameters.empty()) {
append_parameter(parameters, "deviceName", device_name());
}
return hash_identity_parameters(parameters);
}

private:
[[nodiscard]] static std::string read_file(const std::string& path)
{
std::ifstream file(path);
Expand Down
8 changes: 6 additions & 2 deletions include/moonbase/licensing.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,13 @@ class licensing {
// offline-activated license token in return.
[[nodiscard]] std::string generate_device_token() const
{
// As request_activation does: a name that is not UTF-8 is repaired, since
// it is only a label, and an id that is not UTF-8 is refused.
const auto device_id = device_ids_->device_id();
detail::require_utf8_device_id(device_id);
const nlohmann::json payload{
{"id", device_ids_->device_id()},
{"name", device_ids_->device_name()},
{"id", device_id},
{"name", detail::replace_invalid_utf8(device_ids_->device_name())},
{"productId", options_.product_id},
// The Moonbase API expects this to always be "JWT".
{"format", "JWT"},
Expand Down
56 changes: 49 additions & 7 deletions include/moonbase/moonbase_device_id_resolver.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
#endif
#endif

#include "moonbase/detail/unicode/utf16.hpp"
#include "moonbase/device_id_resolver.hpp"
#include "moonbase/errors.hpp"
#include "moonbase/fingerprint_spec.hpp"
Expand Down Expand Up @@ -204,7 +205,7 @@ class moonbase_device_id_resolver : public device_id_resolver {
}
}

/// The host name, with a trailing ".local" removed on macOS.
/// The host name as UTF-8, with a trailing ".local" removed on macOS.
///
/// Never throws: a machine with no readable identity still has to be able to
/// label itself, since activation sends the name alongside the id.
Expand Down Expand Up @@ -257,15 +258,24 @@ class moonbase_device_id_resolver : public device_id_resolver {
return identity;
}

/// The host name as UTF-8, with a trailing ".local" removed on macOS.
[[nodiscard]] static std::string read_host_name()
{
#if defined(_WIN32)
std::array<char, 256> buffer{};
auto size = static_cast<DWORD>(buffer.size()) - 1;
if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) {
return std::string(buffer.data(), size);
// The wide API, transcoded. GetComputerNameExA answers in the ANSI code
// page, so a name with any non-ASCII letter came back as bytes that are
// not UTF-8, and serializing the activation request threw on them.
std::array<wchar_t, 256> buffer{};
auto size = static_cast<DWORD>(buffer.size());
if (!GetComputerNameExW(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) {
return {};
}
return {};
std::u16string name;
name.reserve(size);
for (DWORD index = 0; index != size; ++index) {
name.push_back(static_cast<char16_t>(buffer[index]));
}
return detail::unicode::utf16_to_utf8(name);
#else
std::array<char, 256> buffer{};
if (gethostname(buffer.data(), buffer.size() - 1) != 0) {
Expand Down Expand Up @@ -340,7 +350,7 @@ class moonbase_device_id_resolver : public device_id_resolver {
throw;
}
description_ = describe(
{{"deviceName", read.device_name}}, fingerprint_spec::device_id_source::device_name);
{{"deviceName", fallback_host_name(read)}}, fingerprint_spec::device_id_source::device_name);
}
described_ = true;
}
Expand All @@ -364,6 +374,38 @@ class moonbase_device_id_resolver : public device_id_resolver {
return described;
}

// What the host-name fallback hashes: the name itself, except under the native
// Windows read. The fallback has always hashed the ANSI code page reading
// there, and still does, so a machine already bound to it keeps its id now
// that the name is read as UTF-8. Canonicalization drops non-ASCII either way,
// so the two only differ where the ANSI reading turned a letter into ASCII: a
// best-fit or "?" substitution, or a double-byte code page's trail byte. In
// those cases the reference SDK, which hashes the UTF-8 name, disagrees; moving
// to it would rebind those machines, so it needs a migration, not a bug fix.
[[nodiscard]] std::string fallback_host_name(const device_identity& read) const
{
#if defined(_WIN32)
// An empty name is a failed read, and stays one.
if (!options_.reader && !read.device_name.empty()) {
return read_ansi_host_name();
}
#endif
return read.device_name;
}

#if defined(_WIN32)
// Fallback material only, never a label: see fallback_host_name.
[[nodiscard]] static std::string read_ansi_host_name()
{
std::array<char, 256> buffer{};
auto size = static_cast<DWORD>(buffer.size()) - 1;
if (GetComputerNameExA(ComputerNamePhysicalDnsHostname, buffer.data(), &size)) {
return std::string(buffer.data(), size);
}
return {};
}
#endif

[[nodiscard]] static std::string read_file(const char* path)
{
std::ifstream file(path, std::ios::binary);
Expand Down
Loading
Loading