initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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), " ", " ");
|
||||
out = replaceAll(std::move(out), "&", "&");
|
||||
out = replaceAll(std::move(out), "<", "<");
|
||||
out = replaceAll(std::move(out), ">", ">");
|
||||
out = replaceAll(std::move(out), """, "\"");
|
||||
out = replaceAll(std::move(out), "'", "'");
|
||||
out = replaceAll(std::move(out), "'", "'");
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user