diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3106fc8..38c375e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release - name: Build - run: cmake --build build --target displayxr_linux_window_test displayxr_linux_window_lattice_test displayxr_linux_window_drag_test displayxr_linux_window_input_test displayxr_url_fetch_test displayxr_launch_args_test displayxr_cursor_depth_test displayxr_cursor_depth_x11_compile + run: cmake --build build --target displayxr_linux_window_test displayxr_linux_window_geom_json_test displayxr_linux_window_lattice_test displayxr_linux_window_drag_test displayxr_linux_window_input_test displayxr_url_fetch_test displayxr_launch_args_test displayxr_cursor_depth_test displayxr_cursor_depth_x11_compile - name: Test (launch contract + SHA-1 cache key) run: ./build/tests/displayxr_launch_args_test @@ -81,6 +81,9 @@ jobs: DXR_URL_FETCH_TEST_PORT=$(head -1 "$RUNNER_TEMP/port") ./build/tests/displayxr_url_fetch_test kill $srv + - name: Test (own window from the geometry payload, any key order, no display) + run: ./build/tests/displayxr_linux_window_geom_json_test + - name: Test (drag lattice table + grid snap path, no display) run: ./build/tests/displayxr_linux_window_lattice_test diff --git a/common/linux/dxr_linux_window.cmake b/common/linux/dxr_linux_window.cmake index 06ebf6d..8aedae2 100644 --- a/common/linux/dxr_linux_window.cmake +++ b/common/linux/dxr_linux_window.cmake @@ -156,6 +156,7 @@ target_sources(displayxr_linux_window PRIVATE "${_dxr_lw_dir}/dxr_wl_chrome.h" "${_dxr_lw_dir}/dxr_wl_placement.cpp" "${_dxr_lw_dir}/dxr_wl_placement.h" + "${_dxr_lw_dir}/dxr_wl_geom_json.h" "${_dxr_lw_dir}/dxr_wl_lattice.h" "${_dxr_lw_dir}/dxr_wl_dnd.cpp" "${_dxr_lw_dir}/dxr_wl_dnd.h" diff --git a/common/linux/dxr_wl_geom_json.h b/common/linux/dxr_wl_geom_json.h new file mode 100644 index 0000000..d729dd9 --- /dev/null +++ b/common/linux/dxr_wl_geom_json.h @@ -0,0 +1,278 @@ +// Copyright 2026, The DisplayXR Project +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief Pure parser for this process's window in the window-geometry + * publisher's `GetWindows` payload (dxr_wl_placement's + * get_own_geometry). No D-Bus, no compositor. + * + * The payload is `{"version":…,"layout_mode":…,"windows":[{…},…]}` and the + * spec (docs/specs/runtime/wayland-window-geometry.md) fixes its keys, NOT + * their order. The GNOME extension writes each window `pid` first and its + * monitor afterwards; a KWin publisher writes them sorted (runtime#1928). So + * this walks the structure — the top-level object, the `windows` array, each + * window object, its `monitor` object — and looks every key up at its own + * depth, inside its own object. It used to search substrings from the first + * `"pid":N,`, which assumed the GNOME order and dereferenced a null `monitor` + * when the caller's window was the last one and its monitor came before `pid`. + * + * Deliberately minimal: enough JSON to skip any value (strings with escapes, + * nested objects and arrays) and read numbers, booleans and the one string + * it needs. Rect integers are truncated toward zero, exactly as the runtime's + * own consumer (comp_vk_native_wl_geom.c, cJSON valuedouble cast) reads them, + * so the app and the runtime never disagree on a pixel. + */ +#pragma once + +#include "util/u_wayland_layout.h" + +#include +#include +#include + +namespace dxr_wl_geom_json { + +//! This process's window, as get_own_geometry reports it (see +//! DxrWlPlacement::OwnGeometry for each field's meaning). +struct Window { + int32_t frame[4] = {0, 0, 0, 0}; + int32_t buffer[4] = {0, 0, 0, 0}; + int32_t monitor[4] = {0, 0, 0, 0}; + double monitor_scale = 0.0; + double device_scale = 0.0; + int layout_mode = 0; + bool move_sync = false; +}; + +namespace detail { + +inline const char *skip_ws(const char *p, const char *end) { + while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) { + p++; + } + return p; +} + +//! Past the end of the string whose opening quote is at @p p; nullptr if +//! unterminated. +inline const char *skip_string(const char *p, const char *end) { + for (p++; p < end; p++) { + if (*p == '\\') { + p++; + } else if (*p == '"') { + return p + 1; + } + } + return nullptr; +} + +//! Past the end of the value starting at @p p; nullptr if malformed. +inline const char *skip_value(const char *p, const char *end) { + p = skip_ws(p, end); + if (p >= end) { + return nullptr; + } + if (*p == '"') { + return skip_string(p, end); + } + if (*p == '{' || *p == '[') { + int depth = 0; + while (p < end) { + if (*p == '"') { + p = skip_string(p, end); + if (p == nullptr) { + return nullptr; + } + continue; + } + if (*p == '{' || *p == '[') { + depth++; + } else if (*p == '}' || *p == ']') { + if (--depth == 0) { + return p + 1; + } + } + p++; + } + return nullptr; + } + // Number, true, false or null: up to the next delimiter. + while (p < end && *p != ',' && *p != '}' && *p != ']' && *p != ' ' && + *p != '\n' && *p != '\r' && *p != '\t') { + p++; + } + return p; +} + +//! The value of @p key directly inside the object at @p obj ('{'), or +//! nullptr. Nested objects' keys never match. +inline const char *find_key(const char *obj, const char *end, const char *key, + const char **value_end = nullptr) { + if (obj == nullptr || obj >= end || *obj != '{') { + return nullptr; + } + const size_t klen = strlen(key); + const char *p = obj + 1; + for (;;) { + p = skip_ws(p, end); + if (p >= end || *p == '}' || *p != '"') { + return nullptr; + } + const char *kstart = p + 1; + const char *kend = skip_string(p, end); + if (kend == nullptr) { + return nullptr; + } + const bool match = + (size_t)(kend - 1 - kstart) == klen && strncmp(kstart, key, klen) == 0; + p = skip_ws(kend, end); + if (p >= end || *p != ':') { + return nullptr; + } + const char *v = skip_ws(p + 1, end); + const char *vend = skip_value(v, end); + if (vend == nullptr) { + return nullptr; + } + if (match) { + if (value_end != nullptr) { + *value_end = vend; + } + return v; + } + p = skip_ws(vend, end); + if (p < end && *p == ',') { + p++; + } + } +} + +inline bool read_num(const char *v, const char *end, double *out) { + if (v == nullptr || v >= end) { + return false; + } + char *e = nullptr; + const double d = strtod(v, &e); + if (e == v || e > end) { + return false; + } + *out = d; + return true; +} + +inline bool num_key(const char *obj, const char *end, const char *key, + double *out) { + return read_num(find_key(obj, end, key), end, out); +} + +//! `"key":[a,b,c,d]` directly inside @p obj. +inline bool int4_key(const char *obj, const char *end, const char *key, + int32_t out[4]) { + const char *aend = nullptr; + const char *p = find_key(obj, end, key, &aend); + if (p == nullptr || *p != '[') { + return false; + } + p++; + for (int i = 0; i < 4; i++) { + p = skip_ws(p, aend); + double d = 0.0; + if (!read_num(p, aend, &d)) { + return false; + } + out[i] = (int32_t)d; + p = skip_value(p, aend); + if (p == nullptr) { + return false; + } + p = skip_ws(p, aend); + if (i < 3) { + if (p >= aend || *p != ',') { + return false; + } + p++; + } + } + return true; +} + +inline bool bool_key(const char *obj, const char *end, const char *key) { + const char *v = find_key(obj, end, key); + return v != nullptr && end - v >= 4 && strncmp(v, "true", 4) == 0; +} + +} // namespace detail + +/*! + * Fill @p out from the first window in @p json whose `pid` is @p pid. + * False when the payload is malformed, has no such window, or that window + * lacks frame, buffer or a monitor with a positive scale. + */ +inline bool parse_own_window(const char *json, long pid, Window *out) { + using namespace detail; + if (json == nullptr || out == nullptr) { + return false; + } + const char *end = json + strlen(json); + const char *root = skip_ws(json, end); + + const char *wend = nullptr; + const char *w = find_key(root, end, "windows", &wend); + if (w == nullptr || *w != '[') { + return false; + } + for (const char *p = skip_ws(w + 1, wend); p < wend && *p != ']';) { + const char *oend = skip_value(p, wend); + if (oend == nullptr) { + return false; + } + double wpid = 0.0; + if (*p == '{' && num_key(p, oend, "pid", &wpid) && (long)wpid == pid) { + Window g; + const char *mend = nullptr; + const char *mon = find_key(p, oend, "monitor", &mend); + double mx = 0.0, my = 0.0, mw = 0.0, mh = 0.0; + if (!int4_key(p, oend, "frame", g.frame) || + !int4_key(p, oend, "buffer", g.buffer) || mon == nullptr || + *mon != '{' || !num_key(mon, mend, "x", &mx) || + !num_key(mon, mend, "y", &my) || !num_key(mon, mend, "w", &mw) || + !num_key(mon, mend, "h", &mh) || + !num_key(mon, mend, "scale", &g.monitor_scale) || + !(g.monitor_scale > 0.0)) { + return false; + } + g.monitor[0] = (int32_t)mx; + g.monitor[1] = (int32_t)my; + g.monitor[2] = (int32_t)mw; + g.monitor[3] = (int32_t)mh; + // Extension v11: device px per stage px, inside the monitor. + if (!num_key(mon, mend, "device_scale", &g.device_scale) || + !(g.device_scale > 0.0)) { + g.device_scale = 0.0; + } + // Extension v11, top level. + const char *lm_end = nullptr; + const char *lm = find_key(root, end, "layout_mode", &lm_end); + if (lm != nullptr && *lm == '"' && lm_end - lm >= 2) { + char buf[16]; + const size_t n = (size_t)(lm_end - lm - 2); + if (n < sizeof(buf)) { + memcpy(buf, lm + 1, n); + buf[n] = '\0'; + g.layout_mode = (int)u_wl_layout_mode_from_string(buf); + } + } + // Extension v9+ (runtime#1748). + g.move_sync = bool_key(p, oend, "move_sync"); + *out = g; + return true; + } + p = skip_ws(oend, wend); + if (p < wend && *p == ',') { + p = skip_ws(p + 1, wend); + } + } + return false; +} + +} // namespace dxr_wl_geom_json diff --git a/common/linux/dxr_wl_placement.cpp b/common/linux/dxr_wl_placement.cpp index 7c4e785..3f9afba 100644 --- a/common/linux/dxr_wl_placement.cpp +++ b/common/linux/dxr_wl_placement.cpp @@ -6,6 +6,7 @@ */ #include "dxr_wl_placement.h" +#include "dxr_wl_geom_json.h" #include "util/u_wayland_layout.h" @@ -297,54 +298,6 @@ DxrWlPlacement::drain() } } -namespace { -//! The integers of a JSON array `"key":[a,b,c,d]` inside [from, to). -bool -json_int4(const char *from, const char *to, const char *key, int32_t out[4]) -{ - const char *p = strstr(from, key); - if (p == nullptr || p >= to) { - return false; - } - p = strchr(p, '['); - if (p == nullptr || p >= to) { - return false; - } - p++; - for (int i = 0; i < 4; i++) { - char *end = nullptr; - const long v = strtol(p, &end, 10); - if (end == p) { - return false; - } - out[i] = (int32_t)v; - p = end; - while (*p == ',' || *p == ' ') { - p++; - } - } - return true; -} - -//! A number `"key":v` inside [from, to). -bool -json_num(const char *from, const char *to, const char *key, double *out) -{ - const char *p = strstr(from, key); - if (p == nullptr || p >= to) { - return false; - } - p += strlen(key); - char *end = nullptr; - const double v = strtod(p, &end); - if (end == p) { - return false; - } - *out = v; - return true; -} -} // namespace - bool DxrWlPlacement::get_own_geometry(OwnGeometry *out) { @@ -364,50 +317,20 @@ DxrWlPlacement::get_own_geometry(OwnGeometry *out) const char *json = nullptr; bool ok = false; if (dbus_message_get_args(reply, nullptr, DBUS_TYPE_STRING, &json, DBUS_TYPE_INVALID) && json != nullptr) { - // The publisher's own JSON.stringify output: one object per window, - // each starting at its "pid". Ours is the first with our PID. - char needle[32]; - snprintf(needle, sizeof(needle), "\"pid\":%d,", (int)getpid()); - const char *obj = strstr(json, needle); - if (obj != nullptr) { - const char *next = strstr(obj + 1, "\"pid\":"); - const char *end = next != nullptr ? next : json + strlen(json); - const char *mon = strstr(obj, "\"monitor\":{"); - // The monitor object's keys, in its own order: x, y, w, h, scale. - double mx = 0.0, my = 0.0, mw = 0.0, mh = 0.0; - ok = json_int4(obj, end, "\"frame\":", out->frame) && json_int4(obj, end, "\"buffer\":", out->buffer) && - mon != nullptr && mon < end && json_num(mon, end, "\"x\":", &mx) && - json_num(mon, end, "\"y\":", &my) && json_num(mon, end, "\"w\":", &mw) && - json_num(mon, end, "\"h\":", &mh) && json_num(mon, end, "\"scale\":", &out->monitor_scale) && - out->monitor_scale > 0.0; - // Extension v11: device px per stage px, inside the monitor - // object (after "scale"; `"scale":` above never matches the - // `"device_scale":` key, whose quote precedes "device"). - out->device_scale = 0.0; - if (!json_num(mon, end, "\"device_scale\":", &out->device_scale) || !(out->device_scale > 0.0)) { - out->device_scale = 0.0; - } - // Top level, before the window list. - const char *windows = strstr(json, "\"windows\":"); - const char *lm = strstr(json, "\"layout_mode\":\""); - out->layout_mode = 0; - if (lm != nullptr && (windows == nullptr || lm < windows)) { - lm += strlen("\"layout_mode\":\""); - if (strncmp(lm, "logical\"", 8) == 0) { - out->layout_mode = U_WL_LAYOUT_MODE_LOGICAL; - } else if (strncmp(lm, "physical\"", 9) == 0) { - out->layout_mode = U_WL_LAYOUT_MODE_PHYSICAL; - } - } - out->monitor[0] = (int32_t)mx; - out->monitor[1] = (int32_t)my; - out->monitor[2] = (int32_t)mw; - out->monitor[3] = (int32_t)mh; - // Extension v9+ (runtime#1748). JSON.stringify writes no spaces. - const char *ms = strstr(obj, "\"move_sync\":true"); - out->move_sync = ms != nullptr && ms < end; - } - } + // Walked by structure, not substring: the spec fixes the keys, not + // their order (a KWin publisher sorts them, runtime#1928). + dxr_wl_geom_json::Window w; + ok = dxr_wl_geom_json::parse_own_window(json, (long)getpid(), &w); + if (ok) { + memcpy(out->frame, w.frame, sizeof(out->frame)); + memcpy(out->buffer, w.buffer, sizeof(out->buffer)); + memcpy(out->monitor, w.monitor, sizeof(out->monitor)); + out->monitor_scale = w.monitor_scale; + out->device_scale = w.device_scale; + out->layout_mode = w.layout_mode; + out->move_sync = w.move_sync; + } + } dbus_message_unref(reply); return ok; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bbb8b23..6335758 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -81,6 +81,16 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) add_test(NAME displayxr_linux_window_lattice_test COMMAND displayxr_linux_window_lattice_test) + # This process's window out of the geometry publisher's payload + # (dxr_wl_geom_json.h, pure): any key order, runtime#1928. + add_executable(displayxr_linux_window_geom_json_test linux_window_geom_json_test.cpp) + target_include_directories(displayxr_linux_window_geom_json_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../common/linux" + "${CMAKE_CURRENT_SOURCE_DIR}/../common/linux/xrt_aux") + set_target_properties(displayxr_linux_window_geom_json_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) + add_test(NAME displayxr_linux_window_geom_json_test COMMAND displayxr_linux_window_geom_json_test) + # The two Wayland scale factors (dxr_wl_scale.h, pure) in both of mutter's # layout modes: device px per SURFACE unit (sizing) and per STAGE px # (positions, the window-geometry extension's rects). diff --git a/tests/linux_window_geom_json_test.cpp b/tests/linux_window_geom_json_test.cpp new file mode 100644 index 0000000..a09bc12 --- /dev/null +++ b/tests/linux_window_geom_json_test.cpp @@ -0,0 +1,138 @@ +// Copyright 2026, The DisplayXR Project +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief This process's window out of the window-geometry publisher's + * payload (common/linux/dxr_wl_geom_json.h). No display, no bus. + * + * 1. The GNOME extension's key order (pid first, monitor after). + * 2. The same payload with every object's keys SORTED, as a KWin publisher + * writes it (runtime#1928) — the old substring parser dereferenced a null + * monitor here when the caller's window was the last one. + * 3. A pid that is a prefix of another (12 vs 123), a key name inside a + * title string, and a nested object carrying the same key names: none of + * them may be read as the caller's. + * 4. Missing pieces fail cleanly; optional fields default. + */ +#include "dxr_wl_geom_json.h" + +#include + +static int g_fail = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); \ + g_fail++; \ + } \ + } while (0) + +using dxr_wl_geom_json::parse_own_window; +using dxr_wl_geom_json::Window; + +static void check_window(const Window &w) { + CHECK(w.frame[0] == 257 && w.frame[1] == 173 && w.frame[2] == 1280 && + w.frame[3] == 766); + CHECK(w.buffer[0] == 257 && w.buffer[1] == 219 && w.buffer[2] == 1280 && + w.buffer[3] == 720); + CHECK(w.monitor[0] == 0 && w.monitor[1] == 0 && w.monitor[2] == 2304 && + w.monitor[3] == 1296); + CHECK(w.monitor_scale > 1.6666 && w.monitor_scale < 1.6667); + CHECK(w.device_scale > 1.6666 && w.device_scale < 1.6667); + CHECK(w.layout_mode == U_WL_LAYOUT_MODE_LOGICAL); + CHECK(w.move_sync); +} + +int main() { + // 1. GNOME order. Ours (pid 4242) is the LAST window. + static const char *gnome = + "{\"version\":12,\"layout_mode\":\"logical\",\"windows\":[" + "{\"pid\":77,\"title\":\"other\",\"frame\":[1,2,3,4],\"buffer\":[1,2,3,4]" + "," + "\"monitor\":{\"x\":0,\"y\":0,\"w\":2304,\"h\":1296,\"scale\":1,\"device_" + "scale\":1},\"move_sync\":false}," + "{\"pid\":4242,\"title\":\"cube\",\"frame\":[257,173,1280,766]," + "\"buffer\":[257,219,1280,720]," + "\"monitor\":{\"x\":0,\"y\":0,\"w\":2304,\"h\":1296,\"scale\":1.6666666," + "\"device_scale\":1.6666666}," + "\"moving\":false,\"move_sync\":true}]}"; + { + Window w; + CHECK(parse_own_window(gnome, 4242, &w)); + check_window(w); + } + + // 2. Sorted keys everywhere (KWin), ours last, whitespace between tokens. + static const char *sorted = + "{ \"layout_mode\": \"logical\", \"version\": 12, \"windows\": [\n" + " {\"buffer\": [1,2,3,4], \"frame\": [1,2,3,4]," + " \"monitor\": {\"device_scale\": 1, \"h\": 1296, \"scale\": 1, \"w\": " + "2304, \"x\": 0, \"y\": 0}," + " \"move_sync\": false, \"pid\": 77, \"title\": \"other\"},\n" + " {\"buffer\": [257, 219, 1280, 720], \"frame\": [257, 173, 1280, 766]," + " \"monitor\": {\"device_scale\": 1.6666666, \"h\": 1296, \"scale\": " + "1.6666666, \"w\": 2304, \"x\": 0, " + "\"y\": 0}," + " \"move_sync\": true, \"moving\": false, \"pid\": 4242, \"title\": " + "\"cube\"}\n" + "] }"; + { + Window w; + CHECK(parse_own_window(sorted, 4242, &w)); + check_window(w); + // The first window is read as itself, not as the next one's fields. + Window o; + CHECK(parse_own_window(sorted, 77, &o)); + CHECK(o.frame[0] == 1 && o.buffer[3] == 4 && o.monitor_scale == 1.0 && + !o.move_sync); + } + + // 3. Decoys: pid 123 must not match 12; a title spelling "pid":12 and a + // nested object with frame/buffer keys must not be read. + static const char *decoys = + "{\"windows\":[" + "{\"pid\":123,\"title\":\"\\\"pid\\\":12,\\\"frame\\\":[9,9,9,9]\"," + "\"frame\":[5,5,5,5]," + "\"buffer\":[5,5,5,5],\"monitor\":{\"x\":0,\"y\":0,\"w\":10,\"h\":10," + "\"scale\":1}}," + "{\"extra\":{\"frame\":[8,8,8,8],\"pid\":12},\"pid\":12,\"frame\":[6,6,6," + "6],\"buffer\":[7,7,7,7]," + "\"monitor\":{\"x\":3,\"y\":4,\"w\":10,\"h\":10,\"scale\":2}}]}"; + { + Window w; + CHECK(parse_own_window(decoys, 12, &w)); + CHECK(w.frame[0] == 6 && w.buffer[0] == 7 && w.monitor[0] == 3 && + w.monitor_scale == 2.0); + CHECK(w.device_scale == 0.0 && w.layout_mode == U_WL_LAYOUT_MODE_UNKNOWN && + !w.move_sync); + CHECK(!parse_own_window(decoys, 1, &w)); + } + + // 4. Failures: no such pid, no monitor, zero scale, truncated, garbage. + { + Window w; + CHECK(!parse_own_window(gnome, 999, &w)); + CHECK(!parse_own_window( + "{\"windows\":[{\"pid\":1,\"frame\":[1,2,3,4],\"buffer\":[1,2,3,4]}]}", + 1, &w)); + CHECK(!parse_own_window( + "{\"windows\":[{\"pid\":1,\"frame\":[1,2,3,4],\"buffer\":[1,2,3,4]," + "\"monitor\":{\"x\":0,\"y\":0,\"w\":1,\"h\":1,\"scale\":0}}]}", + 1, &w)); + CHECK( + !parse_own_window("{\"windows\":[{\"pid\":1,\"frame\":[1,2,3", 1, &w)); + CHECK(!parse_own_window( + "{\"windows\":[{\"pid\":1,\"frame\":[1,2,3],\"buffer\":[1,2,3,4]," + "\"monitor\":{\"x\":0,\"y\":0,\"w\":1,\"h\":1,\"scale\":1}}]}", + 1, &w)); + CHECK(!parse_own_window("", 1, &w)); + CHECK(!parse_own_window("not json", 1, &w)); + CHECK(!parse_own_window(nullptr, 1, &w)); + CHECK(!parse_own_window("{\"windows\":[]}", 1, &w)); + } + + if (g_fail == 0) { + printf("linux_window_geom_json_test: all checks passed\n"); + } + return g_fail == 0 ? 0 : 1; +}