known_sources() reads the session files the layout store already owns, keeping only those that name a signal: a development machine accumulates one file per probe run on a throwaway port, and all 140 of them here hold empty slots.
461 lines
16 KiB
C++
461 lines
16 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "layout_store.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <cstdio>
|
|
#include <dirent.h>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
|
|
namespace udpscope {
|
|
|
|
namespace {
|
|
|
|
/// Anything outside a safe filename set becomes '_': the host is a dotted quad
|
|
/// today, but the CLI takes any string and a '/' or ':' in it must not escape
|
|
/// the directory.
|
|
std::string sanitize(const std::string& host) {
|
|
std::string out;
|
|
out.reserve(host.size());
|
|
for (char c : host) {
|
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
|
|
out.push_back(ok ? c : '_');
|
|
}
|
|
return out.empty() ? "_" : out;
|
|
}
|
|
|
|
std::string config_dir() {
|
|
const char* xdg = std::getenv("XDG_CONFIG_HOME");
|
|
if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
|
|
const char* home = std::getenv("HOME");
|
|
if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config";
|
|
return std::string(home) + "/.config/udpscope";
|
|
}
|
|
|
|
void mkdirs(const std::string& path) {
|
|
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
|
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
|
// first `save_layout()` failed silently, and the session appeared to persist only on a machine
|
|
// where something else had already made the directory.
|
|
std::string cur;
|
|
for (std::size_t i = 0; i < path.size(); ++i) {
|
|
cur.push_back(path[i]);
|
|
if (path[i] != '/' && i + 1 != path.size()) continue;
|
|
::mkdir(cur.c_str(), 0755);
|
|
}
|
|
}
|
|
|
|
std::string toml_escape(const std::string& s) {
|
|
std::string out = "\"";
|
|
for (char c : s) {
|
|
if (c == '"' || c == '\\') out.push_back('\\');
|
|
out.push_back(c);
|
|
}
|
|
return out + "\"";
|
|
}
|
|
|
|
bool unescape(std::string& s) {
|
|
std::string out;
|
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
|
if (s[i] != '\\') {
|
|
out.push_back(s[i]);
|
|
continue;
|
|
}
|
|
if (++i >= s.size()) return false;
|
|
if (s[i] == '"' || s[i] == '\\') {
|
|
out.push_back(s[i]);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
s = out;
|
|
return true;
|
|
}
|
|
|
|
std::string trim(const std::string& s) {
|
|
const auto a = s.find_first_not_of(" \t\r");
|
|
if (a == std::string::npos) return {};
|
|
const auto b = s.find_last_not_of(" \t\r");
|
|
return s.substr(a, b - a + 1);
|
|
}
|
|
|
|
/// `"a", "b"` (with the brackets already stripped) -> ["a", "b"].
|
|
bool parse_string_array(const std::string& in, std::vector<std::string>& out) {
|
|
out.clear();
|
|
std::size_t i = 0;
|
|
while (i < in.size()) {
|
|
while (i < in.size() && (in[i] == ' ' || in[i] == ',' || in[i] == '\t')) ++i;
|
|
if (i >= in.size()) break;
|
|
if (in[i] != '"') return false;
|
|
++i;
|
|
std::string tok;
|
|
while (i < in.size() && in[i] != '"') {
|
|
if (in[i] == '\\' && i + 1 < in.size()) {
|
|
tok.push_back(in[i]);
|
|
++i;
|
|
}
|
|
tok.push_back(in[i]);
|
|
++i;
|
|
}
|
|
if (i >= in.size()) return false;
|
|
++i; // closing quote
|
|
if (!unescape(tok)) return false;
|
|
out.push_back(tok);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string layout_path(const std::string& host, std::uint16_t port) {
|
|
const std::string dir = config_dir();
|
|
mkdirs(dir);
|
|
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
|
}
|
|
|
|
std::vector<KnownSource> known_sources() {
|
|
std::vector<KnownSource> out;
|
|
const std::string dir = config_dir();
|
|
DIR* d = ::opendir(dir.c_str());
|
|
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
|
if (d == nullptr) return out;
|
|
while (const dirent* e = ::readdir(d)) {
|
|
const std::string name = e->d_name;
|
|
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
|
const std::string stem = name.substr(0, name.size() - 5);
|
|
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
|
// may hold them too and a first-match split would return a host cut in half.
|
|
const std::size_t cut = stem.rfind('_');
|
|
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
|
const long port = std::strtol(stem.c_str() + cut + 1, nullptr, 10);
|
|
if (port <= 0 || port > 65535) continue;
|
|
|
|
LayoutState st;
|
|
const std::string path = dir + "/" + name;
|
|
if (!load_layout(path, st)) continue;
|
|
|
|
KnownSource s;
|
|
s.host = stem.substr(0, cut);
|
|
s.port = static_cast<std::uint16_t>(port);
|
|
s.multicast = st.multicast;
|
|
s.interface_addr = st.interface_addr;
|
|
s.rows = static_cast<int>(st.rows.size());
|
|
for (const LayoutState::Row& row : st.rows) {
|
|
for (const std::string& sig : row.signals) {
|
|
if (!sig.empty()) ++s.signals;
|
|
}
|
|
}
|
|
if (s.signals == 0) continue; // see the header: a session nobody configured
|
|
|
|
struct stat sb {};
|
|
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
|
out.push_back(std::move(s));
|
|
}
|
|
::closedir(d);
|
|
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
|
// swapping between runs.
|
|
std::stable_sort(out.begin(), out.end(),
|
|
[](const KnownSource& a, const KnownSource& b) { return a.mtime > b.mtime; });
|
|
return out;
|
|
}
|
|
|
|
bool forget_source(const KnownSource& src) {
|
|
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
|
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
|
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
|
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
|
}
|
|
|
|
std::string math_node_text(const MathNode& node) {
|
|
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
|
|
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
|
|
// than to the declaration order of an enum nobody else has to know about.
|
|
int kind = 0;
|
|
for (int i = 0; i < math_op_count(); ++i) {
|
|
if (math_op_at(i) == node.op) kind = i;
|
|
}
|
|
std::ostringstream o;
|
|
o << kind << '|' << node.source << '|' << node.in_a << '|' << node.in_b << '|' << node.gain << '|'
|
|
<< node.offset << '|' << node.fc << '|' << node.q << '|' << node.lo << '|' << node.hi << '|'
|
|
<< node.x << '|' << node.y;
|
|
return o.str();
|
|
}
|
|
|
|
bool math_node_from_text(const std::string& text, MathNode& out) {
|
|
std::vector<std::string> f;
|
|
std::string cur;
|
|
for (const char c : text) {
|
|
if (c == '|') {
|
|
f.push_back(cur);
|
|
cur.clear();
|
|
} else {
|
|
cur.push_back(c);
|
|
}
|
|
}
|
|
f.push_back(cur);
|
|
if (f.size() != 12u) return false;
|
|
const auto number = [&](std::size_t i, double& v) {
|
|
try {
|
|
v = std::stod(f[i]);
|
|
return true;
|
|
} catch (...) {
|
|
return false;
|
|
}
|
|
};
|
|
double kind = 0.0;
|
|
double in_a = -1.0;
|
|
double in_b = -1.0;
|
|
if (!number(0, kind) || !number(2, in_a) || !number(3, in_b)) return false;
|
|
const int k = static_cast<int>(kind);
|
|
if (k < 0 || k >= math_op_count()) return false;
|
|
out = MathNode{};
|
|
out.op = math_op_at(k);
|
|
out.source = f[1];
|
|
out.in_a = static_cast<int>(in_a);
|
|
out.in_b = static_cast<int>(in_b);
|
|
return number(4, out.gain) && number(5, out.offset) && number(6, out.fc) && number(7, out.q) &&
|
|
number(8, out.lo) && number(9, out.hi) && number(10, out.x) && number(11, out.y);
|
|
}
|
|
|
|
bool load_layout(const std::string& path, LayoutState& out) {
|
|
std::ifstream in(path);
|
|
if (!in) return false;
|
|
|
|
LayoutState st;
|
|
st.window_s = 0.0;
|
|
st.rows.clear();
|
|
st.calibration.clear();
|
|
st.enabled.clear();
|
|
st.math.clear();
|
|
st.measures.clear();
|
|
|
|
std::string line;
|
|
// What the last `[[...]]` header opened, so keys land in the right record.
|
|
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top;
|
|
int plot_count = 0;
|
|
bool plot_section_seen = false;
|
|
while (std::getline(in, line)) {
|
|
const std::string t = trim(line);
|
|
if (t.empty() || t[0] == '#') continue;
|
|
if (t[0] == '[') {
|
|
if (t == "[[plot]]") {
|
|
section = Section::Plot;
|
|
plot_section_seen = true;
|
|
st.rows.emplace_back();
|
|
} else if (t == "[[calibration]]") {
|
|
section = Section::Calibration;
|
|
st.calibration.emplace_back();
|
|
} else if (t == "[[math]]") {
|
|
section = Section::Math;
|
|
st.math.emplace_back();
|
|
} else if (t == "[[measure]]") {
|
|
section = Section::Measure;
|
|
st.measures.emplace_back();
|
|
} else {
|
|
section = Section::Top; // an unknown table: keep its keys out of ours
|
|
}
|
|
continue;
|
|
}
|
|
const auto eq = t.find('=');
|
|
if (eq == std::string::npos) continue;
|
|
const std::string key = trim(t.substr(0, eq));
|
|
const std::string value = trim(t.substr(eq + 1));
|
|
auto strip_quote = [](const std::string& v) {
|
|
if (v.size() >= 2 && v.front() == '"' && v.back() == '"') {
|
|
std::string s = v.substr(1, v.size() - 2);
|
|
std::string out;
|
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
|
if (s[i] == '\\' && i + 1 < s.size()) ++i;
|
|
out.push_back(s[i]);
|
|
}
|
|
return out;
|
|
}
|
|
return v;
|
|
};
|
|
|
|
if (section == Section::Top) {
|
|
if (key == "window_s") {
|
|
try {
|
|
st.window_s = std::stod(value);
|
|
} catch (...) {
|
|
}
|
|
} else if (key == "enabled") {
|
|
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
|
|
parse_string_array(value.substr(1, value.size() - 2), st.enabled);
|
|
}
|
|
} else if (key == "plots") {
|
|
// Only a fallback for a file with no [[plot]] sections: the writer emits
|
|
// both, and honouring both appended the sections onto the pre-created
|
|
// rows (a 2-plot file loaded as 4).
|
|
try {
|
|
plot_count = std::max(0, std::stoi(value));
|
|
} catch (...) {
|
|
}
|
|
} else if (key == "multicast") {
|
|
st.multicast = strip_quote(value);
|
|
} else if (key == "interface") {
|
|
st.interface_addr = strip_quote(value);
|
|
}
|
|
} else if (section == Section::Plot && !st.rows.empty()) {
|
|
if (key == "signals") {
|
|
std::vector<std::string> names;
|
|
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
|
|
parse_string_array(value.substr(1, value.size() - 2), names);
|
|
}
|
|
st.rows.back().signals = names;
|
|
}
|
|
} else if (section == Section::Math && !st.math.empty()) {
|
|
MathChannel& m = st.math.back();
|
|
if (key == "name") {
|
|
m.name = strip_quote(value);
|
|
} else if (key == "unit") {
|
|
m.unit = strip_quote(value);
|
|
} else if (key == "enabled") {
|
|
m.enabled = value == "true";
|
|
} else if (key == "output") {
|
|
try {
|
|
m.output = std::stoi(value);
|
|
} catch (...) {
|
|
}
|
|
} else if (key == "nodes") {
|
|
std::vector<std::string> texts;
|
|
if (value.size() >= 2 && value.front() == '[' && value.back() == ']') {
|
|
parse_string_array(value.substr(1, value.size() - 2), texts);
|
|
}
|
|
m.nodes.clear();
|
|
for (const std::string& text : texts) {
|
|
MathNode node;
|
|
// A node this version cannot read is dropped rather than failing the whole file: a graph
|
|
// with a hole in it is invalid and the editor says so, which beats losing the session.
|
|
if (math_node_from_text(text, node)) m.nodes.push_back(node);
|
|
}
|
|
}
|
|
} else if (section == Section::Measure && !st.measures.empty()) {
|
|
Measure& m = st.measures.back();
|
|
if (key == "signal") {
|
|
m.signal = strip_quote(value);
|
|
} else if (key == "type") {
|
|
try {
|
|
const int index = std::stoi(value);
|
|
if (index >= 0 && index < measure_type_count()) m.type = measure_type_at(index);
|
|
} catch (...) {
|
|
}
|
|
} else if (key == "threshold") {
|
|
try {
|
|
m.options.threshold_pct = std::stod(value);
|
|
} catch (...) {
|
|
}
|
|
} else if (key == "above") {
|
|
m.options.above = value == "true";
|
|
} else if (key == "show") {
|
|
m.options.show_on_plot = value == "true";
|
|
}
|
|
} else if (section == Section::Calibration && !st.calibration.empty()) {
|
|
if (key == "name") {
|
|
st.calibration.back().name = strip_quote(value);
|
|
} else if (key == "gain") {
|
|
try {
|
|
st.calibration.back().gain = std::stod(value);
|
|
} catch (...) {
|
|
}
|
|
} else if (key == "offset") {
|
|
try {
|
|
st.calibration.back().offset = std::stod(value);
|
|
} catch (...) {
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!plot_section_seen && plot_count > 0) {
|
|
st.rows.assign(static_cast<std::size_t>(plot_count), {});
|
|
}
|
|
out = std::move(st);
|
|
return true;
|
|
}
|
|
|
|
bool save_layout(const std::string& path, const LayoutState& state) {
|
|
std::ostringstream o;
|
|
o << "# udpscope session layout (written by the app; safe to edit)\n";
|
|
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
|
|
o << "plots = " << state.rows.size() << "\n";
|
|
// Top level, before any table, for the same reason as `enabled` below.
|
|
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
|
|
if (!state.interface_addr.empty()) {
|
|
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
|
}
|
|
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
|
|
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
|
|
// never saw it and a session's enable state was silently lost.
|
|
if (!state.enabled.empty()) {
|
|
o << "enabled = [";
|
|
for (std::size_t i = 0; i < state.enabled.size(); ++i) {
|
|
if (i != 0) o << ", ";
|
|
o << toml_escape(state.enabled[i]);
|
|
}
|
|
o << "]\n";
|
|
}
|
|
for (const auto& row : state.rows) {
|
|
o << "\n[[plot]]\nsignals = [";
|
|
for (std::size_t i = 0; i < row.signals.size(); ++i) {
|
|
if (i != 0) o << ", ";
|
|
o << toml_escape(row.signals[i]);
|
|
}
|
|
o << "]\n";
|
|
}
|
|
for (const auto& cal : state.calibration) {
|
|
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
|
<< "\noffset = " << cal.offset << "\n";
|
|
}
|
|
for (const MathChannel& m : state.math) {
|
|
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
|
|
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
|
|
<< "\nnodes = [";
|
|
for (std::size_t i = 0; i < m.nodes.size(); ++i) {
|
|
if (i != 0) o << ", ";
|
|
o << toml_escape(math_node_text(m.nodes[i]));
|
|
}
|
|
o << "]\n";
|
|
}
|
|
for (const Measure& m : state.measures) {
|
|
// The *accessor's* index, not the enum's value: `measure_type_at()` is the mapping the dialog
|
|
// and this file share, and its order is the dropdown's -- which is not the declaration order of
|
|
// the enum. Writing the raw value and reading it back through the accessor turned `top` into
|
|
// whatever sat at that position (the same mistake the math nodes' op index was written to avoid).
|
|
int kind = 0;
|
|
for (int i = 0; i < measure_type_count(); ++i) {
|
|
if (measure_type_at(i) == m.type) kind = i;
|
|
}
|
|
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
|
|
<< "\nthreshold = " << m.options.threshold_pct
|
|
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
|
|
<< (m.options.show_on_plot ? "true" : "false") << "\n";
|
|
}
|
|
|
|
// A sibling temp file + rename: a crash mid-write leaves the old layout, not a
|
|
// truncated one that would fail to parse next start.
|
|
const std::string tmp = path + ".tmp";
|
|
{
|
|
std::ofstream f(tmp, std::ios::trunc);
|
|
if (!f) return false;
|
|
f << o.str();
|
|
if (!f.good()) {
|
|
::remove(tmp.c_str());
|
|
return false;
|
|
}
|
|
}
|
|
if (std::rename(tmp.c_str(), path.c_str()) != 0) {
|
|
::remove(tmp.c_str());
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace udpscope
|