// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "layout_store.hpp" #include #include #include #include #include #include #include #include #include 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; } } // namespace /// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the /// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of /// `/udpscope` fails when `` does not exist yet, which is what a fresh install looks /// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make. 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); } } namespace { 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& 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 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"; } std::string cache_dir() { const char* xdg = std::getenv("XDG_CACHE_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-cache"; return std::string(home) + "/.cache/udpscope"; } 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::string config_dir_for(const std::string& host, std::uint16_t port) { return config_dir() + "/" + sanitize(host) + "_" + std::to_string(port); } std::string configuration_path(const std::string& host, std::uint16_t port, const std::string& name) { const std::string dir = config_dir_for(host, port); mkdirs(dir); return dir + "/" + sanitize(name) + ".toml"; } namespace { bool file_exists(const std::string& path) { struct stat sb {}; return ::stat(path.c_str(), &sb) == 0; } /// A session written before configurations existed is the configuration named `default`: copied /// into the per-source directory, remembered as active, and the old file renamed out of the way /// rather than deleted, so the import can be undone by hand. Runs before every read and write of /// the per-source directory, so a machine that upgrades keeps its session. void import_legacy(const std::string& host, std::uint16_t port) { const std::string dir = config_dir_for(host, port); if (file_exists(dir)) return; const std::string legacy = layout_path(host, port); if (!file_exists(legacy)) return; mkdirs(dir); FILE* in = std::fopen(legacy.c_str(), "rb"); if (in == nullptr) return; const std::string target = dir + "/default.toml"; FILE* out = std::fopen(target.c_str(), "wb"); if (out == nullptr) { std::fclose(in); return; } char buf[4096]; std::size_t n = 0; while ((n = std::fread(buf, 1, sizeof(buf), in)) > 0) std::fwrite(buf, 1, n, out); std::fclose(in); std::fclose(out); FILE* active = std::fopen((dir + "/active").c_str(), "wb"); if (active != nullptr) { std::fputs("default\n", active); std::fclose(active); } ::rename(legacy.c_str(), (legacy + ".imported").c_str()); } /// `a.toml` -> `a`; anything else -> "". std::string toml_stem(const std::string& file) { if (file.size() <= 5 || file.compare(file.size() - 5, 5, ".toml") != 0) return {}; return file.substr(0, file.size() - 5); } /// `host_port` -> {host, port}, or false. The *last* '_' is the split, because `sanitize()` /// replaces anything unsafe in a host with one. bool split_stem(const std::string& stem, std::string* host, std::uint16_t* port) { const std::size_t cut = stem.rfind('_'); if (cut == std::string::npos || cut + 1 == stem.size()) return false; char* end = nullptr; const long p = std::strtol(stem.c_str() + cut + 1, &end, 10); if (end == nullptr || *end != '\0' || p <= 0 || p > 65535) return false; *host = stem.substr(0, cut); *port = static_cast(p); return true; } /// The configurations a source directory holds, newest first, by file name. std::vector names_in_dir(const std::string& dir) { std::vector> found; DIR* d = ::opendir(dir.c_str()); if (d == nullptr) return {}; while (const dirent* e = ::readdir(d)) { const std::string stem = toml_stem(e->d_name); if (stem.empty()) continue; struct stat sb {}; const std::string path = dir + "/" + e->d_name; std::int64_t mtime = 0; if (::stat(path.c_str(), &sb) == 0) mtime = static_cast(sb.st_mtime); found.emplace_back(mtime, stem); } ::closedir(d); std::stable_sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); std::vector out; out.reserve(found.size()); for (auto& f : found) out.push_back(std::move(f.second)); return out; } } // namespace std::vector configuration_names(const std::string& host, std::uint16_t port) { import_legacy(host, port); return names_in_dir(config_dir_for(host, port)); } std::string active_configuration(const std::string& host, std::uint16_t port) { const std::vector names = configuration_names(host, port); if (names.empty()) return "default"; FILE* f = std::fopen((config_dir_for(host, port) + "/active").c_str(), "rb"); if (f != nullptr) { char buf[512]; const std::size_t n = std::fread(buf, 1, sizeof(buf) - 1, f); std::fclose(f); buf[n] = '\0'; const std::string want = trim(std::string(buf)); if (std::find(names.begin(), names.end(), want) != names.end()) return want; } // Newest, which is the one just saved when nothing wrote an `active`. return names.front(); } bool set_active_configuration(const std::string& host, std::uint16_t port, const std::string& name) { const std::string dir = config_dir_for(host, port); mkdirs(dir); FILE* f = std::fopen((dir + "/active").c_str(), "wb"); if (f == nullptr) return false; std::fputs(sanitize(name).c_str(), f); std::fputs("\n", f); std::fclose(f); return true; } bool forget_configuration(const std::string& host, std::uint16_t port, const std::string& name) { const std::string path = configuration_path(host, port, name); const bool gone = std::remove(path.c_str()) == 0; const std::string active = active_configuration(host, port); if (gone && sanitize(name) == sanitize(active)) { std::remove((config_dir_for(host, port) + "/active").c_str()); } return gone; } std::vector known_sources() { const std::string root = config_dir(); // A legacy single-file session is imported first, so the listing below has one shape: the // per-source directory and its configuration files. if (DIR* d = ::opendir(root.c_str())) { while (const dirent* e = ::readdir(d)) { const std::string stem = toml_stem(e->d_name); if (stem.empty()) continue; std::string host; std::uint16_t port = 0; if (!split_stem(stem, &host, &port)) continue; // The scheme `layout_path()` writes, and nothing else: a stray `.toml` here is not ours. if (sanitize(host) + "_" + std::to_string(port) != stem) continue; import_legacy(host, port); } ::closedir(d); } std::vector out; DIR* d = ::opendir(root.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 dir = root + "/" + e->d_name; struct stat sb {}; if (::stat(dir.c_str(), &sb) != 0 || !S_ISDIR(sb.st_mode)) continue; std::string host; std::uint16_t port = 0; if (!split_stem(e->d_name, &host, &port)) continue; // The name must be one `config_dir_for()` could have written, or `forget_source()` would walk // out of the directory the row came from. if (sanitize(host) + "_" + std::to_string(port) != e->d_name) continue; // One row per *configuration*: the same source may hold several named sessions, which is the // point of the feature, and the chooser is where they are told apart. for (const std::string& name : names_in_dir(dir)) { const std::string path = dir + "/" + name + ".toml"; LayoutState st; if (!load_layout(path, st)) continue; KnownSource s; s.host = host; s.port = port; s.config = name; s.comment = st.comment; s.buffer_mb = st.buffer_mb; s.relative_time = st.relative_time; s.capture_packets = st.capture_packets; s.multicast = st.multicast; s.interface_addr = st.interface_addr; s.rows = static_cast(st.rows.size()); for (const LayoutState::Row& row : st.rows) { for (const std::string& sig : row.signals) { if (!sig.empty()) ++s.signals; } } // A configuration nobody configured is not worth a row: a development machine accumulates one // session file per probe run and every one holds `signals = ["", "", "", ""]`. One that // carries a *setting* is worth a row even before it has ever streamed -- that is how a host is // set up from the chooser's form, and the settings are the whole point of editing one. const bool configured = s.signals > 0 || s.buffer_mb > 0.0 || !st.comment.empty() || st.relative_time || st.capture_packets > 0; if (!configured) continue; struct stat fb {}; s.mtime = (::stat(path.c_str(), &fb) == 0) ? static_cast(fb.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 `configuration_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 forget_configuration(src.host, src.port, src.config); } 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 << '|' << node.med_n; return o.str(); } bool math_node_from_text(const std::string& text, MathNode& out) { std::vector 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); // Twelve fields is a session file written before the de-noise node existed; its `med_n` stays at the // struct's default, which is what that file meant. if (f.size() != 12u && f.size() != 13u) 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(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(in_a); out.in_b = static_cast(in_b); if (!(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))) { return false; } if (f.size() == 13u) { double med = 0.0; if (!number(12, med)) return false; out.med_n = static_cast(med); } return true; } 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.colors.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, Color, 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 == "[[color]]") { section = Section::Color; st.colors.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 == "measure_stats") { st.measure_stats = value == "true"; } else if (key == "multicast") { st.multicast = strip_quote(value); } else if (key == "interface") { st.interface_addr = strip_quote(value); } else if (key == "comment") { st.comment = strip_quote(value); } else if (key == "buffer_mb") { try { st.buffer_mb = std::stod(value); } catch (...) { } } else if (key == "relative_time") { st.relative_time = value == "true"; } else if (key == "capture_packets") { try { st.capture_packets = static_cast(std::max(0, std::stoi(value))); } catch (...) { } } } else if (section == Section::Plot && !st.rows.empty()) { if (key == "signals") { std::vector 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 (key == "weight") { st.rows.back().weight = std::strtod(value.c_str(), nullptr); } } 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 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 == "noise") { try { m.options.noise_pct = std::stod(value); } catch (...) { } } else if (key == "hf") { try { m.options.hf_fc = 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 (...) { } } } else if (section == Section::Color && !st.colors.empty()) { if (key == "name") { st.colors.back().name = strip_quote(value); } else if (key == "rgb") { // `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal // would be the one number in this file nobody could check by eye. Anything unparseable // leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway. const std::string hex = strip_quote(value); const std::size_t at = hex.find('#'); try { st.colors.back().rgb = static_cast(std::stoul( at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) & 0x00FFFFFFu; } catch (...) { } } } } if (!plot_section_seen && plot_count > 0) { st.rows.assign(static_cast(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. A bare key written after a `[[table]]` header belongs to that // table in TOML, which is exactly how `enabled` was once parsed into the last `[[calibration]]` // record and silently lost. 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"; } o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n"; if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n"; if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n"; for (const auto& row : state.rows) { o << "\n[[plot]]\nweight = " << row.weight << "\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 auto& color : state.colors) { char rgb[16]; std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu); o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\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") << "\nnoise = " << m.options.noise_pct << "\nhf = " << m.options.hf_fc << "\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