server/src/remote_session.cpp
Rama main · réplica de lectura del repositorio original.
#include "remote_session.h"
#include <algorithm>
#include <cctype>
#include <iterator>
#include <mutex>
#include <unordered_set>
namespace remote_session {
namespace {
std::mutex monitor_runtime_hooks_mutex;
monitor_runtime_hooks_t monitor_runtime_hooks;
// Moonlight refreshes the app catalogue after the first synthetic launch
// failure before the user can launch Terminate again. That round trip can
// exceed ten seconds on mobile clients even when the second launch is
// immediate from the user's perspective.
constexpr auto terminate_confirmation_window = std::chrono::seconds {60};
struct terminate_confirmation_t {
std::uint64_t generation {};
std::int32_t app_id {};
std::chrono::steady_clock::time_point expires_at {};
};
std::mutex terminate_confirmation_mutex;
std::unordered_map<std::string, terminate_confirmation_t> terminate_confirmations;
struct app_replacement_confirmation_t {
std::uint64_t generation {};
std::int32_t requested_app_id {};
std::chrono::steady_clock::time_point expires_at {};
};
std::mutex app_replacement_confirmation_mutex;
std::unordered_map<std::string, app_replacement_confirmation_t> app_replacement_confirmations;
bool equal_folded(std::string_view left, std::string_view right) {
if (left.size() != right.size()) return false;
for (std::size_t i = 0; i < left.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(left[i])) != std::tolower(static_cast<unsigned char>(right[i]))) return false;
}
return true;
}
bool contains(const std::vector<std::string> &values, std::string_view value) {
return std::find(values.begin(), values.end(), value) != values.end();
}
bool reserved_synthetic_id(const std::int32_t id) {
return (id >= resume_id && id <= running_game_id) ||
id == secondary_resume_id || id == secondary_terminate_id ||
id == secondary_monitor_id || id == secondary_input_id ||
(id >= 2147483601 && id <= 2147483606);
}
std::string ranked_title(const std::string_view rank, const std::string_view title) {
return std::string {rank} + std::string {title};
}
app_t prioritized_secondary_control(const control_e control) {
auto app = synthetic(control);
switch (control) {
case control_e::monitor: app.id = secondary_monitor_id; app.title = ranked_title(" ", app.title); break;
case control_e::input: app.id = secondary_input_id; app.title = ranked_title(" ", app.title); break;
case control_e::resume: app.id = secondary_resume_id; app.title = ranked_title(" ", app.title); break;
case control_e::terminate: app.id = secondary_terminate_id; app.title = ranked_title(" ", app.title); break;
default: break;
}
return app;
}
bool owns_game(const caller_t &caller, const game_t &game) { return game.running && caller.paired && caller.uuid == game.owner_uuid; }
}
bool reserved_name(const std::string_view name) {
const auto first_visible = name.find_first_not_of(" \t\r\n");
const auto visible_name = first_visible == std::string_view::npos ? std::string_view {} : name.substr(first_visible);
return equal_folded(visible_name, "Remote Input") || equal_folded(visible_name, "Remote Monitor") || equal_folded(visible_name, "Virtual Display") || equal_folded(visible_name, "Terminate");
}
control_e identify(const std::int32_t id, const std::string_view uuid) {
if (id == secondary_resume_id) return control_e::resume;
if (id == secondary_terminate_id) return control_e::terminate;
if (id == secondary_monitor_id) return control_e::monitor;
if (id == secondary_input_id) return control_e::input;
if (id == resume_id || id == 2147483601 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000001") return control_e::resume;
if (id == disconnect_monitor_id || id == 2147483602 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000002") return control_e::disconnect_monitor;
if (id == disconnect_input_id || id == 2147483603 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000003") return control_e::disconnect_input;
if (id == terminate_id || id == 2147483604 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000004") return control_e::terminate;
if (id == monitor_id || id == 2147483605 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000005") return control_e::monitor;
if (id == input_id || id == 2147483606 || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000006") return control_e::input;
if (id == running_game_id || uuid == "9a1c5a25-58fe-40e0-b9aa-7d3f00000007") return control_e::running_game;
return control_e::none;
}
control_e identify(const std::int32_t id, const std::string_view uuid, const std::int32_t running_app_id) {
const auto control = identify(id, uuid);
if (control != control_e::none) {
return control;
}
return running_app_id > 0 && id == synthetic_running_game_id(running_app_id) ?
control_e::running_game :
control_e::none;
}
std::string synthetic_uuid(const control_e control) {
const auto suffix = static_cast<unsigned>(control);
return suffix >= 1 && suffix <= 7 ? "9a1c5a25-58fe-40e0-b9aa-7d3f0000000" + std::to_string(suffix) : std::string {};
}
app_t synthetic(const control_e control) {
switch (control) {
case control_e::resume: return {resume_id, synthetic_uuid(control), "Resume", true};
case control_e::disconnect_monitor: return {disconnect_monitor_id, synthetic_uuid(control), "Disconnect Monitor", true};
case control_e::disconnect_input: return {disconnect_input_id, synthetic_uuid(control), "Disconnect Input", true};
case control_e::terminate: return {terminate_id, synthetic_uuid(control), "Terminate", true};
case control_e::monitor: return {monitor_id, synthetic_uuid(control), "Remote Monitor", true};
case control_e::input: return {input_id, synthetic_uuid(control), "Remote Input", true};
default: return {};
}
}
std::int32_t synthetic_running_game_id(const std::int32_t app_id) {
// Toggle a high value bit so the resume-only identity remains stable for
// one app/art revision but changes with the configured app identity. Skip
// control IDs so the contextual running-game check cannot be shadowed by
// an ordinary synthetic-control lookup.
constexpr std::uint32_t positive_id_mask = 0x7fffffffU;
constexpr std::uint32_t identity_salt = 0x40000000U;
const auto source = static_cast<std::uint32_t>(app_id) & positive_id_mask;
auto candidate = (source ^ identity_salt) & positive_id_mask;
while (candidate == 0 || candidate == source || reserved_synthetic_id(static_cast<std::int32_t>(candidate))) {
candidate = candidate == positive_id_mask ? 1 : candidate + 1;
}
return static_cast<std::int32_t>(candidate);
}
app_t synthetic_running_game(const app_t &game) {
// Moonlight clients commonly alphabetize the received catalogue. Ranked
// leading spaces keep this resume-only duplicate first, followed by
// Remote Monitor, Remote Input, Resume, and Terminate, without changing
// visible labels.
return {
synthetic_running_game_id(game.id),
synthetic_uuid(control_e::running_game),
ranked_title(" ", game.title),
true,
};
}
std::optional<std::string_view> synthetic_artwork_filename(const control_e control) {
switch (control) {
case control_e::resume: return "resume.png";
case control_e::disconnect_monitor: return "disconnect-remote-monitor.png";
case control_e::disconnect_input: return "disconnect-remote-input.png";
case control_e::terminate: return "terminate.png";
case control_e::monitor: return "remote-monitor.png";
case control_e::input: return "remote-input.png";
default: return std::nullopt;
}
}
bool exposes_active_game(
const caller_t &caller,
const game_t &game,
const owner_t &owner,
const bool remote_sessions_active,
const bool replacement_confirmation_active
) {
if (!game.running) return false;
if (replacement_confirmation_active) return true;
// Any paired client that may view the host is told what the host is actually doing.
//
// Upstream 2.0.0 restricted this to the session's own owner, which left every OTHER
// ArtMoon looking at a host that claimed to be free while it was mid-stream: no
// running-app card, no STREAMING badge, no Resume/Stop — on a session the host would
// happily hand over. Nik's rule (2026-10-10): any device looking at that PC sees what
// is happening on it. What an active game is doing is a fact about the HOST, not about
// the caller, so it is not the owner's private information.
//
// Kept unexposed: no game running, a caller without view permission, and a caller holding
// a role that is IN the session it would be told about.
//
// A retained Remote Monitor is the exception, and it is not a small one: the monitor is a
// second session beside the game, so its holder is exactly the person most likely to be
// looking at the list while something runs. Excluding it meant the badge vanished at the
// moment the second screen came up - the one row you needed to read, with no way to read
// it. The monitor owns no part of the game (that is what role_e::game is), so telling it
// what the game is doing leaks nothing it cannot already see: the whole catalogue, and
// Resume.
if (owner.role != role_e::none && owner.role != role_e::monitor) return false;
return caller.paired && caller.may_view;
}
bool allows_normal_game_cancel(const caller_t &caller, const game_t &game, const bool remote_sessions_active) {
return game.running && caller.paired && caller.may_terminate && (!remote_sessions_active || owns_game(caller, game));
}
projection_t project(const caller_t &caller, const game_t &game, const owner_t &owner, const std::vector<app_t> &configured, const bool remote_sessions_active) {
projection_t result;
std::vector<app_t> visible_configured;
std::copy_if(configured.begin(), configured.end(), std::back_inserter(visible_configured), [](const app_t &app) {
const auto control = identify(0, app.uuid);
return control != control_e::input && control != control_e::monitor && !reserved_name(app.title);
});
if (owner.role == role_e::monitor) {
// Holding a Remote Monitor used to collapse the whole catalogue to these two entries. That
// was honest while leaving a monitor ended its role: the collapse lasted exactly as long as
// the second screen was streaming. Leaving no longer ends the role (4 Nov 2026), so the
// collapse outlived the stream, and for as long as the second screen existed the client saw
// nothing but Resume and Disconnect Monitor - no way back to a game, and no way to start one.
//
// The monitor's own two controls go on the front; under them the caller sees exactly the list
// it would see holding no role at all. The game and the monitor are separate sessions, and
// running one while the other is up is the point of the feature.
//
// A catalogue that already carries a resume entry keeps it: two resume tiles on one screen
// would differ only by which stream they reopen, which is not something a list can say.
auto as_if_idle = owner;
as_if_idle.role = role_e::none;
result = project(caller, game, as_if_idle, configured, remote_sessions_active);
// Remote Input is refused while this client holds a role (dispatch allows it only from
// role none), so offering the tile would be a button that cannot act.
result.catalogue.erase(
std::remove_if(result.catalogue.begin(), result.catalogue.end(), [](const app_t &entry) {
return identify(entry.id, entry.uuid) == control_e::input;
}),
result.catalogue.end()
);
const auto already_resumable = std::any_of(result.catalogue.begin(), result.catalogue.end(), [](const app_t &entry) {
const auto control = identify(entry.id, entry.uuid);
return control == control_e::resume || control == control_e::running_game;
});
result.catalogue.insert(result.catalogue.begin(), synthetic(control_e::disconnect_monitor));
if (!already_resumable) result.catalogue.insert(result.catalogue.begin(), synthetic(control_e::resume));
return result;
}
if (!game.running && !remote_sessions_active && owner.role == role_e::none) {
result.catalogue = std::move(visible_configured);
result.catalogue.push_back(synthetic(control_e::input));
result.catalogue.push_back(synthetic(control_e::monitor));
return result;
}
if (owns_game(caller, game)) {
result.free = false;
result.current_game = game.app.id;
result.catalogue = visible_configured;
if (owner.role != role_e::input) result.catalogue.push_back(synthetic(control_e::input));
result.catalogue.push_back(synthetic(control_e::monitor));
return result;
}
if (game.running) {
// Secondary clients are told the host is free so they can choose any
// configured app. Keep that complete catalogue, but add a distinct,
// resume-only copy of the active game at the front. The configured copy
// remains available under its normal identity.
//
// `free` here is a statement about THIS CALLER's launchability, not about the host
// being idle: a secondary client may launch any configured app, and the host's
// takeover flow decides what happens to the running session. What the host is
// actually doing goes in serverinfo via exposes_active_game(). Do not conflate them.
result.catalogue = {
synthetic_running_game(game.app),
prioritized_secondary_control(control_e::resume),
prioritized_secondary_control(control_e::terminate),
};
result.catalogue.insert(result.catalogue.end(), visible_configured.begin(), visible_configured.end());
if (owner.role != role_e::input) result.catalogue.push_back(prioritized_secondary_control(control_e::input));
result.catalogue.push_back(prioritized_secondary_control(control_e::monitor));
return result;
}
result.catalogue = visible_configured;
if (owner.role != role_e::input) result.catalogue.push_back(synthetic(control_e::input));
result.catalogue.push_back(synthetic(control_e::monitor));
return result;
}
dispatch_t dispatch(const caller_t &caller, const game_t &game, const owner_t &owner, const control_e control) {
dispatch_t result {.control = control};
switch (control) {
case control_e::resume:
case control_e::running_game:
result.permission = permission_e::view;
result.allowed = caller.may_view && (game.running || owner.role == role_e::monitor);
result.resume = result.allowed;
if (result.allowed) {
result.resume_role = owner.role == role_e::monitor ? role_e::monitor : role_e::game;
}
break;
case control_e::input:
result.permission = permission_e::launch;
result.allowed = caller.may_launch && owner.role == role_e::none;
break;
case control_e::monitor:
result.permission = permission_e::launch;
// Moonlight may retry a slow /launch request or invoke a cached Remote
// Monitor tile after ownership has already been published. For the
// same paired client this is an idempotent activation retry, not a
// permission failure. Another client's owner is never passed here.
result.allowed = caller.may_launch && (owner.role == role_e::none || owner.role == role_e::monitor);
result.resume = result.allowed && owner.role == role_e::monitor;
if (result.resume) result.resume_role = role_e::monitor;
break;
case control_e::terminate:
result.permission = permission_e::terminate;
result.allowed = caller.may_terminate && game.running;
result.terminate = result.allowed;
break;
case control_e::disconnect_monitor:
result.permission = permission_e::terminate;
result.allowed = caller.may_terminate && (owner.role == role_e::monitor || owner.role == role_e::none);
result.already_complete = result.allowed && owner.role == role_e::none;
break;
case control_e::disconnect_input:
result.permission = permission_e::terminate;
result.allowed = caller.may_terminate && (owner.role == role_e::input || owner.role == role_e::none);
result.already_complete = result.allowed && owner.role == role_e::none;
break;
default: break;
}
return result;
}
bool joins_existing_game_output(
const role_e role,
const bool stream_active,
const bool retained_output_ready
) {
return role == role_e::game && (stream_active || retained_output_ready);
}
bool is_secondary_game_client(const std::string_view app_owner_uuid, const std::string_view caller_uuid) {
return !app_owner_uuid.empty() && app_owner_uuid != caller_uuid;
}
std::string_view stream_start_response_key(const bool launched_from_applist) {
return launched_from_applist ? "gamesession" : "resume";
}
std::optional<control_completion_t> successful_control_completion(const control_e control) {
// These entries are host controls presented through Moonlight's launch UI,
// not streamable applications. Apollo established 410 as the deliberate
// launch failure that completes the action and displays its message instead
// of making Moonlight wait for a nonexistent RTSP session URL.
switch (control) {
case control_e::disconnect_monitor: return control_completion_t {410, "Remote Monitor disconnected successfully."};
case control_e::disconnect_input: return control_completion_t {410, "Remote Input disconnected successfully."};
case control_e::terminate: return control_completion_t {410, "Active stream terminated. Remote Monitor and Remote Input remain connected."};
default: return std::nullopt;
}
}
bool requires_termination_confirmation(const bool terminate_on_first_request, const bool caller_owns_active_game) {
// The original game client is not affected by the extra-client safety
// guard. The setting only opts secondary clients into the one-request path.
return !terminate_on_first_request && !caller_owns_active_game;
}
terminate_confirmation_e arm_or_confirm_termination(
const std::string_view client_uuid,
const std::uint64_t generation,
const std::int32_t app_id,
const std::chrono::steady_clock::time_point now
) {
std::lock_guard lock {terminate_confirmation_mutex};
const auto it = terminate_confirmations.find(std::string {client_uuid});
if (it != terminate_confirmations.end()) {
const auto pending = it->second;
terminate_confirmations.erase(it);
if (pending.generation == generation && pending.app_id == app_id && pending.expires_at > now) {
return terminate_confirmation_e::confirmed;
}
}
terminate_confirmations.insert_or_assign(
std::string {client_uuid},
terminate_confirmation_t {generation, app_id, now + terminate_confirmation_window}
);
return terminate_confirmation_e::prompt;
}
std::string_view termination_confirmation_message() {
return "This will close the active stream but leave Remote Monitor and Remote Input connected. Launch Terminate again within 60 seconds to confirm this was intentional.";
}
void clear_termination_confirmation(const std::string_view client_uuid) {
std::lock_guard lock {terminate_confirmation_mutex};
terminate_confirmations.erase(std::string {client_uuid});
}
app_replacement_confirmation_e arm_or_confirm_app_replacement(
const std::string_view client_uuid,
const std::uint64_t generation,
const std::int32_t requested_app_id,
const std::chrono::steady_clock::time_point now
) {
std::lock_guard lock {app_replacement_confirmation_mutex};
const auto it = app_replacement_confirmations.find(std::string {client_uuid});
if (it != app_replacement_confirmations.end()) {
const auto pending = it->second;
app_replacement_confirmations.erase(it);
if (pending.generation == generation &&
pending.requested_app_id == requested_app_id &&
pending.expires_at > now) {
return app_replacement_confirmation_e::confirmed;
}
}
app_replacement_confirmations.insert_or_assign(
std::string {client_uuid},
app_replacement_confirmation_t {generation, requested_app_id, now + terminate_confirmation_window}
);
return app_replacement_confirmation_e::prompt;
}
bool app_replacement_confirmation_active(
const std::string_view client_uuid,
const std::uint64_t generation,
const std::chrono::steady_clock::time_point now
) {
std::lock_guard lock {app_replacement_confirmation_mutex};
const auto it = app_replacement_confirmations.find(std::string {client_uuid});
if (it == app_replacement_confirmations.end()) return false;
if (it->second.generation == generation && it->second.expires_at > now) return true;
app_replacement_confirmations.erase(it);
return false;
}
std::string_view app_replacement_confirmation_message() {
return "An app is already running. Launch this app again within 60 seconds to confirm that you want to close it.";
}
void clear_app_replacement_confirmation(const std::string_view client_uuid) {
std::lock_guard lock {app_replacement_confirmation_mutex};
app_replacement_confirmations.erase(std::string {client_uuid});
}
bool input_uses_display_or_audio(const role_e role) { return role != role_e::input; }
bool uses_audio(const role_e role, const bool mute_remote_monitor) {
return role != role_e::input && !(role == role_e::monitor && mute_remote_monitor);
}
bool uses_host_audio(const role_e role) { return role == role_e::monitor; }
bool disconnect_monitor_after_stream(
const bool disconnect_on_stream_end,
const bool disconnect_on_client_disconnect,
const bool client_disconnected
) {
return disconnect_on_stream_end || (disconnect_on_client_disconnect && client_disconnected);
}
capture_plan_t capture_plan(const role_e role, std::optional<std::string> output) {
if (role == role_e::input) {
return {.source = capture_source_e::synthetic_black};
}
if (role == role_e::monitor) {
if (!output || output->empty()) {
return {.source = capture_source_e::invalid};
}
return {.source = capture_source_e::exact_output, .output = std::move(output)};
}
return {.source = capture_source_e::active_output};
}
void register_monitor_runtime_hooks(monitor_runtime_hooks_t hooks) {
std::lock_guard lock {monitor_runtime_hooks_mutex};
monitor_runtime_hooks = std::move(hooks);
}
monitor_runtime_state_t activate_or_resume_monitor(const std::string_view client_uuid, const std::string_view client_label, const std::string_view requested_mode, const bool hdr_requested, const std::uint64_t generation) {
std::function<monitor_runtime_state_t(std::string_view, std::string_view, std::string_view, bool, std::uint64_t)> activate;
{
std::lock_guard lock {monitor_runtime_hooks_mutex};
activate = monitor_runtime_hooks.activate_or_resume;
}
return activate ? activate(client_uuid, client_label, requested_mode, hdr_requested, generation) : monitor_runtime_state_t {.retryable = true, .error = "Remote Monitor topology is not ready."};
}
monitor_runtime_state_t monitor_runtime_snapshot(const std::string_view client_uuid, const std::uint64_t generation) {
std::function<monitor_runtime_state_t(std::string_view, std::uint64_t)> snapshot;
{
std::lock_guard lock {monitor_runtime_hooks_mutex};
snapshot = monitor_runtime_hooks.snapshot;
}
return snapshot ? snapshot(client_uuid, generation) : monitor_runtime_state_t {.retryable = true, .error = "Remote Monitor topology is not ready."};
}
void release_monitor(const std::string_view client_uuid, const std::uint64_t generation, const std::string_view reason) {
std::function<void(std::string_view, std::uint64_t, std::string_view)> release;
{
std::lock_guard lock {monitor_runtime_hooks_mutex};
release = monitor_runtime_hooks.explicit_release;
}
if (release) release(client_uuid, generation, reason);
}
void notify_monitor_transport_lost(const std::string_view client_uuid, const std::uint64_t generation) {
std::function<void(std::string_view, std::uint64_t)> transport_lost;
{
std::lock_guard lock {monitor_runtime_hooks_mutex};
transport_lost = monitor_runtime_hooks.transport_lost;
}
if (transport_lost) transport_lost(client_uuid, generation);
}
void notify_monitor_unpair(const std::string_view client_uuid) {
std::function<void(std::string_view)> unpair;
{
std::lock_guard lock {monitor_runtime_hooks_mutex};
unpair = monitor_runtime_hooks.unpair;
}
if (unpair) unpair(client_uuid);
}
void notify_monitor_shutdown() {
std::function<void()> shutdown;
{
std::lock_guard lock {monitor_runtime_hooks_mutex};
shutdown = monitor_runtime_hooks.shutdown;
}
if (shutdown) shutdown();
}
bool pending_registry_t::add(pending_t pending, std::string *warning) {
expire(std::chrono::steady_clock::now());
if (!pending.launch_id || pending_.contains(pending.launch_id)) return false;
if (!pending.encrypted) {
const auto count = std::count_if(pending_.begin(), pending_.end(), [&pending](const auto &entry) { return !entry.second.encrypted && entry.second.source_address == pending.source_address; });
if (count != 0) {
warning_ = "Plaintext RTSP has more than one pending launch for this source address; the new launch was rejected.";
if (warning) *warning = warning_;
return false;
}
}
if (pending_.size() >= max_client_vdds * 2) return false;
pending_.emplace(pending.launch_id, std::move(pending));
return true;
}
std::optional<pending_t> pending_registry_t::match_encrypted(const std::string_view client_uuid, const std::string_view crypto_binding, const std::chrono::steady_clock::time_point now) {
expire(now);
std::optional<pending_t> match;
for (const auto &[id, pending] : pending_) {
if (pending.encrypted && pending.client_uuid == client_uuid && pending.crypto_binding == crypto_binding) {
if (match) return std::nullopt;
match = pending;
}
}
return match;
}
std::optional<pending_t> pending_registry_t::match_plaintext(const std::string_view address, const std::chrono::steady_clock::time_point now) {
expire(now);
std::optional<pending_t> match;
for (const auto &[id, pending] : pending_) {
if (!pending.encrypted && pending.source_address == address) {
if (match) { warning_ = "Plaintext RTSP source-address routing became ambiguous and was rejected."; return std::nullopt; }
match = pending;
}
}
return match;
}
void pending_registry_t::erase(const std::uint32_t id) { pending_.erase(id); }
void pending_registry_t::expire(const std::chrono::steady_clock::time_point now) { for (auto it = pending_.begin(); it != pending_.end();) it = it->second.expires_at <= now ? pending_.erase(it) : std::next(it); }
void pending_registry_t::clear() { pending_.clear(); }
std::string pending_registry_t::warning() const { return warning_; }
bool validate_layout(const std::vector<placement_t> &placements, const std::vector<std::string> &known_clients, const std::vector<std::string> &physical_ids, std::string *error) {
std::unordered_map<std::string, std::string> parents;
std::unordered_set<std::string> seen;
bool primary = false;
for (const auto &placement : placements) {
if (!contains(known_clients, placement.client_uuid) || !seen.insert(placement.client_uuid).second || placement.client_uuid == placement.anchor_id || placement.gap_px < 0 || placement.gap_px > 100000) { if (error) *error = "Invalid client placement."; return false; }
if (placement.anchor_kind == "client" ? !contains(known_clients, placement.anchor_id) : placement.anchor_kind != "physical" || !contains(physical_ids, placement.anchor_id)) { if (error) *error = "Unknown layout anchor."; return false; }
if (placement.edge != "left" && placement.edge != "right" && placement.edge != "above" && placement.edge != "below") { if (error) *error = "Invalid layout edge."; return false; }
if (placement.alignment != "start" && placement.alignment != "center" && placement.alignment != "end") { if (error) *error = "Invalid layout alignment."; return false; }
if (placement.primary && primary) { if (error) *error = "Only one client display may be primary."; return false; }
primary = primary || placement.primary;
if (placement.anchor_kind == "client") parents.emplace(placement.client_uuid, placement.anchor_id);
}
for (const auto &[child, parent] : parents) { std::unordered_set<std::string> path; auto node = child; while (parents.contains(node)) { if (!path.insert(node).second) { if (error) *error = "Client display placement contains a cycle."; return false; } node = parents.at(node); } }
return true;
}
}