initial commit

This commit is contained in:
NeilClack
2026-08-07 14:51:41 -05:00
commit 9251b3a196
604 changed files with 464448 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
#include "network/api_client.h"
#include "network/json.h"
#include <curl/curl.h>
#include <string>
#ifndef API_KEY
#define API_KEY ""
#endif
namespace radiantwave::api {
namespace {
constexpr long HTTP_OK = 200;
// Builds the full URL for a path relative to the base API URL.
std::string urlFor(const std::string& path) {
std::string url = API_BASE_URL;
if (!path.empty() && path.front() != '/') {
url += '/';
}
url += path;
return url;
}
size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
auto* out = static_cast<std::string*>(userdata);
out->append(ptr, size * nmemb);
return size * nmemb;
}
// Shared curl setup + perform. Returns a populated HttpResponse.
HttpResponse performRequest(const std::string& url, const std::string& apiKey,
bool post, const std::string& jsonBody) {
HttpResponse response;
CURL* curl = curl_easy_init();
if (!curl) return response;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "RadiantWave-Kiosk/0.1");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
struct curl_slist* headers = nullptr;
// An explicit apiKey wins; otherwise fall back to the key baked in at
// build time via -DAPI_KEY (empty string when unset).
const std::string effectiveKey = apiKey.empty() ? API_KEY : apiKey;
if (!effectiveKey.empty()) {
headers = curl_slist_append(headers, ("Authorization: Bearer " + effectiveKey).c_str());
}
if (post) {
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, jsonBody.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(jsonBody.size()));
}
if (headers) {
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
}
CURLcode res = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
response.status = status;
response.transportOk = (res == CURLE_OK);
return response;
}
} // anonymous namespace
// ─── HTTP helpers ──────────────────────────────────────────────────────
HttpResponse httpGet(const std::string& path, const std::string& apiKey) {
return performRequest(urlFor(path), apiKey, false, "");
}
HttpResponse httpPost(const std::string& path, const std::string& jsonBody,
const std::string& apiKey) {
return performRequest(urlFor(path), apiKey, true, jsonBody);
}
// ─── GET /frequencies ──────────────────────────────────────────────────
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Array) {
return false;
}
out.clear();
for (const auto& entry : root.array) {
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
Frequency freq;
freq.id = static_cast<int>(entry.numberField("id"));
if (freq.id == 0) continue;
freq.name = entry.stringField("name");
freq.value = static_cast<int>(entry.numberField("value"));
out.push_back(std::move(freq));
}
return true;
}
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey) {
HttpResponse response = httpGet(PATH_FREQUENCIES, apiKey);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
return parseFrequenciesJson(response.body, out);
}
// ─── GET /color-palettes ───────────────────────────────────────────────
bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Array) {
return false;
}
auto parseHex = [](const std::string& hex) -> PaletteColor {
PaletteColor c;
auto nibble = [&hex](size_t i) -> int {
char ch = hex.size() > i ? hex[i] : '0';
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return 0;
};
// Accept "#RRGGBB" or "RRGGBB".
size_t base = (!hex.empty() && hex[0] == '#') ? 1 : 0;
c.r = static_cast<uint8_t>(nibble(base) * 16 + nibble(base + 1));
c.g = static_cast<uint8_t>(nibble(base + 2) * 16 + nibble(base + 3));
c.b = static_cast<uint8_t>(nibble(base + 4) * 16 + nibble(base + 5));
return c;
};
out.clear();
for (const auto& entry : root.array) {
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
int freqId = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
if (freqId == 0) continue;
ColorPalette palette;
palette.solfeggio_frequency_id = freqId;
const radiantwave::json::JsonValue* colors = entry.find("colors");
if (colors && colors->type == radiantwave::json::JsonValue::Type::Array) {
for (const auto& color : colors->array) {
if (color.type == radiantwave::json::JsonValue::Type::String) {
palette.colors.push_back(parseHex(color.str));
}
}
}
out.push_back(std::move(palette));
}
return true;
}
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey) {
HttpResponse response = httpGet(PATH_COLOR_PALETTES, apiKey);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
return parseColorPalettesJson(response.body, out);
}
// ─── POST /license ─────────────────────────────────────────────────────
bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Object) {
return false;
}
out.key = root.stringField("key");
out.email_address = root.stringField("email_address");
// subscription_level is an object {id,name,description} in the updated API
// and a bare level-name string in the current one. Tolerate both, plus a
// flat {subscription_level, subscription_level_id, subscription_level_description}.
const radiantwave::json::JsonValue* level = root.find("subscription_level");
if (level && level->type == radiantwave::json::JsonValue::Type::Object) {
out.subscription_level.id = static_cast<int>(level->numberField("id"));
out.subscription_level.name = level->stringField("name");
out.subscription_level.description = level->stringField("description");
} else if (level && level->type == radiantwave::json::JsonValue::Type::String) {
out.subscription_level.name = level->str;
}
if (out.subscription_level.name.empty()) {
out.subscription_level.name = root.stringField("subscription_level");
}
if (out.subscription_level.id == 0) {
out.subscription_level.id = static_cast<int>(root.numberField("subscription_level_id"));
}
if (out.subscription_level.description.empty()) {
out.subscription_level.description = root.stringField("subscription_level_description");
}
return !out.key.empty();
}
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& apiKey) {
LicenseResult result;
const std::string jsonBody =
"{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}";
HttpResponse response = httpPost(PATH_LICENSE, jsonBody, apiKey);
result.status = response.status;
if (!response.transportOk) {
result.error_message = "network error";
return result;
}
if (result.ok()) {
parseLicenseJson(response.body, result.info);
} else {
result.error_message = radiantwave::json::extractErrorMessage(response.body);
}
return result;
}
// ─── POST /automations/create/account ──────────────────────────────────
bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
radiantwave::json::JsonValue root;
if (!radiantwave::json::parseDocument(body, root) ||
root.type != radiantwave::json::JsonValue::Type::Object) {
return false;
}
out.success = root.boolField("success");
out.message = root.stringField("message");
out.user_id = root.stringField("user_id");
return true;
}
AccountResult createAccount(const std::string& email, const std::string& subscription_level,
const std::string& name, const std::string& apiKey) {
AccountResult result;
std::string jsonBody =
"{\"email\":\"" + email + "\",\"subscription_level\":\"" + subscription_level + "\"";
if (!name.empty()) {
jsonBody += ",\"name\":\"" + name + "\"";
}
jsonBody += "}";
HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody, apiKey);
result.status = response.status;
if (!response.transportOk) {
result.error_message = "network error";
return result;
}
if (result.ok()) {
parseCreateAccountJson(response.body, result.body);
} else {
result.error_message = radiantwave::json::extractErrorMessage(response.body);
}
return result;
}
} // namespace radiantwave::api
+137
View File
@@ -0,0 +1,137 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace radiantwave::api {
// Base URL of the RadiantWave content API.
constexpr const char* API_BASE_URL = "https://portal.radiantwavetech.com/api/v2";
// Paths relative to API_BASE_URL.
constexpr const char* PATH_PROCLAMATIONS = "/proclamations";
constexpr const char* PATH_CATEGORIES = "/categories";
constexpr const char* PATH_FREQUENCIES = "/frequencies";
constexpr const char* PATH_LICENSE = "/license";
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
constexpr const char* PATH_COLOR_PALETTES = "/color-palettes";
// Result of a single HTTP request.
struct HttpResponse {
long status = 0; // HTTP status code; 0 if the request never completed
std::string body; // raw response body (any status)
bool transportOk = false; // true when the request completed without a transport error
};
// Performs a GET/POST against `path` (relative to API_BASE_URL). When `apiKey` is
// non-empty it is sent as `Authorization: Bearer <apiKey>`. When it is empty (or
// omitted) the request falls back to the key baked in at build time via the
// `API_KEY` compile definition (see CMakeLists.txt); with neither set, no auth
// header is sent.
HttpResponse httpGet(const std::string& path, const std::string& apiKey = "");
HttpResponse httpPost(const std::string& path, const std::string& jsonBody,
const std::string& apiKey = "");
// ─── GET /frequencies ──────────────────────────────────────────────────
struct Frequency {
int id = 0;
std::string name;
int value = 0; // frequency in Hz
};
// Parse the JSON payload returned by the frequencies endpoint.
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out);
// Fetch the solfeggio frequencies. Returns true only on HTTP 2xx + successful parse.
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey = "");
// ─── GET /color-palettes ───────────────────────────────────────────────
// A single color swatch (RGBA). Mirrors db::PaletteColor without coupling the
// API layer to the database.
struct PaletteColor {
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 255;
};
// The ordered 7-color palette for one solfeggio frequency.
struct ColorPalette {
int solfeggio_frequency_id = 0;
std::vector<PaletteColor> colors;
};
// Parse the JSON payload returned by the color-palettes endpoint. Each entry
// is expected to look like {"solfeggioFrequencyId": 1, "colors": ["#RRGGBB", ...]}.
// Returns false on malformed input.
bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>& out);
// Fetch the color palettes for every solfeggio frequency. Returns true only on
// HTTP 2xx + successful parse.
//
// NOTE: the portal does not expose this route yet. This is readiness code — it
// is deliberately not called anywhere in the app until the route ships, so
// palettes can be updated from the portal rather than code releases.
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey = "");
// ─── POST /license ─────────────────────────────────────────────────────
// A subscription level as described by the portal's `SubscriptionLevel`
// table. `name` is always populated; `id`/`description` may be 0/empty when
// the API only reported the level name.
struct SubscriptionLevel {
int id = 0;
std::string name;
std::string description;
};
struct LicenseInfo {
std::string key;
std::string email_address;
SubscriptionLevel subscription_level;
};
// Parse the 200 JSON payload returned by the license endpoint.
bool parseLicenseJson(const std::string& body, LicenseInfo& out);
struct LicenseResult {
int status = 0;
LicenseInfo info;
std::string error_message; // extracted from the error body on failure
bool ok() const { return status >= 200 && status < 300; }
};
// Validate a license key / email pair against the portal. Never throws; failures
// (network, HTTP error status) are reported through the returned LicenseResult.
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& apiKey = "");
// ─── POST /automations/create/account ──────────────────────────────────
struct AccountBody {
bool success = false;
std::string message;
std::string user_id;
};
// Parse the 200 JSON payload returned by the create-account endpoint.
bool parseCreateAccountJson(const std::string& body, AccountBody& out);
struct AccountResult {
int status = 0;
AccountBody body;
std::string error_message;
bool ok() const { return status >= 200 && status < 300; }
};
// Creates (or reuses) a user account and issues a license key. The key is emailed
// to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& subscription_level,
const std::string& name = "", const std::string& apiKey = "");
} // namespace radiantwave::api
+246
View File
@@ -0,0 +1,246 @@
#include "network/json.h"
#include <cctype>
#include <cstdlib>
namespace radiantwave::json {
// ─── JsonValue ─────────────────────────────────────────────────────────
const JsonValue* JsonValue::find(const std::string& key) const {
if (type != Type::Object) return nullptr;
for (const auto& entry : object) {
if (entry.first == key) return &entry.second;
}
return nullptr;
}
std::string JsonValue::stringField(const std::string& key) const {
const JsonValue* v = find(key);
return (v && v->type == Type::String) ? v->str : std::string();
}
double JsonValue::numberField(const std::string& key) const {
const JsonValue* v = find(key);
return (v && v->type == Type::Number) ? v->num : 0.0;
}
bool JsonValue::boolField(const std::string& key) const {
const JsonValue* v = find(key);
return (v && v->type == Type::Bool) ? v->b : false;
}
// ─── JsonParser ────────────────────────────────────────────────────────
JsonParser::JsonParser(const std::string& text) : text_(text) {}
bool JsonParser::parse(JsonValue& out) {
skipWhitespace();
if (!parseValue(out)) return false;
skipWhitespace();
return pos_ == text_.size();
}
void JsonParser::skipWhitespace() {
while (pos_ < text_.size() && std::isspace(static_cast<unsigned char>(text_[pos_]))) {
++pos_;
}
}
bool JsonParser::parseValue(JsonValue& out) {
skipWhitespace();
if (pos_ >= text_.size()) return false;
char c = text_[pos_];
switch (c) {
case '{': return parseObject(out);
case '[': return parseArray(out);
case '"': {
std::string s;
if (!parseString(s)) return false;
out.type = JsonValue::Type::String;
out.str = std::move(s);
return true;
}
case 't':
if (text_.compare(pos_, 4, "true") == 0) {
pos_ += 4;
out.type = JsonValue::Type::Bool;
out.b = true;
return true;
}
return false;
case 'f':
if (text_.compare(pos_, 5, "false") == 0) {
pos_ += 5;
out.type = JsonValue::Type::Bool;
out.b = false;
return true;
}
return false;
case 'n':
if (text_.compare(pos_, 4, "null") == 0) {
pos_ += 4;
out.type = JsonValue::Type::Null;
return true;
}
return false;
default:
return parseNumber(out);
}
}
bool JsonParser::parseObject(JsonValue& out) {
out.type = JsonValue::Type::Object;
++pos_; // consume '{'
skipWhitespace();
if (pos_ < text_.size() && text_[pos_] == '}') {
++pos_;
return true;
}
while (pos_ < text_.size()) {
skipWhitespace();
std::string key;
if (!parseString(key)) return false;
skipWhitespace();
if (pos_ >= text_.size() || text_[pos_] != ':') return false;
++pos_;
JsonValue value;
if (!parseValue(value)) return false;
out.object.emplace_back(std::move(key), std::move(value));
skipWhitespace();
if (pos_ >= text_.size()) return false;
char c = text_[pos_];
if (c == ',') {
++pos_;
} else if (c == '}') {
++pos_;
return true;
} else {
return false;
}
}
return false;
}
bool JsonParser::parseArray(JsonValue& out) {
out.type = JsonValue::Type::Array;
++pos_; // consume '['
skipWhitespace();
if (pos_ < text_.size() && text_[pos_] == ']') {
++pos_;
return true;
}
while (pos_ < text_.size()) {
JsonValue value;
if (!parseValue(value)) return false;
out.array.push_back(std::move(value));
skipWhitespace();
if (pos_ >= text_.size()) return false;
char c = text_[pos_];
if (c == ',') {
++pos_;
} else if (c == ']') {
++pos_;
return true;
} else {
return false;
}
}
return false;
}
bool JsonParser::parseString(std::string& out) {
if (pos_ >= text_.size() || text_[pos_] != '"') return false;
++pos_;
while (pos_ < text_.size()) {
char c = text_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (pos_ >= text_.size()) return false;
char esc = text_[pos_++];
switch (esc) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
// Only handles the common BMP escapes; surrogate pairs
// are approximated rather than rejected.
int code = 0;
for (int i = 0; i < 4; ++i) {
if (pos_ >= text_.size()) return false;
int digit = hexDigit(text_[pos_++]);
if (digit < 0) return false;
code = code * 16 + digit;
}
if (code >= 0x20) {
out += static_cast<char>(code);
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false;
}
bool JsonParser::parseNumber(JsonValue& out) {
size_t start = pos_;
if (pos_ < text_.size() && text_[pos_] == '-') ++pos_;
while (pos_ < text_.size() && std::isdigit(static_cast<unsigned char>(text_[pos_]))) ++pos_;
if (pos_ < text_.size() && text_[pos_] == '.') {
++pos_;
while (pos_ < text_.size() && std::isdigit(static_cast<unsigned char>(text_[pos_]))) ++pos_;
}
if (pos_ < text_.size() && (text_[pos_] == 'e' || text_[pos_] == 'E')) {
++pos_;
if (pos_ < text_.size() && (text_[pos_] == '+' || text_[pos_] == '-')) ++pos_;
while (pos_ < text_.size() && std::isdigit(static_cast<unsigned char>(text_[pos_]))) ++pos_;
}
if (pos_ == start) return false;
out.type = JsonValue::Type::Number;
out.num = std::atof(text_.substr(start, pos_ - start).c_str());
return true;
}
int JsonParser::hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// ─── Helpers ───────────────────────────────────────────────────────────
bool parseDocument(const std::string& text, JsonValue& out) {
JsonParser parser(text);
return parser.parse(out);
}
std::string extractErrorMessage(const std::string& body) {
JsonValue root;
if (!parseDocument(body, root) || root.type != JsonValue::Type::Object) {
return std::string();
}
const std::string message = root.stringField("message");
if (!message.empty()) {
return message;
}
return root.stringField("error");
}
} // namespace radiantwave::json
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <string>
#include <vector>
#include <utility>
namespace radiantwave::json {
// Minimal JSON value model: objects, arrays, strings, numbers, bools, null.
struct JsonValue {
enum class Type { Null, Bool, Number, String, Array, Object };
Type type = Type::Null;
bool b = false;
double num = 0.0;
std::string str;
std::vector<JsonValue> array;
std::vector<std::pair<std::string, JsonValue>> object;
// Returns the value for `key` if this is an object and the key exists.
const JsonValue* find(const std::string& key) const;
std::string stringField(const std::string& key) const;
double numberField(const std::string& key) const;
bool boolField(const std::string& key) const;
};
// Streaming JSON parser. `parse()` returns true only when the whole input is a
// single valid JSON value (no trailing garbage).
class JsonParser {
public:
explicit JsonParser(const std::string& text);
bool parse(JsonValue& out);
private:
const std::string& text_;
size_t pos_ = 0;
void skipWhitespace();
bool parseValue(JsonValue& out);
bool parseObject(JsonValue& out);
bool parseArray(JsonValue& out);
bool parseString(std::string& out);
bool parseNumber(JsonValue& out);
static int hexDigit(char c);
};
// Parses a full JSON document into `out`. Returns false on malformed input.
bool parseDocument(const std::string& text, JsonValue& out);
// Best-effort extraction of an error message from an API error body. Looks for a
// "message" string field, then an "error" string field. Returns an empty string
// when the body is not JSON or has neither field.
std::string extractErrorMessage(const std::string& body);
} // namespace radiantwave::json
+108
View File
@@ -0,0 +1,108 @@
#include "network/license_service.h"
#include "db/db_keys.h"
#include "network/api_client.h"
#include <cctype>
#include <cstdlib>
#include <chrono>
namespace radiantwave::license {
namespace {
long long nowEpochSeconds() {
const auto now = std::chrono::system_clock::now();
return std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
}
} // namespace
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey,
std::string* outLevelName,
std::string* outErrorMessage) {
if (outLevelName) outLevelName->clear();
if (outErrorMessage) outErrorMessage->clear();
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
const std::string key = db.getKV(radiantwave::db::KV_LICENSE_KEY);
if (email.empty() || key.empty()) {
return Result::NotConfigured;
}
const radiantwave::api::LicenseResult result =
radiantwave::api::validateLicense(key, email, apiKey);
if (result.status == 0) {
if (outErrorMessage) *outErrorMessage = "network error";
return Result::NetworkError;
}
if (!result.ok()) {
if (outErrorMessage) *outErrorMessage = result.error_message;
return Result::Invalid;
}
const auto& level = result.info.subscription_level;
int levelId = level.id;
if (levelId <= 0) {
// The portal may only report the level name (e.g. "Elite"). Derive the
// numeric id from the name so the Home footer and proclamation gating
// can rank the level even when the response omits the id.
levelId = subscriptionLevelIdForName(level.name);
}
db.setKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_ID,
levelId > 0 ? std::to_string(levelId) : std::string());
db.setKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_NAME, level.name);
db.setKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_DESCRIPTION, level.description);
markLicenseVerified(db);
if (outLevelName) *outLevelName = level.name;
return Result::Ok;
}
void markLicenseVerified(radiantwave::db::DatabaseManager& db) {
db.setKV(radiantwave::db::KV_LICENSE_LAST_VERIFIED,
std::to_string(nowEpochSeconds()));
}
bool isLicenseWithinGracePeriod(const radiantwave::db::DatabaseManager& db) {
const std::string raw = db.getKV(radiantwave::db::KV_LICENSE_LAST_VERIFIED);
if (raw.empty()) return false;
char* end = nullptr;
const long long stamp = std::strtoll(raw.c_str(), &end, 10);
if (!end || *end != '\0' || stamp <= 0) return false;
return nowEpochSeconds() - stamp < GRACE_PERIOD_SECONDS;
}
int subscriptionLevelIdForName(const std::string& name) {
std::string lower;
lower.reserve(name.size());
for (char c : name) {
lower.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
}
if (lower == "basic") return 1;
if (lower == "advanced") return 2;
if (lower == "elite") return 3;
return 0;
}
int getStoredSubscriptionLevelId(const radiantwave::db::DatabaseManager& db) {
const std::string raw = db.getKV(radiantwave::db::KV_SUBSCRIPTION_LEVEL_ID);
if (raw.empty()) return 0;
char* end = nullptr;
const long id = std::strtol(raw.c_str(), &end, 10);
return (end && *end == '\0' && id > 0) ? static_cast<int>(id) : 0;
}
std::string subscriptionLevelName(int levelId) {
switch (levelId) {
case 1: return "Basic";
case 2: return "Advanced";
case 3: return "Elite";
default: return "Subscription Level " + std::to_string(levelId);
}
}
} // namespace radiantwave::license
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include "db/database_manager.h"
#include <string>
namespace radiantwave::license {
// NOTE: the system_kv keys where the validated subscription level is persisted
// live in db/db_keys.h (KV_SUBSCRIPTION_LEVEL_*).
enum class Result {
Ok, // license valid; subscription level persisted to the DB
NotConfigured, // email and/or license key missing from the DB
Invalid, // API rejected the pair (bad key, wrong user, inactive, ...)
NetworkError, // the request never completed
};
// How long a previously-validated license stays valid without a successful
// re-validation (7 days). Network failures within this window after the last
// successful check do not invalidate the license; the user has the whole
// window to reconnect so the "last verified" stamp can be refreshed.
constexpr long long GRACE_PERIOD_SECONDS = 7LL * 24 * 60 * 60;
// Validates the stored license key against the stored email address via the
// portal API. On success the subscription level (id/name/description) is
// written to the DB; on failure the stored level is left untouched so a
// previous good value survives a transient outage. Returns NotConfigured
// without touching the network when either value is missing. Never throws.
//
// `outLevelName` / `outErrorMessage` are optional out-params.
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "",
std::string* outLevelName = nullptr,
std::string* outErrorMessage = nullptr);
// Returns the subscription level id currently stored after a successful
// license validation. Returns 0 when no validated license is on record.
int getStoredSubscriptionLevelId(const radiantwave::db::DatabaseManager& db);
// Human-readable name for a subscription level id (Basic / Advanced / Elite).
// Falls back to a generic label for unknown ids.
std::string subscriptionLevelName(int levelId);
// Numeric id for a subscription level name (case-insensitive match against
// Basic / Advanced / Elite). Returns 0 for an unknown or empty name.
int subscriptionLevelIdForName(const std::string& name);
// Records the current time as the "last verified" stamp of the license. Called
// after every successful server validation so the offline grace window starts
// (or is extended) from the most recent confirmation.
void markLicenseVerified(radiantwave::db::DatabaseManager& db);
// True when a prior successful validation happened within
// GRACE_PERIOD_SECONDS of now. Returns false when no stamp is stored or the
// stamp is corrupted or older than the grace window.
bool isLicenseWithinGracePeriod(const radiantwave::db::DatabaseManager& db);
} // namespace radiantwave::license
+91
View File
@@ -0,0 +1,91 @@
#include "network/license_status.h"
#include "db/database_manager.h"
#include <utility>
namespace radiantwave::license {
Status statusForResult(Result result) {
switch (result) {
case Result::Ok: return Status::Valid;
case Result::NotConfigured:
case Result::Invalid:
case Result::NetworkError: return Status::Invalid;
}
return Status::Invalid;
}
Status statusForResultWithGrace(Result result, const db::DatabaseManager& db) {
if (result == Result::NetworkError && isLicenseWithinGracePeriod(db)) {
return Status::Valid;
}
return statusForResult(result);
}
LicenseStatusTracker::~LicenseStatusTracker() {
shutdown();
}
void LicenseStatusTracker::start(db::DatabaseManager& db) {
std::lock_guard<std::mutex> lock(mutex_);
if (worker_.joinable()) return;
db_ = &db;
worker_ = std::thread(&LicenseStatusTracker::run, this);
}
void LicenseStatusTracker::refresh() {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!worker_.joinable()) return;
if (status_.load() == static_cast<int>(Status::Checking)) return;
status_.store(static_cast<int>(Status::Checking));
}
cv_.notify_one();
}
void LicenseStatusTracker::shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
stop_.store(true);
}
cv_.notify_one();
if (worker_.joinable()) {
worker_.join();
}
}
Status LicenseStatusTracker::status() const {
return static_cast<Status>(status_.load());
}
bool LicenseStatusTracker::isValid() const {
return status() == Status::Valid;
}
void LicenseStatusTracker::run() {
while (true) {
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] {
return stop_.load() ||
status_.load() == static_cast<int>(Status::Checking);
});
if (stop_.load()) return;
}
db::DatabaseManager* db = nullptr;
{
std::lock_guard<std::mutex> lock(mutex_);
db = db_;
}
if (!db) {
status_.store(static_cast<int>(Status::Invalid));
continue;
}
const Result result = validateStoredLicense(*db);
status_.store(static_cast<int>(statusForResultWithGrace(result, *db)));
}
}
} // namespace radiantwave::license
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include "network/license_service.h"
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
namespace radiantwave::db {
class DatabaseManager;
}
namespace radiantwave::license {
// How confident the device is that its stored license is valid. Only Valid
// grants access to Play and the Proclamation options.
enum class Status {
Unknown, // no check has completed yet
Checking, // a background validation is in flight
Valid, // the portal confirmed the license/email pair
Invalid, // missing credentials, portal rejection, or unreachable server
};
// Maps a validateStoredLicense() result onto the tracker status. Pure, so it
// can be unit-tested offline. Network errors are treated as Invalid: a kiosk
// that cannot confirm its license must not grant access.
Status statusForResult(Result result);
// Like statusForResult(), but applies the 7-day offline grace period: a
// NetworkError keeps the license Valid while the last successful validation
// is still within GRACE_PERIOD_SECONDS of now (see isLicenseWithinGracePeriod).
// Explicit portal rejections and missing credentials are never rescued by the
// grace period.
Status statusForResultWithGrace(Result result, const db::DatabaseManager& db);
// Validates the stored license key/email pair against the portal in the
// background. Runs a single worker thread that waits for refresh() requests;
// the result is readable at any time via status()/isValid(). The worker is
// owned by AppState so it outlives individual pages.
class LicenseStatusTracker {
public:
LicenseStatusTracker() = default;
~LicenseStatusTracker();
LicenseStatusTracker(const LicenseStatusTracker&) = delete;
LicenseStatusTracker& operator=(const LicenseStatusTracker&) = delete;
// Spawns the worker thread. Safe to call repeatedly; only one worker runs.
void start(db::DatabaseManager& db);
// Triggers a fresh validation. No-op while a check is already running.
// Safe to call from any thread.
void refresh();
// Stops the worker and joins the thread. Must be called before the
// database is closed at shutdown.
void shutdown();
Status status() const;
bool isValid() const;
private:
void run();
mutable std::mutex mutex_;
std::condition_variable cv_;
std::thread worker_;
std::atomic<bool> stop_{false};
std::atomic<int> status_{static_cast<int>(Status::Unknown)};
db::DatabaseManager* db_ = nullptr;
};
} // namespace radiantwave::license
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
#pragma once
#include "nm_types.h"
#include "nm_device.h"
#include "nm_connection.h"
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace radiantwave::network {
// ─── Callbacks ─────────────────────────────────────────────────────────
using StateCallback = std::function<void(ConnectionState new_state,
const std::string& message)>;
using LogCallback = std::function<void(const std::string& message)>;
// ─── Network Manager ───────────────────────────────────────────────────
class NetworkManager {
public:
NetworkManager();
~NetworkManager();
// Non-copyable, movable
NetworkManager(const NetworkManager&) = delete;
NetworkManager& operator=(const NetworkManager&) = delete;
NetworkManager(NetworkManager&&) noexcept;
NetworkManager& operator=(NetworkManager&&) noexcept;
// ── Lifecycle ──────────────────────────────────────────────────────
// Connect to system D-Bus. Returns false if NM unavailable.
bool initialize();
// Shut down D-Bus connection
void shutdown();
// ── Scanning ───────────────────────────────────────────────────────
// Request a WiFi scan. Returns immediately.
// Use getScanResults() after ~2s to get results.
bool requestScan();
// Get cached scan results (no D-Bus call, returns last scan)
std::vector<NetworkInfo> getScanResults() const;
// ── Connection ─────────────────────────────────────────────────────
// Connect to WiFi network. Blocks until connected or failed.
// password can contain any characters - properly escaped.
// Returns connection state and descriptive error on failure.
ConnectResult connect(const std::string& ssid,
const std::string& password,
std::chrono::seconds timeout = std::chrono::seconds(30));
// Disconnect current connection
bool disconnect();
// ── Network Management ─────────────────────────────────────────────
// Forget (delete) a saved connection profile
bool forgetNetwork(const std::string& ssid);
// ── Status ─────────────────────────────────────────────────────────
// Is WiFi device connected to any network?
bool isConnected() const;
// What's the current SSID? Empty if not connected.
std::string currentSSID() const;
// Full connectivity check (tests actual internet access)
bool hasInternet() const;
// Force libc to reload /etc/resolv.conf and flush stale DNS cache
void refreshDnsResolver() const;
// Get list of saved network SSIDs
std::vector<std::string> savedNetworks() const;
// ── Callbacks ──────────────────────────────────────────────────────
// Called on connection state changes
void onStateChanged(StateCallback callback);
// Called with log messages
void setLogCallback(LogCallback callback);
// ── Auto-reconnect ─────────────────────────────────────────────────
// Enable/disable auto-reconnect on boot (default: enabled)
void setAutoReconnect(bool enable);
// Get the last successfully connected SSID (for auto-reconnect)
std::string lastConnectedSSID() const;
private:
// Internal helpers
void log(const std::string& message);
void notifyState(ConnectionState state, const std::string& message);
// Wait for device state
bool waitForDeviceState(uint32_t target_state, std::chrono::seconds timeout);
// Delete stale profiles for SSID
void deleteStaleProfiles(const std::string& ssid);
// Find access point by SSID
std::string findAccessPoint(const std::string& ssid);
// Build and activate connection
ConnectResult addAndActivateConnection(
const ConnectionSettings& settings,
const std::string& device_path,
const std::string& ap_path
);
// Wait for full connectivity (DHCP + DNS + routing)
bool waitForFullConnectivity(std::chrono::seconds timeout);
// Check actual internet via TCP
bool checkInternetTcp() const;
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace radiantwave::network
+234
View File
@@ -0,0 +1,234 @@
#include "nm_connection.h"
#include <algorithm>
#include <cstring>
#include <random>
#include <stdexcept>
#include <sstream>
namespace radiantwave::network {
// ─── UUID Generation (standard RFC 4122 v4) ─────────────────────────────
namespace {
std::string generateUuid() {
static std::random_device rd;
static std::mt19937_64 gen(rd());
static std::uniform_int_distribution<uint64_t> dis;
uint8_t bytes[16];
uint64_t v1 = dis(gen);
uint64_t v2 = dis(gen);
std::memcpy(bytes, &v1, 8);
std::memcpy(bytes + 8, &v2, 8);
bytes[6] = (bytes[6] & 0x0F) | 0x40; // Version 4
bytes[8] = (bytes[8] & 0x3F) | 0x80; // Variant RFC 4122
static const char hex[] = "0123456789abcdef";
char buf[37];
int p = 0;
for (int i = 0; i < 16; ++i) {
if (i == 4 || i == 6 || i == 8 || i == 10) {
buf[p++] = '-';
}
buf[p++] = hex[(bytes[i] >> 4) & 0x0F];
buf[p++] = hex[bytes[i] & 0x0F];
}
buf[36] = '\0';
return std::string(buf, 36);
}
} // anonymous namespace
// ─── Connection Builder ────────────────────────────────────────────────
ConnectionSettings buildConnectionSettings(
const std::string& ssid,
const std::string& password,
SecurityType security,
const std::string& interface_name
) {
// Validate SSID
if (!isValidSsid(ssid)) {
throw std::invalid_argument("Invalid SSID: must be 1-32 bytes with no null characters");
}
// Validate password if required
if (requiresPassword(security)) {
std::string error = validatePassword(password, security);
if (!error.empty()) {
throw std::invalid_argument(error);
}
}
ConnectionSettings settings;
// ── connection section ─────────────────────────────────────────────
settings["connection"] = {
{"id", ssid},
{"type", std::string("802-11-wireless")},
{"uuid", generateUuid()},
{"interface-name", interface_name},
{"autoconnect", true},
{"permissions", std::vector<std::string>()}
};
// ── 802-11-wireless section ────────────────────────────────────────
settings["802-11-wireless"] = {
{"ssid", ssidToBytes(ssid)},
{"mode", std::string("infrastructure")}
};
// ── 802-11-wireless-security section ───────────────────────────────
if (security != SecurityType::Open) {
SettingDict security_settings;
switch (security) {
case SecurityType::WEP:
security_settings["key-mgmt"] = std::string("none");
security_settings["wep-key0"] = password;
security_settings["wep-key-type"] = static_cast<uint32_t>(1); // WEP_KEY_TYPE_PASSPHRASE
break;
case SecurityType::WPA:
case SecurityType::WPA2:
security_settings["key-mgmt"] = std::string("wpa-psk");
security_settings["auth-alg"] = std::string("open");
security_settings["psk"] = password;
security_settings["psk-flags"] = static_cast<uint32_t>(0);
break;
case SecurityType::WPA3:
security_settings["key-mgmt"] = std::string("sae");
security_settings["psk"] = password;
break;
default:
break;
}
settings["802-11-wireless-security"] = security_settings;
}
// ── ipv4 section ───────────────────────────────────────────────────
settings["ipv4"] = {
{"method", std::string("auto")}
};
// ── ipv6 section ───────────────────────────────────────────────────
settings["ipv6"] = {
{"method", std::string("auto")}
};
return settings;
}
// ─── Security Detection ────────────────────────────────────────────────
SecurityType detectSecurityType(
uint32_t ap_flags,
uint32_t wpa_flags,
uint32_t rsn_flags
) {
// WPA3 uses SAE (flag 0x0400)
if (rsn_flags & nm_ap_wpa_flags::RsnSae) {
return SecurityType::WPA3;
}
// WPA2 uses RSN PSK (flag 0x0100)
if (rsn_flags & nm_ap_wpa_flags::RsnPsk) {
return SecurityType::WPA2;
}
// WPA uses WPA flags (not RSN)
if (wpa_flags != 0) {
return SecurityType::WPA;
}
// WEP uses privacy flag
if (ap_flags & nm_ap_flags::Privacy) {
return SecurityType::WEP;
}
return SecurityType::Open;
}
bool requiresPassword(SecurityType type) {
return type != SecurityType::Open;
}
// ─── SSID Utilities ────────────────────────────────────────────────────
std::vector<uint8_t> ssidToBytes(const std::string& ssid) {
return std::vector<uint8_t>(ssid.begin(), ssid.end());
}
std::string bytesToSsid(const std::vector<uint8_t>& bytes) {
return std::string(bytes.begin(), bytes.end());
}
bool isValidSsid(const std::string& ssid) {
// SSID must be 1-32 bytes
if (ssid.empty() || ssid.size() > 32) {
return false;
}
// SSID must not contain null bytes
for (char c : ssid) {
if (c == '\0') {
return false;
}
}
return true;
}
// ─── Password Validation ───────────────────────────────────────────────
std::string validatePassword(const std::string& password, SecurityType type) {
switch (type) {
case SecurityType::WEP:
// WEP keys can be 5 or 13 ASCII characters, or 10/26 hex digits
// For simplicity, we accept any non-empty string
if (password.empty()) {
return "WEP network requires a password";
}
break;
case SecurityType::WPA:
case SecurityType::WPA2:
// WPA/WPA2 PSK requires 8-63 characters
if (password.empty()) {
return "WPA/WPA2 network requires a password";
}
if (password.size() < 8) {
return "WPA/WPA2 password must be at least 8 characters";
}
if (password.size() > 63) {
return "WPA/WPA2 password must be at most 63 characters";
}
break;
case SecurityType::WPA3:
// WPA3 SAE requires 8-63 characters
if (password.empty()) {
return "WPA3 network requires a password";
}
if (password.size() < 8) {
return "WPA3 password must be at least 8 characters";
}
if (password.size() > 63) {
return "WPA3 password must be at most 63 characters";
}
break;
case SecurityType::Open:
// No password required
break;
}
return ""; // Valid
}
} // namespace radiantwave::network
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include "nm_types.h"
#include <functional>
#include <map>
#include <string>
#include <variant>
#include <vector>
namespace radiantwave::network {
// ─── D-Bus Variant Types ───────────────────────────────────────────────
// NetworkManager connection settings use a{sa{sv}} type:
// - array of (string -> dictionary of (string -> variant))
//
// We use a simplified representation for building settings
// that can be serialized to D-Bus format later.
using VariantValue = std::variant<
std::string,
uint32_t,
bool,
std::vector<uint8_t>, // For SSID byte arrays
std::vector<std::string>
>;
using SettingDict = std::map<std::string, VariantValue>;
using ConnectionSettings = std::map<std::string, SettingDict>;
// ─── Connection Builder ────────────────────────────────────────────────
// Build connection settings for NetworkManager
// Throws std::invalid_argument if required fields are missing
ConnectionSettings buildConnectionSettings(
const std::string& ssid,
const std::string& password,
SecurityType security,
const std::string& interface_name = "wlan0"
);
// ─── Security Detection ────────────────────────────────────────────────
// Detect security type from access point flags
SecurityType detectSecurityType(
uint32_t ap_flags,
uint32_t wpa_flags,
uint32_t rsn_flags
);
// Check if a security type requires a password
bool requiresPassword(SecurityType type);
// ─── SSID Utilities ────────────────────────────────────────────────────
// Convert SSID string to byte array for D-Bus
std::vector<uint8_t> ssidToBytes(const std::string& ssid);
// Convert byte array back to SSID string
std::string bytesToSsid(const std::vector<uint8_t>& bytes);
// Validate SSID (max 32 bytes, no null bytes)
bool isValidSsid(const std::string& ssid);
// ─── Password Validation ───────────────────────────────────────────────
// Validate password for given security type
// Returns empty string if valid, error message if invalid
std::string validatePassword(const std::string& password, SecurityType type);
} // namespace radiantwave::network
+475
View File
@@ -0,0 +1,475 @@
#include "nm_device.h"
#include "nm_connection.h"
#include <iostream>
#include <stdexcept>
// Only include sdbus on Linux
#ifdef __linux__
#include <systemd/sd-bus.h>
#endif
namespace radiantwave::network {
// ─── Access Point Helper ───────────────────────────────────────────────
namespace {
AccessPointInfo parseAccessPoint(
const std::string& object_path,
const std::string& ssid,
uint8_t signal,
uint32_t flags,
uint32_t wpa_flags,
uint32_t rsn_flags
) {
AccessPointInfo ap;
ap.object_path = object_path;
ap.ssid = ssid;
ap.ssid_bytes = ssidToBytes(ssid);
ap.signal_strength = signal;
ap.flags = flags;
ap.wpa_flags = wpa_flags;
ap.rsn_flags = rsn_flags;
ap.security = detectSecurityType(flags, wpa_flags, rsn_flags);
return ap;
}
} // anonymous namespace
// ─── WifiDevice Implementation ─────────────────────────────────────────
#ifdef __linux__
struct WifiDevice::Impl {
std::string object_path;
sd_bus* bus = nullptr;
explicit Impl(const std::string& path) : object_path(path) {
int r = sd_bus_open_system(&bus);
if (r < 0) {
throw std::runtime_error("Failed to open system bus: " + std::string(strerror(-r)));
}
}
~Impl() {
if (bus) {
sd_bus_unref(bus);
}
}
// Helper: call a D-Bus method and get response
sd_bus_message* callMethod(
const char* dest,
const char* path,
const char* iface,
const char* member,
const char* type,
...
) {
sd_bus_message* msg = nullptr;
sd_bus_error error = SD_BUS_ERROR_NULL;
int r = sd_bus_call_method(
bus,
dest,
path,
iface,
member,
&error,
&msg,
type
);
if (r < 0) {
std::string err = "D-Bus call failed: ";
err += error.message ? error.message : strerror(-r);
sd_bus_error_free(&error);
throw std::runtime_error(err);
}
sd_bus_error_free(&error);
return msg;
}
// Helper: get a property value
sd_bus_message* getProperty(
const char* dest,
const char* path,
const char* iface,
const char* prop,
const char* type
) {
sd_bus_message* msg = nullptr;
sd_bus_error error = SD_BUS_ERROR_NULL;
int r = sd_bus_get_property(
bus,
dest,
path,
iface,
prop,
&error,
&msg,
type
);
if (r < 0) {
std::string err = "D-Bus property get failed: ";
err += error.message ? error.message : strerror(-r);
sd_bus_error_free(&error);
throw std::runtime_error(err);
}
sd_bus_error_free(&error);
return msg;
}
};
WifiDevice::WifiDevice(const std::string& object_path)
: impl_(std::make_unique<Impl>(object_path))
{
}
WifiDevice::~WifiDevice() = default;
WifiDevice::WifiDevice(WifiDevice&& other) noexcept = default;
WifiDevice& WifiDevice::operator=(WifiDevice&& other) noexcept = default;
std::string WifiDevice::getInterfaceName() const {
sd_bus_message* msg = impl_->getProperty(
"org.freedesktop.NetworkManager",
impl_->object_path.c_str(),
"org.freedesktop.NetworkManager.Device",
"Interface",
"s"
);
const char* iface = nullptr;
sd_bus_message_read(msg, "s", &iface);
std::string result = iface ? iface : "";
sd_bus_message_unref(msg);
return result;
}
uint32_t WifiDevice::getDeviceState() const {
sd_bus_message* msg = impl_->getProperty(
"org.freedesktop.NetworkManager",
impl_->object_path.c_str(),
"org.freedesktop.NetworkManager.Device",
"State",
"u"
);
uint32_t state = 0;
sd_bus_message_read(msg, "u", &state);
sd_bus_message_unref(msg);
return state;
}
std::string WifiDevice::getObjectPath() const {
return impl_->object_path;
}
bool WifiDevice::requestScan() {
sd_bus_error error = SD_BUS_ERROR_NULL;
int r = sd_bus_call_method(
impl_->bus,
"org.freedesktop.NetworkManager",
impl_->object_path.c_str(),
"org.freedesktop.NetworkManager.Device.Wireless",
"RequestScan",
&error,
nullptr,
"a{sv}",
0 // Empty options dict
);
sd_bus_error_free(&error);
return r >= 0;
}
std::vector<AccessPointInfo> WifiDevice::getAccessPoints() const {
std::vector<AccessPointInfo> result;
// Get access point paths
sd_bus_message* msg = nullptr;
sd_bus_error error = SD_BUS_ERROR_NULL;
int r = sd_bus_get_property(
impl_->bus,
"org.freedesktop.NetworkManager",
impl_->object_path.c_str(),
"org.freedesktop.NetworkManager.Device.Wireless",
"AllAccessPoints",
&error,
&msg,
"ao"
);
if (r < 0) {
sd_bus_error_free(&error);
return result;
}
// Read array of object paths
r = sd_bus_message_enter_container(msg, 'a', "o");
if (r < 0) {
sd_bus_message_unref(msg);
return result;
}
const char* ap_path = nullptr;
while ((r = sd_bus_message_read(msg, "o", &ap_path)) > 0 && ap_path) {
// Get AP properties
sd_bus_message* ap_msg = nullptr;
sd_bus_error ap_error = SD_BUS_ERROR_NULL;
// Get all properties at once
r = sd_bus_get_property(
impl_->bus,
"org.freedesktop.NetworkManager",
ap_path,
"org.freedesktop.NetworkManager.AccessPoint",
"Ssid",
&ap_error,
&ap_msg,
"ay"
);
if (r >= 0) {
// Read SSID byte array
sd_bus_message_enter_container(ap_msg, 'a', "y");
const uint8_t* ssid_data = nullptr;
size_t ssid_len = 0;
r = sd_bus_message_read_array(ap_msg, 'y', (const void**)&ssid_data, &ssid_len);
std::string ssid;
if (r >= 0 && ssid_data && ssid_len > 0) {
ssid = std::string(reinterpret_cast<const char*>(ssid_data), ssid_len);
}
sd_bus_message_unref(ap_msg);
// Get signal strength
sd_bus_message* sig_msg = nullptr;
sd_bus_error sig_error = SD_BUS_ERROR_NULL;
uint8_t signal = 0;
r = sd_bus_get_property(
impl_->bus,
"org.freedesktop.NetworkManager",
ap_path,
"org.freedesktop.NetworkManager.AccessPoint",
"Strength",
&sig_error,
&sig_msg,
"y"
);
if (r >= 0) {
sd_bus_message_read(sig_msg, "y", &signal);
sd_bus_message_unref(sig_msg);
}
sd_bus_error_free(&sig_error);
// Get flags
uint32_t flags = 0, wpa_flags = 0, rsn_flags = 0;
// ... (would need similar property reads for flags)
if (!ssid.empty()) {
result.push_back(parseAccessPoint(ap_path, ssid, signal, flags, wpa_flags, rsn_flags));
}
}
sd_bus_error_free(&ap_error);
}
sd_bus_message_exit_container(msg);
sd_bus_message_unref(msg);
return result;
}
AccessPointInfo WifiDevice::getActiveAccessPoint() const {
sd_bus_message* msg = impl_->getProperty(
"org.freedesktop.NetworkManager",
impl_->object_path.c_str(),
"org.freedesktop.NetworkManager.Device.Wireless",
"ActiveAccessPoint",
"o"
);
const char* ap_path = nullptr;
sd_bus_message_read(msg, "o", &ap_path);
sd_bus_message_unref(msg);
if (!ap_path || std::string(ap_path) == "/") {
return AccessPointInfo{}; // No active AP
}
// Would need to fetch AP properties here
// For now, return basic info
AccessPointInfo ap;
ap.object_path = ap_path;
return ap;
}
bool WifiDevice::isConnected() const {
return getDeviceState() == nm_device_state::Activated;
}
std::string WifiDevice::connectedSSID() const {
if (!isConnected()) {
return "";
}
auto ap = getActiveAccessPoint();
return ap.ssid;
}
std::unique_ptr<WifiDevice> WifiDevice::findFirstDevice() {
sd_bus* bus = nullptr;
int r = sd_bus_open_system(&bus);
if (r < 0) {
return nullptr;
}
sd_bus_message* msg = nullptr;
sd_bus_error error = SD_BUS_ERROR_NULL;
r = sd_bus_call_method(
bus,
"org.freedesktop.NetworkManager",
"/org/freedesktop/NetworkManager",
"org.freedesktop.NetworkManager",
"GetDevices",
&error,
&msg,
""
);
if (r < 0) {
sd_bus_error_free(&error);
sd_bus_unref(bus);
return nullptr;
}
// Read device paths
r = sd_bus_message_enter_container(msg, 'a', "o");
if (r < 0) {
sd_bus_message_unref(msg);
sd_bus_unref(bus);
return nullptr;
}
const char* dev_path = nullptr;
std::string wifi_path;
while ((r = sd_bus_message_read(msg, "o", &dev_path)) > 0 && dev_path) {
// Check device type
sd_bus_message* type_msg = nullptr;
sd_bus_error type_error = SD_BUS_ERROR_NULL;
uint32_t dev_type = 0;
r = sd_bus_get_property(
bus,
"org.freedesktop.NetworkManager",
dev_path,
"org.freedesktop.NetworkManager.Device",
"DeviceType",
&type_error,
&type_msg,
"u"
);
if (r >= 0) {
sd_bus_message_read(type_msg, "u", &dev_type);
sd_bus_message_unref(type_msg);
// NM_DEVICE_TYPE_WIFI = 2
if (dev_type == 2) {
wifi_path = dev_path;
break;
}
}
sd_bus_error_free(&type_error);
}
sd_bus_message_exit_container(msg);
sd_bus_message_unref(msg);
sd_bus_unref(bus);
if (wifi_path.empty()) {
return nullptr;
}
return std::make_unique<WifiDevice>(wifi_path);
}
#else // !__linux__
// ─── Stub implementation for non-Linux platforms ───────────────────────
struct WifiDevice::Impl {
std::string object_path;
explicit Impl(const std::string& path) : object_path(path) {}
};
WifiDevice::WifiDevice(const std::string& object_path)
: impl_(std::make_unique<Impl>(object_path))
{
}
WifiDevice::~WifiDevice() = default;
WifiDevice::WifiDevice(WifiDevice&& other) noexcept = default;
WifiDevice& WifiDevice::operator=(WifiDevice&& other) noexcept = default;
std::string WifiDevice::getInterfaceName() const {
return "wlan0"; // Stub
}
uint32_t WifiDevice::getDeviceState() const {
return nm_device_state::Disconnected; // Stub
}
std::string WifiDevice::getObjectPath() const {
return impl_->object_path;
}
bool WifiDevice::requestScan() {
std::cerr << "[WifiDevice] Stub: requestScan() called on non-Linux platform\n";
return false;
}
std::vector<AccessPointInfo> WifiDevice::getAccessPoints() const {
std::cerr << "[WifiDevice] Stub: getAccessPoints() called on non-Linux platform\n";
return {};
}
AccessPointInfo WifiDevice::getActiveAccessPoint() const {
std::cerr << "[WifiDevice] Stub: getActiveAccessPoint() called on non-Linux platform\n";
return {};
}
bool WifiDevice::isConnected() const {
return false;
}
std::string WifiDevice::connectedSSID() const {
return "";
}
std::unique_ptr<WifiDevice> WifiDevice::findFirstDevice() {
std::cerr << "[WifiDevice] Stub: findFirstDevice() called on non-Linux platform\n";
return nullptr;
}
#endif // __linux__
} // namespace radiantwave::network
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include "nm_types.h"
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace radiantwave::network {
// ─── Access Point ──────────────────────────────────────────────────────
struct AccessPointInfo {
std::string object_path; // D-Bus object path
std::string ssid;
std::vector<uint8_t> ssid_bytes;
uint8_t signal_strength = 0;
uint32_t flags = 0; // AP flags (privacy bit)
uint32_t wpa_flags = 0; // WPA flags
uint32_t rsn_flags = 0; // RSN/WPA2 flags
SecurityType security = SecurityType::Open;
};
// ─── WiFi Device Interface ─────────────────────────────────────────────
// Abstract interface for WiFi device operations
// Allows mocking for unit tests
class IWifiDevice {
public:
virtual ~IWifiDevice() = default;
// Get device properties
virtual std::string getInterfaceName() const = 0;
virtual uint32_t getDeviceState() const = 0;
virtual std::string getObjectPath() const = 0;
// Scan operations
virtual bool requestScan() = 0;
virtual std::vector<AccessPointInfo> getAccessPoints() const = 0;
virtual AccessPointInfo getActiveAccessPoint() const = 0;
// Connection operations
virtual bool isConnected() const = 0;
virtual std::string connectedSSID() const = 0;
};
// ─── Real WiFi Device (D-Bus) ──────────────────────────────────────────
// Forward declare sdbus types
struct SdBusConnection;
struct SdBusMessage;
class WifiDevice : public IWifiDevice {
public:
explicit WifiDevice(const std::string& object_path);
~WifiDevice() override;
// Non-copyable
WifiDevice(const WifiDevice&) = delete;
WifiDevice& operator=(const WifiDevice&) = delete;
// Movable
WifiDevice(WifiDevice&& other) noexcept;
WifiDevice& operator=(WifiDevice&& other) noexcept;
// IWifiDevice interface
std::string getInterfaceName() const override;
uint32_t getDeviceState() const override;
std::string getObjectPath() const override;
bool requestScan() override;
std::vector<AccessPointInfo> getAccessPoints() const override;
AccessPointInfo getActiveAccessPoint() const override;
bool isConnected() const override;
std::string connectedSSID() const override;
// Static factory: find first WiFi device
static std::unique_ptr<WifiDevice> findFirstDevice();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace radiantwave::network
+140
View File
@@ -0,0 +1,140 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace radiantwave::network {
// ─── Security Types ────────────────────────────────────────────────────
enum class SecurityType : uint8_t {
Open = 0,
WEP,
WPA,
WPA2,
WPA3
};
inline const char* securityTypeToString(SecurityType type) {
switch (type) {
case SecurityType::Open: return "Open";
case SecurityType::WEP: return "WEP";
case SecurityType::WPA: return "WPA";
case SecurityType::WPA2: return "WPA2";
case SecurityType::WPA3: return "WPA3";
default: return "Unknown";
}
}
// ─── Connection State ──────────────────────────────────────────────────
enum class ConnectionState : uint8_t {
Disconnected = 0,
Preparing, // Connection profile selected
Configuring, // DHCP in progress
Activated, // Fully connected
Failed,
NeedAuth, // Wrong password or auth required
Timeout
};
inline const char* connectionStateToString(ConnectionState state) {
switch (state) {
case ConnectionState::Disconnected: return "Disconnected";
case ConnectionState::Preparing: return "Preparing";
case ConnectionState::Configuring: return "Configuring";
case ConnectionState::Activated: return "Connected";
case ConnectionState::Failed: return "Failed";
case ConnectionState::NeedAuth: return "Authentication Required";
case ConnectionState::Timeout: return "Connection Timed Out";
default: return "Unknown";
}
}
// ─── Network Information ───────────────────────────────────────────────
struct NetworkInfo {
std::string ssid;
uint8_t signal_strength = 0; // 0-100
SecurityType security = SecurityType::Open;
bool saved = false; // Has saved profile in NM
bool connected = false; // Currently active
};
// ─── Connection Result ─────────────────────────────────────────────────
struct ConnectResult {
ConnectionState state = ConnectionState::Disconnected;
std::string error_message; // Empty on success
std::string nm_error_name; // D-Bus error name for debugging
};
// ─── NM State Constants ────────────────────────────────────────────────
// From nm-dbus-interface.h
namespace nm_state {
constexpr uint32_t Unknown = 0;
constexpr uint32_t Asleep = 10;
constexpr uint32_t Disconnected = 20;
constexpr uint32_t Connecting = 40;
constexpr uint32_t ConnectedLocal = 50;
constexpr uint32_t ConnectedSite = 60;
constexpr uint32_t ConnectedGlobal = 70;
} // namespace nm_state
// Device states
namespace nm_device_state {
constexpr uint32_t Unknown = 0;
constexpr uint32_t Unmanaged = 10;
constexpr uint32_t Unavailable = 20;
constexpr uint32_t Disconnected = 30;
constexpr uint32_t Prepare = 40;
constexpr uint32_t Config = 50;
constexpr uint32_t NeedAuth = 60;
constexpr uint32_t IpConfig = 70;
constexpr uint32_t IpCheck = 80;
constexpr uint32_t Secondaries = 90;
constexpr uint32_t Activated = 100;
constexpr uint32_t Deactivating = 110;
constexpr uint32_t Failed = 120;
} // namespace nm_device_state
// Active connection states
namespace nm_active_connection_state {
constexpr uint32_t Unknown = 0;
constexpr uint32_t Activating = 1;
constexpr uint32_t Activated = 2;
constexpr uint32_t Deactivating = 3;
constexpr uint32_t Deactivated = 4;
} // namespace nm_active_connection_state
// Connectivity states
namespace nm_connectivity {
constexpr uint32_t Unknown = 0;
constexpr uint32_t None = 1;
constexpr uint32_t Portal = 2;
constexpr uint32_t Limited = 3;
constexpr uint32_t Full = 4;
} // namespace nm_connectivity
// Access point flags
namespace nm_ap_flags {
constexpr uint32_t Privacy = 0x1;
} // namespace nm_ap_flags
// WPA/RSN flags for security detection
namespace nm_ap_wpa_flags {
constexpr uint32_t None = 0x0000;
constexpr uint32_t WpaPsk = 0x0001;
constexpr uint32_t WpaTkip = 0x0002;
constexpr uint32_t WpaEap = 0x0004;
// RSN (WPA2) flags
constexpr uint32_t RsnPsk = 0x0100;
constexpr uint32_t RsnCcmp = 0x0200;
constexpr uint32_t RsnEap = 0x0400;
// WPA3/SAE
constexpr uint32_t RsnSae = 0x0400; // SAE flag
} // namespace nm_ap_wpa_flags
} // namespace radiantwave::network
+230
View File
@@ -0,0 +1,230 @@
#include "network/proclamation_sync.h"
#include "network/api_client.h"
#include "network/json.h"
#include <string>
namespace radiantwave::sync {
namespace {
// ─── HTML stripping ────────────────────────────────────────────────────
std::string replaceAll(std::string text, const std::string& from, const std::string& to) {
if (from.empty()) return text;
size_t pos = 0;
while ((pos = text.find(from, pos)) != std::string::npos) {
text.replace(pos, from.size(), to);
pos += to.size();
}
return text;
}
std::string stripHtml(const std::string& in) {
std::string out;
out.reserve(in.size());
bool inTag = false;
for (char c : in) {
if (c == '<') {
inTag = true;
} else if (c == '>') {
inTag = false;
} else if (!inTag) {
out += c;
}
}
out = replaceAll(std::move(out), "&nbsp;", " ");
out = replaceAll(std::move(out), "&amp;", "&");
out = replaceAll(std::move(out), "&lt;", "<");
out = replaceAll(std::move(out), "&gt;", ">");
out = replaceAll(std::move(out), "&quot;", "\"");
out = replaceAll(std::move(out), "&#39;", "'");
out = replaceAll(std::move(out), "&apos;", "'");
return out;
}
} // anonymous namespace
// ─── Public API ────────────────────────────────────────────────────────
bool parseProclamationsJson(const std::string& body,
std::vector<radiantwave::db::ProclamationRecord>& out) {
json::JsonValue root;
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
return false;
}
out.clear();
for (const auto& entry : root.array) {
if (entry.type != json::JsonValue::Type::Object) continue;
radiantwave::db::ProclamationRecord rec;
rec.id = entry.stringField("id");
if (rec.id.empty()) continue;
rec.title = stripHtml(entry.stringField("name"));
rec.content = stripHtml(entry.stringField("content"));
rec.category_id = static_cast<int>(entry.numberField("categoryId"));
rec.solfeggio_frequency_id = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
rec.required_level_id = static_cast<int>(entry.numberField("subscriptionLevelId"));
if (rec.required_level_id <= 0) rec.required_level_id = 1;
out.push_back(std::move(rec));
}
return true;
}
bool parseCategoriesJson(const std::string& body,
std::vector<radiantwave::db::ProclamationCategoryRecord>& out) {
json::JsonValue root;
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
return false;
}
out.clear();
for (const auto& entry : root.array) {
if (entry.type != json::JsonValue::Type::Object) continue;
radiantwave::db::ProclamationCategoryRecord rec;
rec.id = static_cast<int>(entry.numberField("id"));
if (rec.id == 0) continue;
rec.name = entry.stringField("name");
rec.parent_id = static_cast<int>(entry.numberField("parentId"));
rec.sort_order = static_cast<int>(entry.numberField("sortOrder"));
out.push_back(std::move(rec));
}
return true;
}
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
const std::string& defaultProclamationId) {
// TODO(portal #4/#5): once the portal exposes color palettes, sync them
// alongside proclamations here (syncColorPalettesFromApi or
// syncProclamationPalettesFromApi) so every caller gets palettes in one step.
api::HttpResponse proclamationsResponse =
api::httpGet(api::PATH_PROCLAMATIONS);
if (!proclamationsResponse.transportOk ||
proclamationsResponse.status < 200 || proclamationsResponse.status >= 300) {
return false;
}
api::HttpResponse categoriesResponse =
api::httpGet(api::PATH_CATEGORIES);
if (!categoriesResponse.transportOk ||
categoriesResponse.status < 200 || categoriesResponse.status >= 300) {
return false;
}
std::vector<radiantwave::db::ProclamationRecord> proclamations;
std::vector<radiantwave::db::ProclamationCategoryRecord> categories;
if (!parseProclamationsJson(proclamationsResponse.body, proclamations) ||
!parseCategoriesJson(categoriesResponse.body, categories)) {
return false;
}
if (!db.replaceCategories(categories)) {
return false;
}
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
return false;
}
return true;
}
// ─── Color palettes (readiness code; not wired into the app flow) ─────
bool parseProclamationPalettesJson(const std::string& body,
std::vector<radiantwave::db::PaletteRecord>& out) {
json::JsonValue root;
if (!json::parseDocument(body, root) || root.type != json::JsonValue::Type::Array) {
return false;
}
auto parseHex = [](const std::string& hex) -> radiantwave::db::PaletteColor {
radiantwave::db::PaletteColor c;
auto nibble = [&hex](size_t i) -> int {
char ch = hex.size() > i ? hex[i] : '0';
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return 0;
};
// Accept "#RRGGBB" or "RRGGBB".
size_t base = (!hex.empty() && hex[0] == '#') ? 1 : 0;
c.r = static_cast<uint8_t>(nibble(base) * 16 + nibble(base + 1));
c.g = static_cast<uint8_t>(nibble(base + 2) * 16 + nibble(base + 3));
c.b = static_cast<uint8_t>(nibble(base + 4) * 16 + nibble(base + 5));
return c;
};
out.clear();
for (const auto& entry : root.array) {
if (entry.type != json::JsonValue::Type::Object) continue;
int freqId = static_cast<int>(entry.numberField("solfeggioFrequencyId"));
if (freqId == 0) continue;
const json::JsonValue* colors = entry.find("colorPalette");
if (!colors || colors->type != json::JsonValue::Type::Array || colors->array.empty()) {
continue;
}
// When several proclamations share a frequency, the first wins.
bool exists = false;
for (const auto& existing : out) {
if (existing.solfeggio_frequency_id == freqId) {
exists = true;
break;
}
}
if (exists) continue;
radiantwave::db::PaletteRecord rec;
rec.solfeggio_frequency_id = freqId;
for (const auto& color : colors->array) {
if (color.type == json::JsonValue::Type::String) {
rec.colors.push_back(parseHex(color.str));
}
}
out.push_back(std::move(rec));
}
return true;
}
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey) {
std::vector<api::ColorPalette> palettes;
if (!api::fetchColorPalettes(palettes, apiKey)) {
return false;
}
std::vector<radiantwave::db::PaletteRecord> records;
records.reserve(palettes.size());
for (const auto& p : palettes) {
radiantwave::db::PaletteRecord rec;
rec.solfeggio_frequency_id = p.solfeggio_frequency_id;
rec.colors.reserve(p.colors.size());
for (const auto& c : p.colors) {
rec.colors.push_back({c.r, c.g, c.b, c.a});
}
records.push_back(std::move(rec));
}
return db.replaceColorPalettes(records);
}
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey) {
api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS, apiKey);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
std::vector<radiantwave::db::PaletteRecord> records;
if (!parseProclamationPalettesJson(response.body, records)) {
return false;
}
return db.replaceColorPalettes(records);
}
} // namespace radiantwave::sync
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "db/database_manager.h"
#include <string>
#include <vector>
namespace radiantwave::sync {
// Parse the JSON payload returned by the proclamations endpoint.
// Strips HTML from the content body. Returns false on malformed input.
bool parseProclamationsJson(const std::string& body,
std::vector<radiantwave::db::ProclamationRecord>& out);
// Parse the JSON payload returned by the categories endpoint.
bool parseCategoriesJson(const std::string& body,
std::vector<radiantwave::db::ProclamationCategoryRecord>& out);
// Fetches proclamations + categories from the API and stores them in the DB,
// preserving any existing selection and forcing `defaultProclamationId` to
// stay selected. Returns true only if the API sync succeeded; on failure the
// database is left untouched so the app can fall back to cached data.
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
const std::string& defaultProclamationId);
// Parse color palettes embedded in the proclamations JSON payload. Each
// proclamation object may carry a "colorPalette" array of "#RRGGBB" strings;
// the palette is keyed by the proclamation's solfeggioFrequencyId. When
// several proclamations share a frequency, the first occurrence wins. Returns
// false on malformed input.
bool parseProclamationPalettesJson(const std::string& body,
std::vector<radiantwave::db::PaletteRecord>& out);
// Fetch the standalone /color-palettes route and replace the local palette
// table (the portal is the source of truth). Returns true only on HTTP 2xx +
// successful parse + successful replace.
//
// NOTE: not yet wired into the app flow — the portal does not expose the route
// yet. Readiness code: palettes should be updated from the portal, not code
// releases, so callers should treat this as a future sync step.
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "");
// Alternative to syncColorPalettesFromApi: fetch proclamations and replace the
// local palette table using the palettes embedded in each proclamation.
// NOTE: not yet wired into the app flow; see syncColorPalettesFromApi.
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "");
} // namespace radiantwave::sync