diff --git a/src/config-merge.hpp b/src/config-merge.hpp new file mode 100644 index 0000000..49d4c6b --- /dev/null +++ b/src/config-merge.hpp @@ -0,0 +1,142 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Save only changes made since this editor's last observation. This preserves +// preferences, comments and unknown options written by other desktop editors. +namespace wcm_config +{ +using Values = std::map; +struct Entry { size_t line, first, last; std::string value; }; +struct Ini +{ + std::vector lines; + std::map entries; + std::map sections; + explicit Ini(const std::string& source) + { + std::string section; + for (size_t pos = 0; pos < source.size();) + { + auto end = source.find('\n', pos); + const size_t count = end == source.npos ? source.size() - pos : end - pos + 1; + const size_t index = lines.size(); lines.push_back(source.substr(pos, count)); pos += count; + const auto& raw = lines.back(); + const auto first = raw.find_first_not_of(" \t\r\n"); + if (first == raw.npos || raw[first] == '#' || raw[first] == ';') continue; + if (raw[first] == '[') + { + const auto close = raw.find(']', first); + if (close == raw.npos) throw std::runtime_error("Invalid config section"); + section = raw.substr(first + 1, close - first - 1); + if (!sections.emplace(section, index).second) throw std::runtime_error("Duplicate config section"); + continue; + } + const auto equal = raw.find('=', first); + if (equal == raw.npos || section.empty()) throw std::runtime_error("Invalid config entry"); + auto name_end = raw.find_last_not_of(" \t", equal - 1); + const auto key = section + "/" + raw.substr(first, name_end - first + 1); + size_t last = raw.size(); + for (size_t i = equal + 1; i < raw.size(); ++i) + if ((raw[i] == '#' || raw[i] == ';') && raw[i - 1] != '\\') { last = i; break; } + const auto start = raw.find_first_not_of(" \t", equal + 1); + const auto finish = raw.find_last_not_of(" \t\r\n", last ? last - 1 : 0); + const size_t a = start == raw.npos || start >= last ? equal + 1 : start; + const size_t b = finish == raw.npos || finish < a ? a : finish + 1; + if (!entries.emplace(key, Entry{index, a, b, raw.substr(a, b - a)}).second) + throw std::runtime_error("Duplicate config entry"); + } + } + std::string bytes() const { std::string out; for (const auto& line : lines) out += line; return out; } + void set(const std::string& key, const std::string& value) + { + if (auto entry = entries.find(key); entry != entries.end()) + { const auto& e = entry->second; lines[e.line].replace(e.first, e.last - e.first, value); return; } + auto slash = key.find('/'); auto section = key.substr(0, slash); + const std::string newline = bytes().find("\r\n") == std::string::npos ? "\n" : "\r\n"; + size_t at = lines.size(); + if (auto found = sections.find(section); found != sections.end()) + { + at = found->second + 1; + while (at < lines.size()) + { + const auto first = lines[at].find_first_not_of(" \t\r\n"); + if (first != std::string::npos && lines[at][first] == '[') break; + ++at; + } + } else + { + if (!lines.empty() && lines.back().back() != '\n') lines.back() += newline; + lines.push_back(newline + "[" + section + "]" + newline); at = lines.size(); + } + if (at && !lines[at - 1].empty() && lines[at - 1].back() != '\n') lines[at - 1] += newline; + lines.insert(lines.begin() + at, key.substr(slash + 1) + " = " + value + newline); + } +}; +inline std::string merge(const std::string& source, const std::string& before, const std::string& after) +{ + const Ini original(before), edited(after); + std::string result = source; + for (const auto& [key, entry] : edited.entries) + { + auto previous = original.entries.find(key); + if (previous != original.entries.end() && previous->second.value == entry.value) continue; + Ini current(result); current.set(key, entry.value); result = current.bytes(); + } + return result; +} +inline bool same(const struct stat& a, const struct stat& b) +{ + return a.st_dev == b.st_dev && a.st_ino == b.st_ino && a.st_size == b.st_size && + a.st_mtim.tv_sec == b.st_mtim.tv_sec && a.st_mtim.tv_nsec == b.st_mtim.tv_nsec && + a.st_ctim.tv_sec == b.st_ctim.tv_sec && a.st_ctim.tv_nsec == b.st_ctim.tv_nsec; +} +inline void save(const std::string& path, const std::string& before, const std::string& after) +{ + struct Fd { int value; ~Fd() { if (value >= 0) close(value); } }; + Fd file{open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)}; + if (file.value < 0 && errno == ENOENT) + file.value = open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + struct stat stamp{}; + if (file.value < 0 || flock(file.value, LOCK_EX | LOCK_NB) || fstat(file.value, &stamp) || + !S_ISREG(stamp.st_mode) || stamp.st_nlink != 1 || stamp.st_uid != geteuid() || + stamp.st_size > 1024 * 1024 || !(stamp.st_mode & S_IWUSR)) + throw std::runtime_error("Config is busy or unavailable; reopen WCM before retrying"); + std::string source; char buffer[8192]; ssize_t count; + while ((count = read(file.value, buffer, sizeof(buffer))) > 0) + { source.append(buffer, count); if (source.size() > 1024 * 1024) throw std::runtime_error("Config exceeds limit"); } + if (count < 0) throw std::runtime_error("Cannot read config"); + const auto result = merge(source, before, after); + if (result == source) return; + std::string temporary = path + ".wcm-XXXXXX"; Fd output{mkstemp(temporary.data())}; + if (output.value < 0) throw std::runtime_error("Cannot prepare config save"); + struct Remove { std::string path; ~Remove() { unlink(path.c_str()); } } cleanup{temporary}; + if (fchown(output.value, stamp.st_uid, stamp.st_gid) || fchmod(output.value, stamp.st_mode & 0777)) + throw std::runtime_error("Cannot retain config permissions"); + size_t pos = 0; + while (pos < result.size()) + { + const auto written = write(output.value, result.data() + pos, result.size() - pos); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) throw std::runtime_error("Cannot save config"); + pos += written; + } + struct stat current{}, opened{}; + if (fsync(output.value) || lstat(path.c_str(), ¤t) || fstat(file.value, &opened) || + !same(stamp, current) || !same(stamp, opened)) + throw std::runtime_error("Config changed while saving; reopen WCM before retrying"); + if (rename(temporary.c_str(), path.c_str())) throw std::runtime_error("Cannot replace config"); + Fd directory{open(std::filesystem::path(path).parent_path().c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC)}; + if (directory.value < 0 || fsync(directory.value)) + throw std::runtime_error("Config saved; directory sync uncertain. Reopen WCM to verify"); +} +} diff --git a/src/wcm.cpp b/src/wcm.cpp index f1ba719..1327c2b 100644 --- a/src/wcm.cpp +++ b/src/wcm.cpp @@ -1,5 +1,6 @@ #include "wcm.hpp" #include "utils.hpp" +#include "config-merge.hpp" #include #include @@ -1336,6 +1337,10 @@ WCM::WCM(Glib::RefPtr app) app->signal_startup().connect([this, app] () { load_config_files(); + saved_config_snapshots[wf_config_file] = wf::config::save_configuration_options_to_string(wf_config_mgr); +#if HAVE_WFSHELL + saved_config_snapshots[wf_shell_config_file] = wf::config::save_configuration_options_to_string(wf_shell_config_mgr); +#endif parse_config(); #if HAVE_WFSHELL parse_wfshell_config(); @@ -1630,6 +1635,7 @@ void WCM::open_page(Plugin *plugin) if (!snap) throw std::runtime_error("Move snapping settings are unavailable."); // Merge the current file before saving, preserving edits by other controls. wf::config::load_configuration_options_from_file(wf_config_mgr, wf_config_file); + saved_config_snapshots[wf_config_file] = wf::config::save_configuration_options_to_string(wf_config_mgr); auto section = get_config_section(snap); section->get_option("enable_snap")->set_value_str(enabled ? "true" : "false"); section->get_option("snap_threshold")->set_value_str(std::to_string(edge)); @@ -1656,7 +1662,11 @@ void WCM::open_displays() window->resize(1100, 880); current_plugin = nullptr; displays_page = std::make_unique(wf_config_file, - [this] { wf::config::load_configuration_options_from_file(wf_config_mgr, wf_config_file); }, + [this] + { + wf::config::load_configuration_options_from_file(wf_config_mgr, wf_config_file); + saved_config_snapshots[wf_config_file] = wf::config::save_configuration_options_to_string(wf_config_mgr); + }, [this] { Glib::signal_idle().connect_once([this] { open_page(find_plugin_by_name(plugins, "panel")); }); }, [this] { Glib::signal_idle().connect_once([this] { open_page(find_plugin_by_name(plugins, "move")); }); }); displays_scroll.add(*displays_page); @@ -1826,7 +1836,7 @@ Plugin*WCM::find_plugin_by_name(std::vector plugins, std::string search * save values in the compound list itself, not the options which represent the * entries in the list. */ -void WCM::save_to_file(wf::config::config_manager_t & mgr, const std::string & file) +bool WCM::save_to_file(wf::config::config_manager_t & mgr, const std::string & file) { std::cout << "Saving to file " << file << std::endl; @@ -1843,21 +1853,31 @@ void WCM::save_to_file(wf::config::config_manager_t & mgr, const std::string & f } } - wf::config::save_configuration_to_file(mgr, file); + const auto edited = wf::config::save_configuration_options_to_string(mgr); + auto& baseline = saved_config_snapshots.at(file); + try + { + wcm_config::save(file, baseline, edited); + baseline = edited; + return true; + } catch (const std::exception& error) + { + Gtk::MessageDialog dialog(*window, error.what(), false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK, true); + dialog.run(); + return false; + } } bool WCM::save_config(Plugin *plugin) { if (plugin->type == PLUGIN_TYPE_WAYFIRE) { - save_to_file(wf_config_mgr, wf_config_file); - return true; + return save_to_file(wf_config_mgr, wf_config_file); } if (plugin->type == PLUGIN_TYPE_WF_SHELL) { - save_to_file(wf_shell_config_mgr, wf_shell_config_file); - return true; + return save_to_file(wf_shell_config_mgr, wf_shell_config_file); } return false; diff --git a/src/wcm.hpp b/src/wcm.hpp index 218c79f..ca53963 100644 --- a/src/wcm.hpp +++ b/src/wcm.hpp @@ -311,7 +311,7 @@ class WCM void parse_config(wf::config::config_manager_t & config_manager); bool init_input_inhibitor(); void create_main_layout(); - void save_to_file(wf::config::config_manager_t & mgr, const std::string & file); + bool save_to_file(wf::config::config_manager_t & mgr, const std::string & file); // these objects can be used when widgets are destroyed and emit `signal_changed` // causing saving config @@ -321,6 +321,7 @@ class WCM std::string wf_config_file; std::string wf_shell_config_file; std::string start_plugin; + std::map saved_config_snapshots; std::vector plugins; std::map plugin_paths;