diff --git a/src/backgrounds.cpp b/src/backgrounds.cpp index b835825..1a18134 100644 --- a/src/backgrounds.cpp +++ b/src/backgrounds.cpp @@ -7,15 +7,43 @@ namespace using json = nlohmann::json; json empty_slot() { return {{"image", ""}, {"sizing", "fill"}, {"color", {0,0,0,0}}, {"opacity", 1.0}}; } json empty_layout() { return {{"mode", "transparent"}, {"image", empty_slot()}, {"monitors", json::object()}}; } +void validate_background_snapshot(const json& data) +{ + data.at("revision").get(); data.at("topology").get(); + data.at("error_message").get(); data.at("applied").get(); + auto slot = [] (const json& s) + { + s.at("image").get(); s.at("sizing").get(); s.at("opacity").get(); + if (!s.at("color").is_array() || s.at("color").size() != 4) throw std::runtime_error("Invalid background color"); + for (auto& channel : s.at("color")) channel.get(); + }; + auto layout = [&] (const json& l) + { + l.at("mode").get(); slot(l.at("image")); + if (!l.at("monitors").is_object()) throw std::runtime_error("Invalid background monitors"); + for (auto& image : l.at("monitors")) slot(image); + }; + auto& profiles = data.at("settings").at("profiles"); + if (!profiles.is_object() || !data.at("outputs").is_array()) throw std::runtime_error("Invalid background profiles"); + for (auto& profile : profiles) + { + layout(profile.at("default")); + if (!profile.at("workspaces").is_object()) throw std::runtime_error("Invalid background workspaces"); + for (auto& workspace : profile.at("workspaces")) layout(workspace); + } + for (auto& output : data.at("outputs")) + { + output.at("name").get(); output.at("profile").get(); + if (!output.at("grid").is_array() || output.at("grid").size() != 2 || !output.at("monitors").is_array()) + throw std::runtime_error("Invalid workspace output"); + for (auto& dimension : output.at("grid")) dimension.get(); + for (auto& monitor : output.at("monitors")) monitor.at("name").get(); + } +} void clear(Gtk::Box& box) { for (auto child : box.get_children()) box.remove(*child); } void label_style(Gtk::Label& label) { label.set_halign(Gtk::ALIGN_START); label.set_line_wrap(); label.set_max_width_chars(75); label.set_selectable(true); } } -BackgroundsPage::json BackgroundsPage::request(const std::string& method, const json& data) -{ - wf::json_t input; wf::json_t::parse_string(data.dump(), input); - return json::parse(wcm_ipc_request(method, input).serialize()); -} BackgroundsPage::BackgroundsPage(const std::string& config) : Gtk::Box(Gtk::ORIENTATION_VERTICAL, 10), config_file(config) { set_border_width(12); pack_start(tabs, true, true); @@ -45,11 +73,11 @@ BackgroundsPage::BackgroundsPage(const std::string& config) : Gtk::Box(Gtk::ORIE if (selected_workspace == "default") { show_layout(); return; } auto profile = state["outputs"][outputs.get_active_row_number()]["profile"].get(); if (draft["profiles"].contains(profile)) draft["profiles"][profile]["workspaces"].erase(selected_workspace); - changed(); + ++edit_serial; changed(); status.set_text("Unapplied workspace background changes"); } else { auto layout = current_layout(); layout["mode"] = id; store_layout(layout); } show_layout(); }); - reload.signal_clicked().connect([this] { refresh(); }); + reload.signal_clicked().connect([this] { refresh(true); }); apply.signal_clicked().connect([this] { save(); }); all.signal_clicked().connect([this] { @@ -63,29 +91,43 @@ BackgroundsPage::BackgroundsPage(const std::string& config) : Gtk::Box(Gtk::ORIE if (target.is_null()) target = {{"default", empty_layout()}, {"workspaces", json::object()}}; for (int y=0; y() == pending_job) - { - pending_job = 0; - if (next["job_success"].get()) refresh(); - else { changed(); status.set_text(next["error_message"].get()); } - } - } catch (const std::exception& e) { pending_job = 0; changed(); status.set_text(e.what()); } - } + if (pending_job && get_mapped() && !ipc.busy()) check_job(); return true; }, 200); } BackgroundsPage::~BackgroundsPage() { timer.disconnect(); } + +void BackgroundsPage::check_job() +{ + if (!pending_job || ipc.busy()) return; + ipc.send("workspace-backgrounds/status", {}, [this] (WcmIpcResult result) + { + try + { + if (!result.error.empty()) throw std::runtime_error(result.error); + auto next = json::parse(result.reply.serialize()); + show_memory(next); + if (next["completed_job"].get() == pending_job) + { + pending_job = 0; + if (next["job_success"].get()) refresh(true); + else { changed(); status.set_text(next["error_message"].get()); } + } + } catch (const std::exception& e) + { + // This is an observation failure, not evidence that the submitted + // renderer job failed. Retain its id and never resubmit the job. + status.set_text(std::string(e.what()) + "; still waiting for the background job."); + } + }); +} void BackgroundsPage::show_memory(const json& renderer) { const auto mib = 1024 * 1024; @@ -94,14 +136,31 @@ void BackgroundsPage::show_memory(const json& renderer) memory.set_text("Image memory: " + std::to_string((used + mib - 1) / mib) + " / " + std::to_string(limit / mib) + " MiB. Adjustable in Workspace backgrounds → Texture budget."); } -void BackgroundsPage::refresh() +void BackgroundsPage::refresh(bool discard) { - try + if (discard) discard_serial = edit_serial; + if (mutation_pending || pending_job) return; + if (ipc.busy()) { refresh_pending = true; discard_pending |= discard; return; } + refresh_pending = false; discard |= discard_pending; discard_pending = false; + auto serial = discard ? discard_serial : edit_serial; + status.set_text("Waiting for workspace backgrounds…"); + ipc.send("workspace-backgrounds/get", {}, [this, discard, serial] (WcmIpcResult result) { - auto next = request("workspace-backgrounds/get"); + try + { + if (!result.error.empty()) throw std::runtime_error(result.error); + auto next = json::parse(result.reply.serialize()); if (next["version"] != 1) throw std::runtime_error("Unsupported workspace background API"); if (std::filesystem::weakly_canonical(next["config_file"].get()) != std::filesystem::weakly_canonical(config_file)) throw std::runtime_error("Open WCM with the running session's configuration to edit workspace backgrounds"); + validate_background_snapshot(next); + if (serial != edit_serial || (!discard && available && draft != original)) + { + show_memory(next); status.set_text("Unapplied workspace background changes retained."); + if (refresh_pending && get_mapped()) refresh(); + return; + } + uncertain = false; state = next; draft = original = state["settings"]; available = true; show_memory(state); loading = true; outputs.remove_all(); @@ -110,12 +169,14 @@ void BackgroundsPage::refresh() loading = false; select_output(); auto error = state["error_message"].get(); status.set_text(!error.empty() ? error : state["applied"].get() ? "Saved and loaded by the workspace renderer" : "Saved; images are awaiting the workspace renderer"); - } catch (const std::exception& e) - { - available = false; loading = false; - status.set_text(std::string(e.what()) + "\nWorkspace images need the workspace-backgrounds and ipc plugins with the matching Wayfire build."); - } - changed(); + } catch (const std::exception& e) + { + loading = false; changed(); + status.set_text(std::string(e.what()) + " Last observed settings and unapplied edits are retained."); + } + changed(); + if (refresh_pending && get_mapped()) refresh(); + }); } void BackgroundsPage::select_output() { @@ -143,15 +204,14 @@ void BackgroundsPage::store_layout(const json& layout) auto& target = draft["profiles"][profile]; if (target.is_null()) target = {{"default", empty_layout()}, {"workspaces", json::object()}}; if (selected_workspace == "default") target["default"] = layout; else target["workspaces"][selected_workspace] = layout; - changed(); + ++edit_serial; changed(); status.set_text("Unapplied workspace background changes"); } void BackgroundsPage::changed() { - outputs.set_sensitive(available && !pending_job); selection.set_sensitive(available && !pending_job); - mode.set_sensitive(available && !pending_job); editor.set_sensitive(available && !pending_job && mode.get_active_id() != "inherit"); - apply.set_sensitive(available && !pending_job && draft != original); all.set_sensitive(available && !pending_job); - reload.set_sensitive(!pending_job); - if (available && draft != original && !pending_job) status.set_text("Unapplied workspace background changes"); + outputs.set_sensitive(available && !pending_job && !mutation_pending); selection.set_sensitive(available && !pending_job && !mutation_pending); + mode.set_sensitive(available && !pending_job && !mutation_pending); editor.set_sensitive(available && !pending_job && !mutation_pending && mode.get_active_id() != "inherit"); + apply.set_sensitive(available && !pending_job && !mutation_pending && draft != original && !uncertain); all.set_sensitive(available && !pending_job && !mutation_pending); + reload.set_sensitive(!pending_job && !mutation_pending); } void BackgroundsPage::show_layout() { @@ -211,9 +271,25 @@ void BackgroundsPage::image_row(Gtk::Box& parent, const std::string& label, cons } void BackgroundsPage::save() { - try + if (!available || pending_job || mutation_pending || uncertain || draft == original) return; + ipc.cancel_observation(); refresh_pending = discard_pending = false; + json request = {{"revision",state["revision"]}, {"topology",state["topology"]}, {"settings",draft}}; + wf::json_t input; wf::json_t::parse_string(request.dump(), input); + mutation_pending = true; changed(); status.set_text("Submitting workspace background changes…"); + ipc.send("workspace-backgrounds/apply", input, [this] (WcmIpcResult result) { - auto response = request("workspace-backgrounds/apply", {{"revision",state["revision"]}, {"topology",state["topology"]}, {"settings",draft}}); - pending_job = response["job"]; status.set_text("Preparing workspace background images…"); changed(); - } catch (const std::exception& e) { status.set_text(e.what()); } + mutation_pending = false; + try + { + if (!result.error.empty()) throw std::runtime_error(result.error); + auto response = json::parse(result.reply.serialize()); + pending_job = response.at("job").get(); + if (!pending_job) throw std::runtime_error("Invalid background job response"); + changed(); status.set_text("Preparing workspace background images…"); + } catch (const std::exception& e) + { + uncertain = result.uncertain || result.error.empty(); changed(); + status.set_text(std::string(e.what()) + (uncertain ? ". Outcome unknown; use Revert workspace changes to reload the saved state before applying again." : "")); + } + }, true); } diff --git a/src/backgrounds.hpp b/src/backgrounds.hpp index be75561..d7a2a8d 100644 --- a/src/backgrounds.hpp +++ b/src/backgrounds.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include "ipc.hpp" #include class BackgroundsPage : public Gtk::Box @@ -14,11 +15,14 @@ class BackgroundsPage : public Gtk::Box Gtk::Button apply{"Apply workspace backgrounds"}, reload{"Revert workspace changes"}, all{"Copy to all workspaces"}; json state, draft, original; std::string config_file, selected_output, selected_workspace = "default"; - bool loading = false, available = false; + bool loading = false, available = false, mutation_pending = false, uncertain = false; + bool refresh_pending = false, discard_pending = false; + uint64_t edit_serial = 0, discard_serial = 0; + WcmIpcClient ipc; uint64_t pending_job = 0; sigc::connection timer; - static json request(const std::string& method, const json& data = json::object()); - void refresh(); + void refresh(bool discard = false); + void check_job(); void show_memory(const json& renderer); void select_output(); void show_layout(); diff --git a/src/displays.cpp b/src/displays.cpp index 0ea8993..ecb50f6 100644 --- a/src/displays.cpp +++ b/src/displays.cpp @@ -2,6 +2,8 @@ #include "displays.hpp" #include "backgrounds.hpp" #include "ipc.hpp" +#include +#include #include #include #include @@ -40,9 +42,40 @@ void field(Gtk::Grid& grid, int row, const std::string& name, Gtk::Widget& contr } } -wf::json_t DisplaysPage::request(const std::string& method, wf::json_t data) +namespace +{ +void validate_display_snapshot(const wf::json_t& wire) { - return wcm_ipc_request(method, data); + // Validate everything load()/select_monitor() consume before replacing a + // last-good observation or rebuilding any draft controls. + auto data = nlohmann::json::parse(wire.serialize()); + data.at("revision").get(); data.at("pending").get(); + const auto& options = data.at("options"); + if (!options.is_object()) throw std::runtime_error("Invalid display options"); + for (const auto& value : options) value.get(); + for (auto key : {"core/window_scope_rules", "core/window_scope", "core/stitch_enabled", "core/stitch_outputs", + "core/default_output", "grid/use_virtual", "grid/use_physical", "grid/reset_scope", "grid/next_monitor", + "grid/zone_shift", "grid/zone_maximize", "move/activate_zone"}) options.at(key).get(); + std::stoi(options.at("grid/zone_gap").get()); + auto rules = nlohmann::json::parse(options.at("core/window_scope_rules").get()); + if (!rules.is_array()) throw std::runtime_error("Invalid application scope rules"); + for (auto& rule : rules) { rule.at("app_id").get(); rule.at("scope").get(); } + if (!data.at("physical").is_array()) throw std::runtime_error("Invalid physical monitor list"); + std::set names; + for (auto& head : data.at("physical")) + { + auto name = head.at("name").get(); + if (name.empty() || !names.insert(name).second) throw std::runtime_error("Invalid monitor name"); + auto prefix = head.at("prefix").get(); head.at("description").get(); + for (auto key : {"width", "height", "refresh"}) head.at(key).get(); + if (!head.at("modes").is_array()) throw std::runtime_error("Invalid monitor modes"); + for (auto& mode : head.at("modes")) for (auto key : {"width", "height", "refresh"}) mode.at(key).get(); + for (auto key : {"mode", "masked", "position", "transform"}) options.at(prefix + key).get(); + std::stoi(options.at(prefix + "zone_gap").get()); + auto scale = std::stod(options.at(prefix + "scale").get()); + if (!std::isfinite(scale) || scale <= 0) throw std::runtime_error("Invalid monitor scale"); + } +} } DisplaysPage::DisplaysPage(const std::string& config, std::function on_saved, @@ -166,6 +199,8 @@ DisplaysPage::DisplaysPage(const std::string& config, std::function on_s std::swap(order[i], order[target]); update_members(); changed(); }; left_button.signal_clicked().connect([move] { move(-1); }); right_button.signal_clicked().connect([move] { move(1); }); + signal_map().connect([this] { refresh(); }); + signal_unmap().connect([this] { leave(); }); refresh(true); timer = Glib::signal_timeout().connect([this] { @@ -173,16 +208,27 @@ DisplaysPage::DisplaysPage(const std::string& config, std::function on_s { --countdown; status.set_text("Keep these display settings? Reverting automatically in " + std::to_string(std::max(0, countdown)) + " seconds."); - if (countdown <= 0) { transaction.clear(); refresh(true); status.set_text("Display preview expired; previous settings restored."); } - } else if (get_mapped() && !dirty) refresh(); + if (countdown <= 0 && !mutation_pending) { transaction.clear(); refresh(); status.set_text("Display preview deadline reached; checking current settings."); } + } else if (get_mapped() && (!dirty || uncertain)) refresh(); return true; }, 1000); } DisplaysPage::~DisplaysPage() { - timer.disconnect(); identify_timer.disconnect(); - if (!transaction.empty()) { try { wf::json_t data; data["token"] = transaction; request("displays/revert", data); } catch (...) {} } + timer.disconnect(); identify_timer.disconnect(); ipc.cancel_observation(); + if (!transaction.empty()) + { + wf::json_t data; data["token"] = transaction; + wcm_ipc_cleanup("displays/revert", data); + } +} + +void DisplaysPage::leave() +{ + refresh_pending = discard_pending = false; + ipc.cancel_observation(); identify_timer.disconnect(); identifiers.clear(); + if (!transaction.empty() && !mutation_pending) finish(false); } std::string DisplaysPage::get(const std::string& key) const @@ -194,28 +240,71 @@ void DisplaysPage::error(const std::string& message) { status.set_text(message); void DisplaysPage::refresh(bool discard) { - try + if (discard) discard_serial = edit_serial; + if (mutation_pending) return; + if (ipc.busy()) { refresh_pending = true; discard_pending |= discard; return; } + refresh_pending = false; discard |= discard_pending; discard_pending = false; + auto serial = discard ? discard_serial : edit_serial; + status.set_text("Waiting for display settings…"); + ipc.send("displays/get", {}, [this, discard, serial] (WcmIpcResult result) { - auto data = request("displays/get"); - if (data["version"].as_int() != 1) throw std::runtime_error("Unsupported display-management version"); - if (std::filesystem::weakly_canonical(data["config_file"].as_string()) != std::filesystem::weakly_canonical(config_file)) - throw std::runtime_error("WCM is editing a different configuration from the running compositor. Open its session configuration: " + data["config_file"].as_string()); - if (data["pending"].as_bool() && transaction.empty()) + try { - left.set_sensitive(false); right.set_sensitive(false); scope_tab.set_sensitive(false); - apply_button.set_sensitive(false); identify_button.set_sensitive(false); - status.set_text("A display preview is awaiting confirmation in another window."); return; + if (!result.error.empty()) throw std::runtime_error(result.error); + auto data = result.reply; + if (data["version"].as_int() != 1) throw std::runtime_error("Unsupported display-management version"); + if (std::filesystem::weakly_canonical(data["config_file"].as_string()) != std::filesystem::weakly_canonical(config_file)) + throw std::runtime_error("WCM is editing a different configuration from the running compositor. Open its session configuration: " + data["config_file"].as_string()); + validate_display_snapshot(data); + foreign_preview = data["pending"].as_bool() && transaction.empty(); + if (foreign_preview) + { + update_controls(); + status.set_text(uncertain ? "The previous request has an uncertain outcome. A preview is pending; waiting for its automatic rollback." : + "A display preview is awaiting confirmation in another window."); + } else + { + uncertain = false; + // A refresh can discard only the draft that existed when it + // started. Never overwrite typing done while its reply waited. + if (serial == edit_serial && (discard || !dirty) && (discard || revision != data["revision"].as_string())) load(data); + available = true; update_controls(); + status.set_text(dirty ? "Unapplied changes retained. Apply checks the current configuration revision." : + "Changes are previewed for 20 seconds. Keep Changes saves them; leaving this page reverts the preview."); + } + } catch (const std::exception& e) + { + loading = false; update_controls(); + error(std::string(e.what()) + " Last observed settings and unapplied edits are retained."); } - bool recovering = !available; - if (discard || revision != data["revision"].as_string()) load(data); - available = true; update_controls(); - if (recovering && !dirty) status.set_text("Changes are previewed for 20 seconds. Keep Changes saves them; closing WCM lets the preview revert."); - } catch (const std::exception& e) + if (refresh_pending && get_mapped()) refresh(); + }); +} + +void DisplaysPage::refresh_apps() +{ + // This optional observation shares the page's single outstanding slot. + if (ipc.busy()) return; + ipc.send("window-rules/list-views", {}, [this] (WcmIpcResult result) { - loading = false; available = false; update_controls(); - error(std::string(e.what()) + "\nDisplays needs the output-control and ipc plugins from the matching Wayfire package."); - apply_button.set_sensitive(false); - } + if (result.error.empty() && result.reply.is_array()) + { + auto selected_app = running_apps.get_active_id(); + running_apps.remove_all(); std::set apps; + auto views = result.reply; + for (size_t i = 0; i < views.size(); ++i) + { + wf::json_t view = views[i]; + if (view["mapped"].as_bool() && view["role"].as_string() == "toplevel") + { + auto app = view["app-id"].as_string(); + if (!app.empty() && apps.insert(app).second) running_apps.append(app, app); + } + } + if (!running_apps.set_active_id(selected_app)) running_apps.set_active(0); + } + if (refresh_pending && get_mapped()) refresh(); + }); } void DisplaysPage::load(const wf::json_t& data) @@ -260,25 +349,11 @@ void DisplaysPage::load(const wf::json_t& data) rule.has_member("scope") && rule["scope"].is_string()) append_rule(rule["app_id"].as_string(), rule["scope"].as_string()); } - running_apps.remove_all(); - try - { - auto views = request("window-rules/list-views"); std::set apps; - for (size_t i = 0; i < views.size(); ++i) - { - wf::json_t view = views[i]; - if (view["mapped"].as_bool() && view["role"].as_string() == "toplevel") - { - auto app = view["app-id"].as_string(); - if (!app.empty() && apps.insert(app).second) running_apps.append(app, app); - } - } - running_apps.set_active(0); - } catch (...) {} // Rules remain editable when ipc-rules is disabled. + refresh_apps(); std::istringstream input(get("core/stitch_outputs")); std::string name; while (input >> name) if (heads.count(name)) order.push_back(name); combined.set_active(get("core/stitch_enabled") == "true"); - gap.set_value(std::stoi(get("grid/zone_gap"))); shift.set_active(get("move/zone_shift") == "true"); + gap.set_value(std::stoi(get("grid/zone_gap"))); shift.set_active(get("grid/zone_shift") == "true"); zone_binding.set_text(get("grid/zone_maximize")); drag_binding.set_text(get("move/activate_zone")); update_defaults(); if (!heads.count(selected)) selected = heads.empty() ? "" : heads.begin()->first; @@ -334,13 +409,14 @@ void DisplaysPage::update_defaults() void DisplaysPage::changed() { if (loading) return; + ++edit_serial; set("core/stitch_enabled", combined.get_active() ? "true" : "false"); set("core/default_output", default_output.get_active_id()); set("core/window_scope", default_scope.get_active_id()); set("grid/use_virtual", virtual_binding.get_text()); set("grid/use_physical", physical_binding.get_text()); set("grid/reset_scope", reset_binding.get_text()); set("grid/next_monitor", next_binding.get_text()); set("grid/zone_gap", std::to_string(gap.get_value_as_int())); - set("move/zone_shift", shift.get_active() ? "true" : "false"); + set("grid/zone_shift", shift.get_active() ? "true" : "false"); set("grid/zone_maximize", zone_binding.get_text()); set("move/activate_zone", drag_binding.get_text()); if (heads.count(selected)) { @@ -348,7 +424,10 @@ void DisplaysPage::changed() set(pre + "masked", role.get_active_id() == "masked" ? "true" : "false"); set(pre + "mode", role.get_active_id() == "off" ? "off" : std::string(mode.get_active_id())); set(pre + "position", position.get_text()); - std::ostringstream numeric; numeric << scale.get_value(); set(pre + "scale", numeric.str()); + if (std::stod(get(pre + "scale")) != scale.get_value()) + { + std::ostringstream numeric; numeric << scale.get_value(); set(pre + "scale", numeric.str()); + } set(pre + "transform", rotation.get_active_id()); set(pre + "zone_gap", std::to_string(local_gap.get_value_as_int())); auto it = std::find(order.begin(), order.end(), selected); @@ -363,12 +442,14 @@ void DisplaysPage::changed() void DisplaysPage::update_controls() { bool preview = !transaction.empty(); + bool locked = preview || mutation_pending || foreign_preview || uncertain; settings_state.set_visible_child("settings"); diagram.set_visible(available && settings_tabs.get_current_page() != 3); - left.set_sensitive(available && !preview); right.set_sensitive(available && !preview); - scope_tab.set_sensitive(available && !preview); refresh_button.set_sensitive(!preview); - identify_button.set_sensitive(available && !heads.empty() && !preview); - keep_button.set_visible(preview); revert_button.set_visible(preview); apply_button.set_visible(!preview); apply_button.set_sensitive(available && dirty && !preview); + left.set_sensitive(available && !locked); right.set_sensitive(available && !locked); + scope_tab.set_sensitive(available && !locked); refresh_button.set_sensitive(!preview && !mutation_pending); + identify_button.set_sensitive(available && !heads.empty() && !locked); + keep_button.set_visible(preview); revert_button.set_visible(preview); apply_button.set_visible(!preview); apply_button.set_sensitive(available && dirty && !locked); + keep_button.set_sensitive(!mutation_pending && !uncertain); revert_button.set_sensitive(!mutation_pending); bool desktop = role.get_active_id() == "desktop"; include.set_sensitive(desktop); local_gap.set_sensitive(desktop && include.get_active()); @@ -381,30 +462,66 @@ void DisplaysPage::update_controls() void DisplaysPage::apply() { - try + if (!available || !dirty || mutation_pending || foreign_preview || uncertain || !transaction.empty()) return; + ipc.cancel_observation(); refresh_pending = discard_pending = false; + wf::json_t options, data; + for (auto& [key, value] : draft) if (!original.count(key) || original.at(key) != value) options[key] = value; + data["options"] = options; data["revision"] = revision; + mutation_pending = true; update_controls(); status.set_text("Waiting for the display preview…"); + auto started = g_get_monotonic_time(); + ipc.send("displays/apply", data, [this, started] (WcmIpcResult result) { - wf::json_t options, data; - for (auto& [key, value] : draft) if (!original.count(key) || original.at(key) != value) options[key] = value; - data["options"] = options; data["revision"] = revision; - auto response = request("displays/apply", data); - transaction = response["token"].as_string(); countdown = response["timeout"].as_int(); - status.set_text("Keep these display settings? They revert automatically in 20 seconds."); update_controls(); - keep_button.grab_focus(); - } catch (const std::exception& e) { error(e.what()); } + mutation_pending = false; + if (!result.error.empty() || !result.reply["token"].is_string() || result.reply["token"].as_string().empty()) + { + uncertain = result.uncertain || result.error.empty(); + update_controls(); + error((result.error.empty() ? "Invalid display preview response" : result.error) + + (uncertain ? ". Outcome unknown; any unconfirmed preview will revert automatically. Refresh to check." : "")); + return; + } + transaction = result.reply["token"].as_string(); + countdown = std::max(0, result.reply["timeout"].as_int() - int((g_get_monotonic_time() - started + 999999) / 1000000)); + if (!get_mapped()) { finish(false); return; } + status.set_text("Keep these display settings? They revert automatically after the preview deadline."); + update_controls(); keep_button.grab_focus(); + }, true, [] (WcmIpcResult result) + { + if (result.reply["token"].is_string() && !result.reply["token"].as_string().empty()) + { + wf::json_t data; data["token"] = result.reply["token"].as_string(); + wcm_ipc_cleanup("displays/revert", data); + } + }); } void DisplaysPage::finish(bool keep) { - try + if (transaction.empty() || mutation_pending) return; + ipc.cancel_observation(); refresh_pending = discard_pending = false; + wf::json_t data; data["token"] = transaction; + transaction.clear(); mutation_pending = true; update_controls(); + status.set_text(keep ? "Waiting for display confirmation…" : "Waiting for display rollback…"); + ipc.send(keep ? "displays/confirm" : "displays/revert", data, [this, keep] (WcmIpcResult result) { - wf::json_t data; data["token"] = transaction; - request(keep ? "displays/confirm" : "displays/revert", data); - transaction.clear(); if (keep) saved(); refresh(true); - status.set_text(keep ? "Display settings saved." : "Previous display settings restored."); - } catch (const std::exception& e) + mutation_pending = false; + if (!result.error.empty()) + { + uncertain = result.uncertain; update_controls(); + error(result.error + (uncertain ? ". Outcome unknown; the request was not repeated. Refresh to check." : "")); + return; + } + uncertain = false; + if (keep) { dirty = false; saved(); } + if (get_mapped()) refresh(keep); + update_controls(); + status.set_text(keep ? "Display settings saved." : "Previous display settings restored; unapplied edits retained."); + }, true, [keep, data] (WcmIpcResult result) { - transaction.clear(); refresh(true); error(e.what()); - } + // An uncertain confirmation may still own a preview. A revert is a + // distinct cleanup action, scoped to its token, never a confirm retry. + if (keep && !result.error.empty()) wcm_ipc_cleanup("displays/revert", data); + }); } bool DisplaysPage::draw(const Cairo::RefPtr& cr) @@ -444,10 +561,15 @@ bool DisplaysPage::draw(const Cairo::RefPtr& cr) void DisplaysPage::identify() { - try + if (ipc.busy() || mutation_pending) return; + identifiers.clear(); identify_timer.disconnect(); + status.set_text("Waiting to identify displays…"); + ipc.send("displays/get", {}, [this] (WcmIpcResult result) { - identifiers.clear(); identify_timer.disconnect(); - auto live = request("displays/get"); wf::json_t desktops = live["desktops"]; + try + { + if (!result.error.empty()) throw std::runtime_error(result.error); + wf::json_t desktops = result.reply["desktops"]; auto display = Gdk::Display::get_default(); for (size_t i = 0; i < desktops.size(); ++i) { @@ -483,7 +605,9 @@ void DisplaysPage::identify() } } identify_timer = Glib::signal_timeout().connect([this] { identifiers.clear(); return false; }, 3000); - } catch (const std::exception& e) { error(e.what()); } + } catch (const std::exception& e) { error(e.what()); } + if (refresh_pending && get_mapped()) refresh(); + }); } diff --git a/src/displays.hpp b/src/displays.hpp index dc5664b..692c6fe 100644 --- a/src/displays.hpp +++ b/src/displays.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include "ipc.hpp" #include #include #include @@ -48,12 +49,17 @@ class DisplaysPage : public Gtk::Box wf::json_t state; std::string revision, transaction, selected, config_file; bool loading = false, dirty = false, available = false; + bool refresh_pending = false, discard_pending = false, mutation_pending = false; + bool foreign_preview = false, uncertain = false; + uint64_t edit_serial = 0, discard_serial = 0; + WcmIpcClient ipc; int countdown = 0; sigc::connection timer, identify_timer; std::vector> identifiers; std::function saved, show_shell, show_zones; - wf::json_t request(const std::string& method, wf::json_t data = {}); + void leave(); + void refresh_apps(); void refresh(bool discard = false); void load(const wf::json_t& data); void select_monitor(); diff --git a/src/ipc.cpp b/src/ipc.cpp new file mode 100644 index 0000000..9414de9 --- /dev/null +++ b/src/ipc.cpp @@ -0,0 +1,198 @@ +#include "ipc.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr size_t max_reply = 4 * 1024 * 1024; +constexpr unsigned deadline_ms = 3000; + +class Request : public std::enable_shared_from_this +{ + int fd = -1; + guint io = 0, timeout = 0, initial = 0; + GApplication *application = nullptr; + std::string path, output, input; + size_t sent = 0, received = 0; + uint32_t length = 0; + bool connecting = false, header = true, done = false; + gint64 deadline = 0; + WcmIpcClient::Callback callback; + + using Ref = std::shared_ptr; + static void release(gpointer p) { delete static_cast(p); } + static void remove(guint& id) { if (id) { auto old = id; id = 0; g_source_remove(old); } } + void watch(GIOCondition condition) + { + remove(io); + io = g_unix_fd_add_full(G_PRIORITY_DEFAULT, fd, condition, [](int, GIOCondition, gpointer p) -> gboolean + { + auto self = *static_cast(p); + try { self->advance(); } + catch (const std::exception& e) { self->finish({{}, e.what(), self->sent != 0}); } + return G_SOURCE_CONTINUE; + }, new Ref(shared_from_this()), release); + } + void connect_socket() + { + if (path.empty()) throw std::runtime_error("Open WCM from the running Wayfire session (WAYFIRE_SOCKET is missing)."); + sockaddr_un addr{}; addr.sun_family = AF_UNIX; + if (path.size() >= sizeof addr.sun_path) throw std::runtime_error("Wayfire socket path is too long"); + std::strcpy(addr.sun_path, path.c_str()); + fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); + if (fd < 0) throw std::runtime_error("Cannot open Wayfire socket"); + if (connect(fd, reinterpret_cast(&addr), sizeof addr)) + { + // AF_UNIX EAGAIN means a full backlog, not a pending connection. + if (errno != EINPROGRESS) throw std::runtime_error("Cannot connect to Wayfire"); + connecting = true; + } + watch(GIOCondition(G_IO_OUT | G_IO_ERR | G_IO_HUP)); + } + void advance() + { + if (g_get_monotonic_time() >= deadline) throw std::runtime_error("Wayfire request timed out"); + if (connecting) + { + int error = 0; socklen_t size = sizeof error; + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &size) || error) + throw std::runtime_error("Cannot connect to Wayfire"); + connecting = false; + } + if (!sent) + { + ucred peer{}; socklen_t size = sizeof peer; + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peer, &size) || peer.uid != getuid()) + throw std::runtime_error("Wayfire socket belongs to another user"); + } + size_t budget = 64 * 1024; // Yield between chunks, even for a fast peer. + while (sent < output.size() && budget) + { + auto n = send(fd, output.data() + sent, std::min(output.size() - sent, budget), MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return; + if (n <= 0) throw std::runtime_error("Wayfire request failed"); + sent += n; budget -= n; + } + if (sent < output.size()) return; + // Replace the write watch once; the read watch must not spin on OUT. + if (input.empty() && header && !received) + { + input.resize(sizeof length); + watch(GIOCondition(G_IO_IN | G_IO_ERR | G_IO_HUP)); + } + while (budget) + { + auto n = recv(fd, input.data() + received, std::min(input.size() - received, budget), 0); + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return; + if (n <= 0) throw std::runtime_error("Wayfire disconnected before replying"); + received += n; budget -= n; + if (received != input.size()) continue; + if (header) + { + std::memcpy(&length, input.data(), sizeof length); length = le32toh(length); + if (!length || length > max_reply) throw std::runtime_error("Invalid Wayfire response size"); + header = false; input.resize(length); received = 0; + } else + { + wf::json_t reply; + if (wf::json_t::parse_string(input, reply) || (!reply.is_object() && !reply.is_array())) + throw std::runtime_error("Invalid Wayfire response"); + if (reply.has_member("error")) + { + if (!reply["error"].is_string()) throw std::runtime_error("Invalid Wayfire error response"); + finish({reply, reply["error"].as_string(), false}); + } else finish({reply, {}, false}); + return; + } + } + } + void finish(WcmIpcResult result) + { + if (done) return; + auto self = shared_from_this(); + done = true; remove(initial); remove(io); remove(timeout); + if (fd >= 0) { close(fd); fd = -1; } + auto completion = std::move(callback); + try { if (completion) completion(std::move(result)); } + catch (const std::exception& e) { g_warning("WCM IPC completion: %s", e.what()); } + if (application) { g_application_release(application); g_object_unref(application); application = nullptr; } + } + public: + static Ref start(const std::string& method, wf::json_t data, WcmIpcClient::Callback callback, bool mutation) + { + auto request = std::make_shared(); + request->callback = std::move(callback); + if (auto path = g_getenv("WAYFIRE_SOCKET")) request->path = path; + wf::json_t message; message["method"] = method; message["data"] = data; + auto bytes = message.serialize(); + uint32_t length = htole32(bytes.size()); + request->output.assign(reinterpret_cast(&length), sizeof length); request->output += bytes; + if (mutation && g_application_get_default()) + { + request->application = G_APPLICATION(g_object_ref(g_application_get_default())); + g_application_hold(request->application); + } + request->deadline = g_get_monotonic_time() + deadline_ms * 1000; + request->timeout = g_timeout_add_full(G_PRIORITY_DEFAULT, deadline_ms, [](gpointer p) -> gboolean + { + auto self = *static_cast(p); self->timeout = 0; + self->finish({{}, "Wayfire request timed out", self->sent != 0}); return G_SOURCE_REMOVE; + }, new Ref(request), release); + request->initial = g_idle_add_full(G_PRIORITY_DEFAULT, [](gpointer p) -> gboolean + { + auto self = *static_cast(p); self->initial = 0; + try { self->connect_socket(); } + catch (const std::exception& e) { self->finish({{}, e.what(), false}); } + return G_SOURCE_REMOVE; + }, new Ref(request), release); + return request; + } + void cancel() { callback = {}; finish({}); } +}; +} + +struct WcmIpcClient::State +{ + std::shared_ptr request; + bool mutation = false; +}; +WcmIpcClient::WcmIpcClient() : state(std::make_shared()) {} +WcmIpcClient::~WcmIpcClient() { cancel_observation(); } +bool WcmIpcClient::busy() const { return bool(state->request); } +void WcmIpcClient::cancel_observation() +{ + if (state->request && !state->mutation) { state->request->cancel(); state->request.reset(); } +} +bool WcmIpcClient::send(const std::string& method, wf::json_t data, Callback callback, bool mutation, Callback orphan) +{ + if (busy()) return false; + state->mutation = mutation; + std::weak_ptr weak = state; + state->request = Request::start(method, data, + [weak, callback = std::move(callback), orphan = std::move(orphan)] (WcmIpcResult result) + { + if (auto current = weak.lock()) + { + current->request.reset(); + if (callback) callback(std::move(result)); + } else if (orphan) orphan(std::move(result)); + }, mutation); + return true; +} +void wcm_ipc_cleanup(const std::string& method, wf::json_t data) +{ + Request::start(method, data, [](WcmIpcResult result) + { + if (!result.error.empty()) g_warning("WCM cleanup: %s", result.error.c_str()); + }, true); +} diff --git a/src/ipc.hpp b/src/ipc.hpp index dc6acec..040292f 100644 --- a/src/ipc.hpp +++ b/src/ipc.hpp @@ -1,59 +1,37 @@ #pragma once #include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include -inline wf::json_t wcm_ipc_request(const std::string& method, wf::json_t data = {}) +struct WcmIpcResult { - auto path = getenv("WAYFIRE_SOCKET"); - if (!path || !*path) throw std::runtime_error("Open WCM from the running Wayfire session (WAYFIRE_SOCKET is missing)."); - sockaddr_un addr{}; addr.sun_family = AF_UNIX; - if (strlen(path) >= sizeof addr.sun_path) throw std::runtime_error("Wayfire socket path is too long"); - strcpy(addr.sun_path, path); - int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); - if (fd < 0) throw std::runtime_error("Cannot open Wayfire socket"); - try - { - timeval timeout{3, 0}; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout); - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof timeout); - if (connect(fd, reinterpret_cast(&addr), sizeof addr)) throw std::runtime_error("Cannot connect to Wayfire"); - ucred peer{}; socklen_t size = sizeof peer; - if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peer, &size) || peer.uid != getuid()) throw std::runtime_error("Wayfire socket belongs to another user"); - wf::json_t message; message["method"] = method; message["data"] = data; - auto bytes = message.serialize(); uint32_t length = htole32(bytes.size()); - auto write_all = [&] (const char *p, size_t n) - { - while (n) - { - auto r = send(fd, p, n, MSG_NOSIGNAL); - if (r < 0 && errno == EINTR) continue; - if (r <= 0) throw std::runtime_error("Wayfire request failed"); - p += r; n -= r; - } - }; - auto read_all = [&] (char *p, size_t n) - { - while (n) - { - auto r = recv(fd, p, n, 0); - if (r < 0 && errno == EINTR) continue; - if (r <= 0) throw std::runtime_error("Wayfire did not respond"); - p += r; n -= r; - } - }; - write_all(reinterpret_cast(&length), sizeof length); write_all(bytes.data(), bytes.size()); - read_all(reinterpret_cast(&length), sizeof length); length = le32toh(length); - if (length > 4 * 1024 * 1024) throw std::runtime_error("Invalid Wayfire response size"); - bytes.resize(length); read_all(bytes.data(), length); - wf::json_t reply; - if (wf::json_t::parse_string(bytes, reply)) throw std::runtime_error("Invalid Wayfire response"); - if (reply.has_member("error")) throw std::runtime_error(reply["error"].as_string()); - close(fd); return reply; - } catch (...) { close(fd); throw; } -} + wf::json_t reply; + std::string error; + // A transport failure after sending bytes cannot establish mutation outcome. + bool uncertain = false; +}; + +// Main-context-only channel. One request at a time; page code coalesces reads. +// Destruction cancels observations. Mutations finish without touching the page, +// then run a value-only orphan callback (for preview/overlay cleanup). +class WcmIpcClient +{ + public: + using Callback = std::function; + WcmIpcClient(); + ~WcmIpcClient(); + WcmIpcClient(const WcmIpcClient&) = delete; + WcmIpcClient& operator=(const WcmIpcClient&) = delete; + bool busy() const; + bool send(const std::string& method, wf::json_t data, Callback callback, + bool mutation = false, Callback orphan = {}); + void cancel_observation(); + private: + struct State; + std::shared_ptr state; +}; + +// A single bounded, best-effort cleanup, never retried. Holds the application +// while its window closes so cleanup can finish without blocking GTK. +void wcm_ipc_cleanup(const std::string& method, wf::json_t data); diff --git a/src/meson.build b/src/meson.build index 9eebcf2..de9b8ff 100644 --- a/src/meson.build +++ b/src/meson.build @@ -10,7 +10,7 @@ layer_shell = dependency('gtk-layer-shell-0') dep_list = [xml, gtkmm, wf_config, wf_protos, evdev, xkbregistry, libintl, libfmt, wf_json, layer_shell, dependency('nlohmann_json'), dependency('threads')] -sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'displays.cpp', 'backgrounds.cpp', 'move-snap.cpp', 'vendor/wf-json.cpp') +sources = files('main.cpp', 'metadata.cpp', 'wcm.cpp', 'utils.cpp', 'ipc.cpp', 'displays.cpp', 'backgrounds.cpp', 'move-snap.cpp', 'vendor/wf-json.cpp') executable(meson.project_name(), sources, install : true, diff --git a/src/metadata.hpp b/src/metadata.hpp index 18056b2..3585fb2 100644 --- a/src/metadata.hpp +++ b/src/metadata.hpp @@ -66,7 +66,7 @@ struct var_data double min; double max; double precision; - hint_type hints; + hint_type hints = static_cast(0); }; using opt_data = std::variant; diff --git a/src/move-snap.cpp b/src/move-snap.cpp index 1eccdef..6e2e305 100644 --- a/src/move-snap.cpp +++ b/src/move-snap.cpp @@ -1,5 +1,6 @@ #include "move-snap.hpp" #include "ipc.hpp" +#include #include #include #include @@ -7,6 +8,27 @@ namespace { +void validate_snap_snapshot(const wf::json_t& wire) +{ + auto data = nlohmann::json::parse(wire.serialize()); + data.at("enabled").get(); + for (auto key : {"edge", "corner", "delay"}) data.at(key).get(); + auto rectangle = [] (const nlohmann::json& r) + { + for (auto key : {"x", "y", "width", "height"}) + if (!std::isfinite(r.at(key).get())) throw std::runtime_error("Invalid snap region geometry"); + }; + if (!data.at("monitors").is_array()) throw std::runtime_error("Invalid snap monitor list"); + for (auto& monitor : data.at("monitors")) + { + monitor.at("name").get(); rectangle(monitor.at("geometry")); + if (!monitor.at("regions").is_array()) throw std::runtime_error("Invalid snap regions"); + for (auto& region : monitor.at("regions")) + { + region.at("slot").get(); rectangle(region.at("geometry")); rectangle(region.at("target")); + } + } +} struct Rect { double x, y, w, h; }; Rect rect(const wf::json_t& j) { @@ -69,37 +91,62 @@ MoveSnapPage::MoveSnapPage(const std::string& config, std::function names; auto monitors = reply["monitors"]; for (size_t i = 0; i < monitors.size(); ++i) names.push_back(monitors[i]["name"].as_string()); if (names != monitor_names) @@ -114,17 +161,26 @@ void MoveSnapPage::update() status.set_text(reply["enabled"].as_bool() ? "Changes save immediately. Regions include panel space and monitor gaps; select a monitor for a closer view." : "Enable Snap to activate drag snapping."); diagram.queue_draw(); - } catch (const std::exception& e) + } catch (const std::exception& e) + { + loading = false; stop_overlay(); + enabled.set_sensitive(available); edge.set_sensitive(available); + corner.set_sensitive(available); delay.set_sensitive(available); overlay.set_sensitive(available); + status.set_text(std::string(e.what()) + " Last observed zones and edits are retained. Enable Move and IPC in the running Wayfire session."); + diagram.queue_draw(); + } + if (refresh_pending && get_mapped() && !ipc.busy()) { refresh_pending = false; update(); } + }, visible, [owner = owner] (WcmIpcResult) { - loading = false; available = false; stop_overlay(); - enabled.set_sensitive(false); edge.set_sensitive(false); corner.set_sensitive(false); delay.set_sensitive(false); overlay.set_sensitive(false); - status.set_text(std::string(e.what()) + " Enable Move and IPC in the running Wayfire session."); diagram.queue_draw(); - } + wf::json_t data; data["owner"] = owner; data["visible"] = false; + wcm_ipc_cleanup("move/snap-regions", data); + }); } void MoveSnapPage::changed() { if (loading || !available) return; + ++edit_serial; delayed_save.disconnect(); delayed_save = Glib::signal_timeout().connect([this] { diff --git a/src/move-snap.hpp b/src/move-snap.hpp index 239076f..26e88e4 100644 --- a/src/move-snap.hpp +++ b/src/move-snap.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include "ipc.hpp" #include class MoveSnapPage : public Gtk::Box @@ -19,6 +20,9 @@ class MoveSnapPage : public Gtk::Box std::function save; sigc::connection poll, delayed_save; bool loading = false, available = false; + bool refresh_pending = false, cleanup_pending = false, overlay_owned = false; + uint64_t edit_serial = 0; + WcmIpcClient ipc; double zoom = 1, dx = 0, dy = 0; wf::json_t target; void update(); diff --git a/src/wcm.cpp b/src/wcm.cpp index 1327c2b..ed998c0 100644 --- a/src/wcm.cpp +++ b/src/wcm.cpp @@ -1517,11 +1517,12 @@ void WCM::set_plugin_enabled(Plugin *plugin, bool enabled) void WCM::create_main_layout() { - window->signal_key_press_event().connect([] (GdkEventKey *event) + window->signal_key_press_event().connect([this] (GdkEventKey *event) { if (event->state & GDK_CONTROL_MASK && (event->keyval == GDK_KEY_q)) { - std::exit(0); + window->close(); + return true; } return false; @@ -1550,7 +1551,7 @@ void WCM::create_main_layout() main_left_panel_layout.pack_start(search_entry, false, false); close_button.property_margin().set_value(10); - close_button.signal_clicked().connect([] { std::exit(0); }); + close_button.signal_clicked().connect([this] { window->close(); }); main_left_panel_layout.pack_end(close_button, false, false); output_config_button.property_margin().set_value(10); diff --git a/src/wcm.hpp b/src/wcm.hpp index ca53963..c7f5eb1 100644 --- a/src/wcm.hpp +++ b/src/wcm.hpp @@ -151,6 +151,7 @@ class OptionWidget : public Gtk::Box std::vector> widgets; Gtk::Button reset_button; sigc::trackable tracker; + WcmIpcClient discovery; guint int_sb_handle; guint animate_sb_handle;