--- /dev/null +++ b/plugins/animate/launch-origin-wrapper.hpp @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT +// SrcUX launch-origin effect; private versioned integration. +#pragma once +#include "animate.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace srcux_launch_origin +{ +struct origin_snapshot +{ + wf::geometry_t rectangle; + wf::output_t *output; + uint64_t serial; // Compositor-local correlation, not an activation token. + // Copied snapshots share only an output-coordinate validity guard. Neither + // token expiry nor creator/source destruction invalidates a target copy. + std::shared_ptr coordinates_valid = nullptr; +}; + +struct target_state +{ + uint64_t epoch = 0; + bool accepting = false; + bool next_map = false; + const void *opening = nullptr; + std::optional candidate; + + void discard() + { + accepting = next_map = false; + candidate.reset(); + } +}; + +struct target_state_data : wf::custom_data_t +{ + std::shared_ptr state = std::make_shared(); +}; + +inline std::shared_ptr get_target_state(wayfire_view view) +{ + return view->get_data_safe()->state; +} + +inline bool offer_origin(wayfire_view view, origin_snapshot snapshot) +{ + auto state = get_target_state(view); + if (view->is_mapped() && !state->accepting) + { + return false; + } + + state->candidate = snapshot; // Value copy, independent of the pending token. + state->next_map = !view->is_mapped(); + return true; +} + +class origin_zoom : public wf::animate::animation_base_t +{ + inline static uint64_t next_id = 0; + wayfire_view view; + origin_snapshot origin; + wf::animation::simple_animation_t progress; + double scale = 1, dx = 0, dy = 0; + wf::geometry_t bounds; + std::string name; + + public: + origin_zoom(origin_snapshot origin, wf::geometry_t bounds) : origin(origin), bounds(bounds) + {} + + void init(wayfire_view view, wf::animation_description_t duration, + wf::animate::animation_type type) override + { + this->view = view; + // The 2D transform pivots at the WM geometry's center, which can + // differ from the center of the rendered decorations/subsurfaces. + auto geometry = wf::toplevel_cast(view)->get_geometry(); + auto box = bounds; + auto r = origin.rectangle; + const double px = geometry.x + geometry.width / 2.0; + const double py = geometry.y + geometry.height / 2.0; + scale = std::min({r.width / box.width, r.height / box.height, 1.0}); + dx = r.x + r.width / 2.0 - px - scale * (box.x + box.width / 2.0 - px); + dy = r.y + r.height / 2.0 - py - scale * (box.y + box.height / 2.0 - py); + progress = wf::animation::simple_animation_t(wf::create_option(duration)); + progress.animate(0, 1); + name = "launch-origin-" + std::to_string(view->get_id()) + "-" + std::to_string(++next_id); + view->get_transformed_node()->add_transformer( + std::make_shared(view), + wf::TRANSFORMER_HIGHLEVEL, name); + } + + bool step() override + { + const double p = progress; + auto tr = view->get_transformed_node()->get_transformer(name); + tr->alpha = p; + tr->scale_x = tr->scale_y = scale + (1 - scale) * p; + tr->translation_x = dx * (1 - p); + tr->translation_y = dy * (1 - p); + return progress.running(); + } + + void reverse() override + { + progress.reverse(); + } + + + ~origin_zoom() + { + if (view) + { + view->get_transformed_node()->rem_transformer(name); + } + } +}; + +class launch_origin_wrapper : public wf::animate::animation_base_t +{ + wf::animate::animate_effects_registry_t *registry; + std::function fallback; + wayfire_view view; + std::shared_ptr state; + std::unique_ptr delegate; + wf::animation_description_t duration; + wf::animate::animation_type type; + std::vector> fallback_transformers; + + std::set transformer_chain() const + { + std::set result; + auto node = view->get_surface_root_node()->parent(); + for (unsigned depth = 0; node && depth < 64; ++depth, node = node->parent()) + { + if (dynamic_cast(node)) result.insert(node); + } + return result; + } + + static bool valid_box(wf::geometry_t box) + { + return std::isfinite(box.x) && std::isfinite(box.y) && + std::isfinite(box.width) && std::isfinite(box.height) && + std::isfinite(box.x + box.width) && std::isfinite(box.y + box.height) && + box.width > 0 && box.height > 0; + } + + std::optional target_bounds() const + { + auto top = wf::toplevel_cast(view); + auto output = view->get_output(); + if (!top || !view->is_mapped() || !output || + output->handle->scale != 1.0 || output->handle->transform != WL_OUTPUT_TRANSFORM_NORMAL) + return {}; + auto geometry = top->get_geometry(); + // This raw node includes decorations and subsurfaces, without the + // unstepped fallback effect that is already installed above it. + auto bounds = view->get_surface_root_node()->get_bounding_box(); + if (!valid_box(geometry) || !valid_box(bounds)) return {}; + auto wset = top->get_wset(); + auto visible = output->get_relative_geometry(); + const double cx = geometry.x + geometry.width / 2.0; + const double cy = geometry.y + geometry.height / 2.0; + // Workspace coordinates are relative to the current workspace. Requiring + // the WM center inside this output also rejects entirely offscreen views + // without the workspace API's clamping to the nearest valid workspace. + if (!wset || wset != output->wset() || cx < 0 || cy < 0 || + cx >= visible.width || cy >= visible.height) return {}; + std::set owned; + for (auto& weak : fallback_transformers) + if (auto node = weak.lock()) owned.insert(node.get()); + + // Target WM geometry and raw bounds must already be output-local. + // Conservatively reject foreign transforms, even at an identity sample, + // and nonidentity ancestry rather than guessing a conversion/pivot. + std::array points{{{bounds.x, bounds.y}, + {bounds.x + bounds.width, bounds.y}, {bounds.x, bounds.y + bounds.height}, + {bounds.x + bounds.width, bounds.y + bounds.height}}}; + auto initial = points; + auto node = view->get_surface_root_node()->parent(); + unsigned depth = 0; + while (node && !dynamic_cast(node) && ++depth <= 64) + { + if (!node->is_enabled()) return {}; + if (!owned.count(node)) + { + if (dynamic_cast(node)) return {}; + for (auto& point : points) + { + auto next = node->to_global(point); + if (!std::isfinite(next.x) || !std::isfinite(next.y) || + std::abs(next.x - point.x) > 0.001 || std::abs(next.y - point.y) > 0.001) + return {}; + point = next; + } + } + node = node->parent(); + } + auto output_node = dynamic_cast(node); + if (!output_node || output_node->get_output() != output || !node->is_enabled()) return {}; + for (size_t i = 0; i < points.size(); ++i) + if (!std::isfinite(points[i].x) || !std::isfinite(points[i].y) || + std::abs(points[i].x - initial[i].x) > 0.001 || + std::abs(points[i].y - initial[i].y) > 0.001) return {}; + return bounds; + } + + public: + bool frozen = false; + bool selected_origin = false; + std::string fallback_name; + + launch_origin_wrapper(wf::animate::animate_effects_registry_t *registry, + std::function fallback) : registry(registry), fallback(std::move(fallback)) + {} + + void init(wayfire_view view, wf::animation_description_t duration, + wf::animate::animation_type type) override + { + this->view = view; + this->duration = duration; + this->type = type; + state = get_target_state(view); + ++state->epoch; + fallback_name = fallback(); // Resolve at init, not plugin startup. + if (fallback_name == "launch-origin") + { + LOGE("Recursive launch-origin fallback; using none"); + fallback_name = "none"; + } else if ((fallback_name != "none") && !registry->effects.count(fallback_name)) + { + LOGE("Unavailable launch-origin fallback: ", fallback_name, "; using none"); + fallback_name = "none"; + } + + if (fallback_name != "none") + { + delegate = registry->effects.at(fallback_name).generator(); + } else + { + delegate = std::make_unique(); + } + + auto before = transformer_chain(); + delegate->init(view, duration, type); + for (auto node : transformer_chain()) + if (!before.count(node)) fallback_transformers.push_back(node->weak_from_this()); + if (type == wf::animate::ANIMATION_TYPE_MAP) + { + if (!state->next_map) + { + state->candidate.reset(); + } + + state->next_map = false; + state->opening = this; + state->accepting = true; + } else + { + frozen = true; + state->discard(); + } + } + + bool step() override + { + if (!frozen) + { + frozen = true; + const auto candidate = state->candidate; + state->discard(); + auto box = target_bounds(); + if (candidate && (!candidate->coordinates_valid || *candidate->coordinates_valid) && + (candidate->output == view->get_output()) && + valid_box(candidate->rectangle) && box) + { + // Remove the unstepped fallback before installing our transformer. + delegate.reset(); + delegate = std::make_unique(*candidate, *box); + delegate->init(view, duration, type); + selected_origin = true; + } + + } + + return delegate->step(); + } + + void reverse() override + { + frozen = true; + state->discard(); + delegate->reverse(); + } + + ~launch_origin_wrapper() + { + if (state && (state->opening == this)) + { + state->opening = nullptr; + state->discard(); + } + + delegate.reset(); + } +}; +} --- /dev/null +++ b/plugins/animate/launch-origin-effect.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +#pragma once +#include "animate-alias.hpp" +#include "launch-origin-wrapper.hpp" +#include "plugins/common/wayfire/plugins/common/shared-core-data.hpp" +#include +#include +#include +#include +#include + +namespace srcux_launch_origin +{ +class launch_origin_effect : public wf::plugin_interface_t +{ + wf::shared_data::ref_ptr_t registry; + wf::shared_data::ref_ptr_t aliases; + wf::option_wrapper_t fallback{"launch-origin/fallback_animation"}; + wf::wl_idle_call bypass_cleanup; + std::vector, uint64_t>> pending_cleanup; + + wf::signal::connection_t effect_mapped = [this] (auto *ev) + { + // Wait until all map listeners have run. A late bridge listener can + // stage a target even when animate chose an effect other than ours. + auto state = get_target_state(ev->view); + pending_cleanup.emplace_back(state, state->epoch); + bypass_cleanup.run_once(); + }; + wf::signal::connection_t effect_unmapped = [this] (auto *ev) + { + auto state = get_target_state(ev->view); + ++state->epoch; + state->discard(); + }; + + public: + void init() override + { + registry->register_effect("launch-origin", { + .generator = [this] { return std::make_unique(registry.get(), + [this] { return fallback.value(); }); }, + .default_duration = [this] () -> std::optional + { + const auto name = fallback.value(); + if (name == "launch-origin" || name == "none") return std::nullopt; + const auto found = registry->effects.find(name); + return found == registry->effects.end() ? std::nullopt : found->second.default_duration(); + }, + }); + aliases->aliases["launch-origin"] = [this] { return fallback.value(); }; + bypass_cleanup.set_callback([this] + { + for (auto& [weak, epoch] : pending_cleanup) + if (auto state = weak.lock(); state && state->epoch == epoch && !state->accepting) + state->discard(); + pending_cleanup.clear(); + }); + wf::get_core().connect(&effect_mapped); + wf::get_core().connect(&effect_unmapped); + } + + bool is_unloadable() override { return false; } + + void fini() override + { + effect_mapped.disconnect(); + effect_unmapped.disconnect(); + bypass_cleanup.disconnect(); + pending_cleanup.clear(); + wf::animate::animate_effect_cleanup_signal cleanup{"launch-origin"}; + wf::get_core().emit(&cleanup); + aliases->aliases.erase("launch-origin"); + registry->unregister_effect("launch-origin"); + } +}; +} --- /dev/null +++ b/plugins/protocols/launch-origin.cpp @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MIT +// Authenticated origins for native Wayland panel launches. +#include "srcux-launch-origin-v1-protocol.h" +#include "xdg-activation.hpp" +#include "../animate/launch-origin-effect.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace srcux_launch_origin; + +class launch_origin_plugin : public launch_origin_effect +{ + struct entry + { + std::string name; + wl_client *creator; + bool written = false; + std::optional origin; + uint64_t submitted = 0; + }; + std::map tokens; + std::map> coordinate_guards; + wl_global *global = nullptr; + std::set resources; + uint64_t next_submission = 0, next_target = 0; + + // An unresolved target is owned by this table, not the consumed token. Its + // surface destroy listener bounds its lifetime even if it never gets a role. + struct pending_target + { + struct hook { wl_listener listener; pending_target *target; } destroy; + launch_origin_plugin *owner; + wlr_surface *surface; + origin_snapshot origin; + uint64_t order; + pending_target(launch_origin_plugin *owner, wlr_surface *surface, origin_snapshot origin) : + owner(owner), surface(surface), origin(origin), order(++owner->next_target) + { + destroy.target = this; + destroy.listener.notify = [] (wl_listener *listener, void*) + { + auto self = reinterpret_cast(listener)->target; + auto owner = self->owner; + + owner->targets.erase(self->surface); + }; + wl_signal_add(&surface->events.destroy, &destroy.listener); + } + ~pending_target() { wl_list_remove(&destroy.listener.link); } + }; + std::map> targets; + + std::optional snapshot(wlr_surface *surface, uint64_t id, + int32_t x, int32_t y, int32_t width, int32_t height) + { + auto view = wf::wl_surface_to_wayfire_view(surface->resource); + const bool role = wlr_xdg_toplevel_try_from_wlr_surface(surface) || + wlr_layer_surface_v1_try_from_wlr_surface(surface); + if (!role || !view || !view->is_mapped() || view->get_wlr_surface() != surface || + !view->get_output() || x < 0 || y < 0 || width <= 0 || height <= 0 || + int64_t(x) + width > surface->current.width || int64_t(y) + height > surface->current.height) + { + return std::nullopt; + } + + auto output = view->get_output(); + if (output->handle->transform != WL_OUTPUT_TRANSFORM_NORMAL || output->handle->scale != 1.0) + { + return std::nullopt; + } + + // Walk all ancestors up to, but excluding, the output node. Its children + // already use output-local coordinates: never subtract layout origin. + std::array points{{{double(x), double(y)}, {double(x + width), double(y)}, + {double(x), double(y + height)}, {double(x + width), double(y + height)}}}; + wf::scene::node_t *node = view->get_surface_root_node().get(); + unsigned depth = 0; + while (node && !dynamic_cast(node) && ++depth <= 64) + { + // Only translation/no-op ancestry is supported in version 1. + // Reject active effect/rotation/scale transformers, including those + // whose current sample happens to look like identity. + if (dynamic_cast(node)) + { + return std::nullopt; + } + for (auto& point : points) + { + point = node->to_global(point); + } + node = node->parent(); + } + + auto output_node = dynamic_cast(node); + auto near = [] (double a, double b) { return std::isfinite(a) && std::abs(a - b) < 0.001; }; + if (!output_node || output_node->get_output() != output || + !near(points[1].x - points[0].x, width) || !near(points[2].y - points[0].y, height) || + !near(points[1].y, points[0].y) || !near(points[2].x, points[0].x) || + !near(points[3].x, points[1].x) || !near(points[3].y, points[2].y)) + { + return std::nullopt; + } + + auto box = output->get_relative_geometry(); + if (points[0].x < 0 || points[0].y < 0 || points[3].x > box.width || points[3].y > box.height) + { + return std::nullopt; + } + auto& guard = coordinate_guards[output]; + if (!guard) guard = std::make_shared(true); + origin_snapshot result{{points[0].x, points[0].y, double(width), double(height)}, output, id, guard}; + return result; + } + + void set_origin(wl_client *client, const char *name, wl_resource *source, + int32_t x, int32_t y, int32_t width, int32_t height) + { + auto found = std::find_if(tokens.begin(), tokens.end(), [&] (auto& item) + { return item.second.name == name && item.second.creator == client; }); + // Both unknown and another creator's token take the same silent path. + if (found == tokens.end() || found->second.written || wl_resource_get_client(source) != client) + { + + return; + } + auto origin = snapshot(wlr_surface_from_resource(source), found->first, x, y, width, height); + if (!origin) + { + + return; + } + auto evict_oldest = [&] (wl_client *owner) + { + auto item = tokens.end(); + for (auto it = tokens.begin(); it != tokens.end(); ++it) + { + if (it->second.origin && (!owner || it->second.creator == owner) && + (item == tokens.end() || it->second.submitted < item->second.submitted)) item = it; + } + if (item != tokens.end()) + { + item->second.origin.reset(); + + } + }; + size_t per_client = 0, total = 0; + for (auto& [id, token] : tokens) + { + if (token.origin) { ++total; per_client += token.creator == client; } + } + if (per_client >= 8) { evict_oldest(client); --total; } + if (total >= 64) evict_oldest(nullptr); + found->second.written = true; + found->second.submitted = ++next_submission; + found->second.origin = *origin; + + } + + static void bind(wl_client *client, void *data, uint32_t, uint32_t id) + { + static const struct srcux_launch_origin_manager_v1_interface impl = { + .destroy = [] (wl_client*, wl_resource *resource) { wl_resource_destroy(resource); }, + .set_origin = [] (wl_client *client, wl_resource *resource, const char *token, + wl_resource *surface, int32_t x, int32_t y, int32_t width, int32_t height) + { + static_cast(wl_resource_get_user_data(resource))-> + set_origin(client, token, surface, x, y, width, height); + }, + }; + auto resource = wl_resource_create(client, &srcux_launch_origin_manager_v1_interface, 1, id); + if (!resource) { wl_client_post_no_memory(client); return; } + wl_resource_set_implementation(resource, &impl, data, [] (wl_resource *resource) + { + static_cast(wl_resource_get_user_data(resource))->resources.erase(resource); + }); + static_cast(data)->resources.insert(resource); + } + + wf::signal::connection_t created = [this] (auto *ev) + { + tokens.emplace(ev->id, entry{ev->token, ev->creator, false, std::nullopt, 0}); + }; + wf::signal::connection_t destroyed = [this] (auto *ev) + { + if (auto it = tokens.find(ev->id); it != tokens.end()) + { + + tokens.erase(it); + } + }; + wf::signal::connection_t disconnected = [this] (auto *ev) + { + if (auto it = tokens.find(ev->id); it != tokens.end()) + { + it->second.creator = nullptr; + + it->second.origin.reset(); + } + }; + wf::signal::connection_t activated = [this] (auto *ev) + { + auto it = tokens.find(ev->id); + if (it == tokens.end() || !ev->creator || it->second.creator != ev->creator || !it->second.origin) + return; + auto origin = *it->second.origin; // copy before the synchronous token destroy + if (!*origin.coordinates_valid) return; + if (ev->view) + { + const bool accepted = wf::toplevel_cast(ev->view) && offer_origin(ev->view, origin); + + } else + { + if (targets.size() >= 64) + { + auto oldest = std::min_element(targets.begin(), targets.end(), [] (auto& a, auto& b) + { return a.second->order < b.second->order; }); + targets.erase(oldest); + } + targets.erase(ev->target); + targets.emplace(ev->target, std::make_unique(this, ev->target, origin)); + + } + }; + wf::signal::connection_t mapped = [this] (auto *ev) + { + auto it = targets.find(ev->view->get_wlr_surface()); + if (it == targets.end()) return; + auto origin = it->second->origin; + targets.erase(it); + auto state = get_target_state(ev->view); + bool accepted = false; + if (wf::toplevel_cast(ev->view) && *origin.coordinates_valid) + { + if (state->accepting) accepted = offer_origin(ev->view, origin); + else if (!state->opening) + { + // If animate's core listener has not run, stage this same map's + // snapshot. The wrapper consumes next_map; post-listener idle + // cleanup discards it when another effect bypasses the wrapper. + state->candidate = origin; + state->next_map = true; + accepted = true; + } + } + + }; + wf::signal::connection_t output_removed = [this] (auto *ev) + { + auto it = coordinate_guards.find(ev->output); + if (it != coordinate_guards.end()) { *it->second = false; coordinate_guards.erase(it); } + }; + wf::signal::connection_t layout_changed = [this] (auto*) + { + for (auto& [output, guard] : coordinate_guards) *guard = false; + coordinate_guards.clear(); + }; + + public: + void init() override + { + launch_origin_effect::init(); + auto& core = wf::get_core(); + core.connect(&created); core.connect(&destroyed); core.connect(&disconnected); + core.connect(&activated); core.connect(&mapped); + core.output_layout->connect(&output_removed); + core.output_layout->connect(&layout_changed); + global = wl_global_create(core.display, &srcux_launch_origin_manager_v1_interface, 1, this, bind); + if (!global) throw std::runtime_error("launch-origin: cannot create protocol global"); + } + bool is_unloadable() override { return false; } + void fini() override + { + if (global) wl_global_destroy(global); + while (!resources.empty()) wl_resource_destroy(*resources.begin()); + created.disconnect(); destroyed.disconnect(); disconnected.disconnect(); + activated.disconnect(); mapped.disconnect(); output_removed.disconnect(); layout_changed.disconnect(); + for (auto& [output, guard] : coordinate_guards) *guard = false; + targets.clear(); tokens.clear(); coordinate_guards.clear(); + launch_origin_effect::fini(); + } +}; +DECLARE_WAYFIRE_PLUGIN(launch_origin_plugin); --- a/plugins/protocols/meson.build +++ b/plugins/protocols/meson.build @@ -1,5 +1,6 @@ protocol_plugins = [ 'ext-toplevel', 'foreign-toplevel', 'gtk-shell', 'wayfire-shell', 'xdg-activation', 'shortcuts-inhibit', + 'launch-origin', 'input-method-v1', 'session-lock', 'security-context-v1', 'kde-appmenu', ] --- /dev/null +++ b/proto/srcux-launch-origin-v1.xml @@ -0,0 +1,23 @@ + + + Copyright 2026. SPDX-License-Identifier: MIT + + + This is a private SrcUX version 1 interface, not an upstream protocol. Only the retained + creator of a committed activation token can submit its first origin. + Unknown and unauthorized tokens are silently ignored. The rectangle uses + logical coordinates of an owned, mapped xdg-toplevel or layer surface. + Submission snapshots geometry immediately; moving or destroying the source + later does not change it. No acknowledgement or token information is sent. + + + + + + + + + + + + --- a/proto/meson.build +++ b/proto/meson.build @@ -15,6 +15,7 @@ ) server_protocols = [ + 'srcux-launch-origin-v1.xml', [wl_protocol_dir, 'stable/xdg-shell/xdg-shell.xml'], [wl_protocol_dir, 'unstable/linux-dmabuf/linux-dmabuf-unstable-v1.xml'], [wl_protocol_dir, 'unstable/xdg-shell/xdg-shell-unstable-v6.xml'], --- /dev/null +++ b/metadata/launch-origin.xml @@ -0,0 +1,13 @@ + + + + <_short>Launch origin + <_long>Open eligible native Wayland windows from their authenticated panel icon. + Effects + + + --- a/metadata/meson.build +++ b/metadata/meson.build @@ -49,3 +49,5 @@ devenv = environment() devenv.append('WAYFIRE_PLUGIN_XML_PATH', meson.current_source_dir()) meson.add_devenv(devenv) + +install_data('launch-origin.xml', install_dir: conf_data.get('PLUGIN_XML_DIR'))