From 8fbe68f88f10e5731c832dcf1537d432fba74659 Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 02:03:43 -0700 Subject: [PATCH 1/6] Alt+e makes a zone's window fullscreen below a bar in the zone's colour that names the zone, and the window's own request never does; the pointer goes to what is drawn on top, not to a window hidden below a bar or border --- build/desktop/dwl-config.h | 2 + build/guest-tests/gui-check.sh | 28 +++-- docs/architecture.md | 8 +- docs/user-guide.md | 6 +- tools/desktop/dwl-zone-borders.py | 167 ++++++++++++++++++++++++++++-- tools/desktop/kryptik-chrome | 6 +- tools/desktop/wlprobe.c | 24 ++++- tools/image/gui-test.sh | 58 ++++++++--- 8 files changed, 262 insertions(+), 37 deletions(-) diff --git a/build/desktop/dwl-config.h b/build/desktop/dwl-config.h index d393b330..a1305940 100644 --- a/build/desktop/dwl-config.h +++ b/build/desktop/dwl-config.h @@ -14,6 +14,8 @@ static const int sloppyfocus = 1; /* focus follows mouse */ static const int bypass_surface_visibility = 0; static const unsigned int borderpx = 6; /* the identity channel: wide */ static const unsigned int bandpx = 2; /* how much narrower an unfocused border's colour is */ +static const unsigned int barpx = 22; /* the bar naming a fullscreen window's zone */ +static const unsigned int barscale = 2; /* its 5x7 lettering, magnified */ static const float rootcolor[] = COLOR(0x101418ff); static const float urgentcolor[] = COLOR(KRYPTIK_URGENT_BORDER); static const float fullscreen_bg[] = {0.0f, 0.0f, 0.0f, 1.0f}; diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index d23de700..42bacadd 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -5,7 +5,7 @@ # GT SCREENSHOT-OVERSIZE take a screenshot # GT KEY-FOCUS-ZONE, GT KEY-FOCUS-OVERSIZE, # GT KEY-FOCUS-FORGED, GT KEY-FOCUS-PERSONAL press Alt+j (explicit focus) -# GT KEY-FULLSCREEN, GT KEY-FULLSCREEN-AGAIN press Alt+e (zone refuses) +# GT KEY-FULLSCREEN, GT KEY-FULLSCREEN-AGAIN press Alt+e (fullscreen, then back) # GT KEY-MENU press Alt+p (the chrome menu) # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) # GT CONSENT-WAIT 2 type y and Enter (not the code: refused) @@ -102,6 +102,23 @@ out="$(since_mark bind untrusted)" [[ "$out" == *"bind refused"* ]] && pass "zone-bind-refused" "a bind of the screencopy manager got wl_display.error and a closed connection" || fail "zone-bind-refused" "$(echo "$out" | tail -3 | tr '\n' ' ')" grep -q 'not advertised' "$RT/kryptik/untrusted/proxy.log" 2>/dev/null && pass "proxy-logged-refusal" "$(grep 'not advertised' "$RT/kryptik/untrusted/proxy.log" | tail -1 | cut -c1-120)" || fail "proxy-logged-refusal" "no refusal in the proxy log" +# --- a zone window goes fullscreen only by the user's key ---------------- +# wlprobe asks for it once drawn and says which configures were fullscreen; +# zone 0's request is granted, which shows the probe would see a grant. +as_user "/usr/libexec/kryptik/wlprobe fullscreen 4" > "$LOG/fullscreen-zone0.out" 2>&1 +grep -q 'configure (fullscreen)' "$LOG/fullscreen-zone0.out" && pass "zone0-fullscreen-granted" || fail "zone0-fullscreen-granted" "$(tr '\n' ' ' < "$LOG/fullscreen-zone0.out")" +mark fs untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe fullscreen 6" > "$LOG/launch-fullscreen.out" 2>&1 +fs_answered() { since_mark fs untrusted | sed -n '/asked for fullscreen/,$p' | grep -q committed; } +wait_for 20 fs_answered; answered=$? +wait_for 20 test ! -e /run/kryptik/zones/untrusted/init.pid; sleep 1 +out="$(since_mark fs untrusted)" +if [[ "$answered" = 0 && "$out" != *"(fullscreen)"* ]]; then + pass "zone-fullscreen-refused" "the zone's own request was answered with a configure that is not fullscreen" +else + fail "zone-fullscreen-refused" "$(echo "$out" | tail -4 | tr '\n' ' '); $(tr '\n' ' ' < "$LOG/launch-fullscreen.out")" +fi + # --- a mapped zone window cannot take the chrome's focus ---------------------- mark map untrusted launch_plain untrusted "/usr/libexec/kryptik/wlprobe oversize 0 8 map-focus" > "$LOG/map-focus.out" 2>&1 @@ -129,13 +146,12 @@ sleep 2 echo "GT SCREENSHOT-READY" sleep 6 echo "GT KEY-FULLSCREEN" -sleep 2 -if grep -q '^fullscreen=0' "$RT/kryptik/focus" && grep -q '^zone=untrusted' "$RT/kryptik/focus"; then - pass "zone-fullscreen-refused" "$(tr '\n' ' ' < "$RT/kryptik/focus")" +if wait_for 20 grep -q '^fullscreen=1' "$RT/kryptik/focus" && grep -q '^zone=untrusted' "$RT/kryptik/focus"; then + pass "fullscreen-by-key" "$(tr '\n' ' ' < "$RT/kryptik/focus")" else - fail "zone-fullscreen-refused" "focus after Alt+e: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" + fail "fullscreen-by-key" "focus after Alt+e: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" fi -# The host's screenshot must still show the chrome beside the zone window. +# The host's screenshot must show the bar naming the zone above the window. sleep 2 echo "GT SCREENSHOT-FULLSCREEN" sleep 6 diff --git a/docs/architecture.md b/docs/architecture.md index 7a7f4586..37eb5ce1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ A zone is the unit of isolation, and every process belongs to exactly one. | Syscalls | seccomp-bpf, default-deny allowlist | | File access | Landlock | | Resources | cgroup v2 memory, pids, cpu and io limits | -| Identity | border colour, drawn by the compositor, which has no title bars; the text identity (zone, glyph, label, the `[zone]` title prefix) is shown by the trusted chrome alone, so no window draws its own | +| Identity | border colour, drawn by the compositor, which has no title bars; the text identity (zone, glyph, label, the `[zone]` title prefix) is shown by the trusted chrome, and over a fullscreen window by the compositor's bar, so no window draws its own | ### Zone 0 @@ -58,7 +58,11 @@ zone; the compositor draws the zone's border and title prefix from that. A window's border colour is how the user tells which zone it belongs to. If they cannot tell at a glance which zone a password prompt belongs to, the -zones have failed them. +zones have failed them. A fullscreen window keeps its border, and the +compositor adds a bar across the top of the output in the zone's colour, +with the zone's name, outside the window's frame, where its surfaces are +clipped away. Only the user's key makes a zone's window fullscreen, never +the program's own request. `zoneid audit` checks that every pair of zones stays distinguishable. ## Storage diff --git a/docs/user-guide.md b/docs/user-guide.md index f5dd9db2..a1b177bd 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -296,8 +296,10 @@ shows each timer, its timeout and whether it is running. hardware support beyond what the virtual machine exercised is claimed. - The builds are not reproducible bit for bit; the hashes name what was tested, not what a rebuild would produce. -- A fullscreen window is framed by its zone's border colour; there is no - separate always-visible bar with the zone's name. +- A program in a zone cannot make its window fullscreen; Alt+e does, and + the window then sits below a bar in its zone's colour that names the + zone. Other windows show their zone by border colour alone, and the + menu's f names it in words. - A trial boot is judged by services and the zone supervisor coming up. After that, a watchdog resets a machine whose userspace has stopped running for a minute; it does not notice a single crashed service or a diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index 590a351a..f2b0077a 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -10,15 +10,109 @@ # Exact-string edits rather than a diff, so a different dwl fails with the text # not found instead of patching with fuzz. The colour comes from the app_id # prefix only the zone's proxy sets; focus is shown by width (a band of the -# root colour when unfocused), and fullscreen keeps the border. +# root colour when unfocused), and fullscreen keeps the border under a bar +# that names the zone. + +# The bar's lettering, 5x7: capitals for the letters, digits and '-' of a zone name. +FONT = { + "a": "01110 10001 10001 10001 11111 10001 10001", + "b": "11110 10001 10001 11110 10001 10001 11110", + "c": "01110 10001 10000 10000 10000 10001 01110", + "d": "11100 10010 10001 10001 10001 10010 11100", + "e": "11111 10000 10000 11110 10000 10000 11111", + "f": "11111 10000 10000 11110 10000 10000 10000", + "g": "01110 10001 10000 10111 10001 10001 01111", + "h": "10001 10001 10001 11111 10001 10001 10001", + "i": "01110 00100 00100 00100 00100 00100 01110", + "j": "00111 00010 00010 00010 00010 10010 01100", + "k": "10001 10010 10100 11000 10100 10010 10001", + "l": "10000 10000 10000 10000 10000 10000 11111", + "m": "10001 11011 10101 10101 10001 10001 10001", + "n": "10001 10001 11001 10101 10011 10001 10001", + "o": "01110 10001 10001 10001 10001 10001 01110", + "p": "11110 10001 10001 11110 10000 10000 10000", + "q": "01110 10001 10001 10001 10101 10010 01101", + "r": "11110 10001 10001 11110 10100 10010 10001", + "s": "01111 10000 10000 01110 00001 00001 11110", + "t": "11111 00100 00100 00100 00100 00100 00100", + "u": "10001 10001 10001 10001 10001 10001 01110", + "v": "10001 10001 10001 10001 10001 01010 00100", + "w": "10001 10001 10001 10101 10101 10101 01010", + "x": "10001 10001 01010 00100 01010 10001 10001", + "y": "10001 10001 10001 01010 00100 00100 00100", + "z": "11111 00001 00010 00100 01000 10000 11111", + "0": "01110 10001 10011 10101 11001 10001 01110", + "1": "00100 01100 00100 00100 00100 00100 01110", + "2": "01110 10001 00001 00010 00100 01000 11111", + "3": "11111 00010 00100 00010 00001 10001 01110", + "4": "00010 00110 01010 10010 11111 00010 00010", + "5": "11111 10000 11110 00001 00001 10001 01110", + "6": "00110 01000 10000 11110 10001 10001 01110", + "7": "11111 00001 00010 00100 01000 01000 01000", + "8": "01110 10001 10001 01110 10001 10001 01110", + "9": "01110 10001 10001 01111 00001 00010 01100", + "-": "00000 00000 00000 11111 00000 00000 00000", +} +FONT_ROWS = "".join("\t['%s'] = {%s},\n" % (ch, ", ".join("0x%02x" % int(r, 2) for r in rows.split())) + for ch, rows in FONT.items()) + +ZONEBAR = """/* Kryptik: the bar's lettering, indexed by the lower-case name; bit 4 is leftmost. */ +static const unsigned char zonefont[128][7] = { +""" + FONT_ROWS + """}; + +/* Kryptik: a fullscreen window leaves the top barpx rows of its output to a + * bar in its zone's colour that names the zone, outside the window's frame: + * its surfaces are clipped to the frame, and a zone has no popups. */ +static void +zonebar(Client *c) +{ +\tstatic const float black[] = {0, 0, 0, 1}, white[] = {1, 1, 1, 1}; +\tconst char *id = client_get_appid(c); +\tconst float *ink; +\tstruct wlr_box box = c->mon->m; +\tchar name[16] = "zone 0"; +\tint s = (int)barscale, pad = ((int)barpx - 7 * s) / 2, x, row, col, n; +\tsize_t i; + +\tzonecolors(c); +\tif (c->zoneborder != unzonedcolor) +\t\tsnprintf(name, sizeof(name), "%.*s", (int)strcspn(id + 8, "."), id + 8); +\tink = 0.299f * c->zoneborder[0] + 0.587f * c->zoneborder[1] +\t\t\t+ 0.114f * c->zoneborder[2] > 0.5f ? black : white; + +\tbox.y += (int)barpx; +\tbox.height -= (int)barpx; +\tresize(c, box, 0); + +\twlr_scene_node_destroy(&c->bar->node); +\tc->bar = wlr_scene_tree_create(c->scene); +\twlr_scene_node_set_position(&c->bar->node, 0, -(int)barpx); +\twlr_scene_rect_create(c->bar, box.width, (int)barpx, c->zoneborder); +\t/* One rect per run of lit pixels in a row of a glyph. */ +\tfor (i = 0, x = pad; name[i]; i++, x += 6 * s) { +\t\tfor (row = 0; row < 7; row++) { +\t\t\tfor (col = 0; col < 5; col += n + 1) { +\t\t\t\tfor (n = 0; col + n < 5 && ((zonefont[name[i] & 0x7f][row] >> (4 - col - n)) & 1); n++) +\t\t\t\t\t; +\t\t\t\tif (n) +\t\t\t\t\twlr_scene_node_set_position(&wlr_scene_rect_create(c->bar, +\t\t\t\t\t\t\tn * s, s, ink)->node, x + col * s, pad + row * s); +\t\t\t} +\t\t} +\t} +} + +""" + EDITS = [ - # (1) Client: the zone's colour and the band that marks it unfocused. + # (1) Client: the zone's colour, the band that marks it unfocused, the bar. (""" struct wlr_scene_rect *border[4]; /* top, bottom, left, right */ """, """ struct wlr_scene_rect *border[4]; /* top, bottom, left, right */ const float *zoneborder; /* Kryptik: chosen by zone from the app_id */ struct wlr_scene_tree *band; /* Kryptik: over the border's inner edge while unfocused */ struct wlr_scene_rect *bands[4]; /* top, bottom, left, right */ + struct wlr_scene_tree *bar; /* Kryptik: names the zone above a fullscreen window */ """), # (2) The ZoneColor type, beside Rule so config.h can define the table. ("""typedef struct { @@ -82,7 +176,7 @@ # (4) mapnotify: zone-coloured borders, then the band over them as four # strips. The surface stays below both, or a buffer larger than its # configure would paint over the right and bottom borders. A new window - # starts unfocused, so the band starts enabled. + # starts unfocused, so the band starts enabled; the bar starts hidden. (""" for (i = 0; i < 4; i++) { c->border[i] = wlr_scene_rect_create(c->scene, 0, 0, c->isurgent ? urgentcolor : bordercolor); @@ -100,6 +194,8 @@ c->bands[i] = wlr_scene_rect_create(c->band, 0, 0, rootcolor); c->bands[i]->node.data = c; } + c->bar = wlr_scene_tree_create(c->scene); + wlr_scene_node_set_enabled(&c->bar->node, 0); """), # (5) resize: the band is the ring of the border nearest the surface. (""" wlr_scene_node_set_position(&c->border[3]->node, c->geom.width - c->bw, c->bw); @@ -156,7 +252,7 @@ \telse \t\twl_list_insert(&fstack, &c->flink); """), - # Keep zone clients out of the float and fullscreen scene layers. + # Keep zone clients out of the float scene layer. ("""\t\tif (c->mon != m || c->scene->node.parent == layers[LyrFS]) \t\t\tcontinue; @@ -182,15 +278,66 @@ \tClient *p = client_get_parent(c); \tc->isfloating = c->zoneborder != unzonedcolor ? 0 : floating; """), - ("""setfullscreen(Client *c, int fullscreen) + # A zone's window reaches LyrFS, above the chrome, only fullscreen itself. + ("""\twlr_scene_node_reparent(&c->scene->node, layers[c->isfullscreen || +\t\t\t(p && p->isfullscreen) ? LyrFS +""", + """\t/* Kryptik: a zone's child stays out of LyrFS, where it would cover its parent's bar. */ +\twlr_scene_node_reparent(&c->scene->node, layers[c->isfullscreen || +\t\t\t(p && p->isfullscreen && c->zoneborder == unzonedcolor) ? LyrFS +"""), + # A zone's request may take its window out of fullscreen, never into it. + ("""\tClient *c = wl_container_of(listener, c, fullscreen); +\tsetfullscreen(c, client_wants_fullscreen(c)); +""", + """\tClient *c = wl_container_of(listener, c, fullscreen); +\t/* Kryptik: a zone window goes fullscreen only by the user's key. */ +\tsetfullscreen(c, client_wants_fullscreen(c) +\t\t\t&& (c->zoneborder == unzonedcolor || c->isfullscreen)); +"""), + # The bar, defined before setfullscreen, its first caller. + ("""void +setfullscreen(Client *c, int fullscreen) { -\tc->isfullscreen = fullscreen; """, - """setfullscreen(Client *c, int fullscreen) + ZONEBAR + """void +setfullscreen(Client *c, int fullscreen) { -\t/* Only zone 0 can use LyrFS, which sits above the trusted windows. */ -\tc->isfullscreen = c->zoneborder != unzonedcolor ? 0 : fullscreen; -\tfullscreen = c->isfullscreen; +"""), + # setfullscreen and updatemons: a fullscreen window is sized below its bar. + ("""\tif (fullscreen) { +\t\tc->prev = c->geom; +\t\tresize(c, c->mon->m, 0); +\t} else { +""", + """\twlr_scene_node_set_enabled(&c->bar->node, fullscreen); +\tif (fullscreen) { +\t\tc->prev = c->geom; +\t\tzonebar(c); +\t} else { +"""), + ("""\t\tif ((c = focustop(m)) && c->isfullscreen) +\t\t\tresize(c, m->m, 0); +""", + """\t\tif ((c = focustop(m)) && c->isfullscreen) +\t\t\tzonebar(c); +"""), + # xytonode: the pointer goes to what is on top. dwl looks on through lower + # layers past a border or background, which over a fullscreen window would + # hand clicks on its bar to another zone's window hidden below. + ("""\t\tif (c && c->type == LayerShell) { +\t\t\tc = NULL; +\t\t\tl = pnode->data; +\t\t} +\t} +""", + """\t\tif (c && c->type == LayerShell) { +\t\t\tc = NULL; +\t\t\tl = pnode->data; +\t\t} +\t\t/* Kryptik: never past the first thing drawn under the pointer. */ +\t\tbreak; +\t} """), # A zone mapping must not cancel another zone's fullscreen either. ("""\t\tif (w != c && w != p && w->isfullscreen && m == w->mon && (w->tags & c->tags)) diff --git a/tools/desktop/kryptik-chrome b/tools/desktop/kryptik-chrome index bf6cd219..a1007bfd 100755 --- a/tools/desktop/kryptik-chrome +++ b/tools/desktop/kryptik-chrome @@ -4,8 +4,10 @@ # Runs in zone 0 on the compositor directly, with no proxy in between: what it # shows comes from the compositor and the launch daemon, never from a zone, and # dwl gives its windows the UNZONED border (build/desktop/zone-colours.h). No -# zone can draw over it, read its input or capture it: the proxy passes no -# layer shell, global input or screencopy. +# zone can draw over it by itself, read its input or capture it: the proxy +# passes no layer shell, global input or screencopy, and a zone's window +# covers the chrome only fullscreen, by the user's Alt+e, until the chrome +# opens another window. # # kryptik-chrome dwl's startup command (`dwl -s`): reads # dwl's status stream on stdin, records the diff --git a/tools/desktop/wlprobe.c b/tools/desktop/wlprobe.c index de27b946..593db522 100644 --- a/tools/desktop/wlprobe.c +++ b/tools/desktop/wlprobe.c @@ -11,6 +11,9 @@ * buffer EXTRA px wider and taller than asked, in a * colour no zone has; stay SECONDS, titled TITLE * ("oversize" by default, at most 255 bytes) + * wlprobe fullscreen SECONDS + * as oversize 0, and once drawn ask for fullscreen; + * a commit says when its configure was fullscreen * wlprobe charge map one unwritten 4 MiB shmem buffer for 10 s; * compare zone and compositor cgroup memory.current * @@ -107,6 +110,7 @@ static int errored; * id must be the next unused one (the registry is 2, its sync 3). */ enum { COMPOSITOR = 4, SHM, WM_BASE, SURFACE, XDG_SURFACE, TOPLEVEL }; static int oversize, charge, drawn, draw_failed, extra, conf_w, conf_h, closed; +static int askfs, conf_fs; static uint32_t next_id = TOPLEVEL + 1; /* A buffer `extra` px wider and taller than the last configure asked for (a @@ -148,7 +152,8 @@ static void draw(void) put32(b, 0); put32(b + 4, 0); put32(b + 8, (uint32_t)w); put32(b + 12, (uint32_t)h); if (send_msg(SURFACE, 2, b, 16) || send_msg(SURFACE, 6, b, 0)) { draw_failed = closed = 1; return; } drawn = 1; - printf("committed %dx%d for a %dx%d configure%s\n", w, h, conf_w, conf_h, + printf("committed %dx%d for a %dx%d configure%s%s\n", w, h, conf_w, conf_h, + conf_fs ? " (fullscreen)" : "", charge ? " (unwritten shmem; sample memory.current now)" : ""); fflush(stdout); } @@ -190,6 +195,9 @@ static int handle_one(void) } else if (oversize && object == TOPLEVEL && opcode == 0) { conf_w = (int)get32(body); /* xdg_toplevel.configure(width, height, states) */ conf_h = (int)get32(body + 4); + conf_fs = 0; + for (uint32_t k = 0, len = get32(body + 8); k + 4 <= len && 16u + k <= size - 8u; k += 4) + if (get32(body + 12 + k) == 2) conf_fs = 1; /* xdg_toplevel.state.fullscreen */ } else if (oversize && object == TOPLEVEL && opcode == 1) { closed = 1; /* xdg_toplevel.close */ } else if (oversize && object == XDG_SURFACE && opcode == 0) { @@ -255,17 +263,26 @@ static int hold_oversize(int more, int seconds, const char *title) time_t end = time(NULL) + seconds; while (time(NULL) < end && !closed) { if (drain(500) < 0) { puts(errored ? "refused" : "connection closed"); return 3; } + if (askfs == 1 && drawn) { + put32(b, 0); /* no output: the compositor's choice */ + send_msg(TOPLEVEL, 11, b, 4); /* xdg_toplevel.set_fullscreen */ + askfs = 2; + puts("asked for fullscreen"); + fflush(stdout); + } } return draw_failed ? 1 : 0; } int main(int argc, char **argv) { - if (argc < 2 || (strcmp(argv[1], "list") && strcmp(argv[1], "bind") && strcmp(argv[1], "oversize") && strcmp(argv[1], "charge")) + if (argc < 2 || (strcmp(argv[1], "list") && strcmp(argv[1], "bind") && strcmp(argv[1], "oversize") + && strcmp(argv[1], "fullscreen") && strcmp(argv[1], "charge")) || (!strcmp(argv[1], "bind") && argc < 3) || (!strcmp(argv[1], "oversize") && argc < 4) || (!strcmp(argv[1], "oversize") && argc > 4 && strlen(argv[4]) > 255) + || (!strcmp(argv[1], "fullscreen") && argc != 3) || (!strcmp(argv[1], "charge") && argc != 2)) { - fprintf(stderr, "usage: wlprobe list | bind INTERFACE | oversize EXTRA SECONDS [TITLE] | charge\n"); + fprintf(stderr, "usage: wlprobe list | bind INTERFACE | oversize EXTRA SECONDS [TITLE] | fullscreen SECONDS | charge\n"); return 2; } const char *disp = getenv("WAYLAND_DISPLAY"); @@ -293,6 +310,7 @@ int main(int argc, char **argv) if (!strcmp(argv[1], "list")) return errored ? 3 : 0; if (!strcmp(argv[1], "oversize")) return hold_oversize(atoi(argv[2]), atoi(argv[3]), argc > 4 ? argv[4] : "oversize"); + if (!strcmp(argv[1], "fullscreen")) { askfs = 1; return hold_oversize(0, atoi(argv[2]), "fullscreen"); } if (!strcmp(argv[1], "charge")) { charge = 1; return hold_oversize(0, 10, "shm-charge"); } /* A filtered client cannot know a hidden global's name, so guess 1; the diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 4dd88c17..34b621a0 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -11,9 +11,9 @@ # What the host adds to the guest's verdicts: screenshots in which each # window's frame is measured on all four sides, in its zone's colour from # build/desktop/zone-colours.h (full width focused, narrower by the band -# unfocused), windowed, after a refused fullscreen request and around a buffer -# larger than its window; explicit focus (Alt+j), fullscreen refusal (Alt+e) -# and the yes/no to the transfer +# unfocused), windowed, fullscreen under the bar that names its zone, and +# around a buffer larger than its window; explicit focus (Alt+j), fullscreen +# on and off (Alt+e) and the yes/no to the transfer # questions, delivered as keystrokes on the guest's keyboard, so the # trusted windows are exercised by input, not by writing answer files. set -uo pipefail @@ -83,21 +83,21 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - map-keeps-zone0-focus focus-shows-zone focus-shows-label title-prefixed last-zone-recorded menu-opens-on-key menu-keeps-last-zone zone-fullscreen-refused compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + zone0-fullscreen-granted zone-fullscreen-refused map-keeps-zone0-focus focus-shows-zone focus-shows-label title-prefixed last-zone-recorded fullscreen-by-key menu-opens-on-key menu-keeps-last-zone compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent; do grep -q "GT PASS ${name}" <<<"$T" && green "guest: ${name}" || red "guest: ${name} (not passed)" done # ----------------------------------------------------------------- step 3 -- step "step 3: the screenshots show every window framed on all four sides" -check_shot() { # check_shot FILE WHAT ZONE:focused|unfocused... +check_shot() { # check_shot FILE WHAT ZONE:focused|unfocused|fullscreen... local shot="$1" what="$2" verdict shift 2 if [[ -s "$shot" ]]; then - # The colours and widths dwl was built with: the headers are the single source. - verdict="$(python3 - "$shot" "${SELF}/../../build/desktop/zone-colours.h" "${SELF}/../../build/desktop/dwl-config.h" "$@" <<'PY' -import re, sys -shot, colours_h, config_h, *want = sys.argv[1:] + # The colours, widths and lettering dwl was built with: its inputs are the single source. + verdict="$(python3 - "$shot" "${SELF}/../../build/desktop/zone-colours.h" "${SELF}/../../build/desktop/dwl-config.h" "${SELF}/../desktop/dwl-zone-borders.py" "$@" <<'PY' +import importlib.util, re, sys +shot, colours_h, config_h, borders_py, *want = sys.argv[1:] h = open(colours_h).read() c = open(config_h).read() named = {z: bytes.fromhex(v) for z, v in re.findall(r'X\("(\w+)",\s*0x([0-9a-f]{6})ff\)', h)} @@ -105,6 +105,12 @@ named["unzoned"] = bytes.fromhex(re.search(r'KRYPTIK_UNZONED_BORDER\s+0x([0-9a-f root = bytes.fromhex(re.search(r'rootcolor\[\]\s*=\s*COLOR\(0x([0-9a-f]{6})ff\)', c).group(1)) bw = int(re.search(r'\bborderpx\s*=\s*(\d+)', c).group(1)) band = int(re.search(r'\bbandpx\s*=\s*(\d+)', c).group(1)) +barpx = int(re.search(r'\bbarpx\s*=\s*(\d+)', c).group(1)) +scale = int(re.search(r'\bbarscale\s*=\s*(\d+)', c).group(1)) +src = importlib.util.spec_from_file_location("borders", borders_py) +borders = importlib.util.module_from_spec(src) +src.loader.exec_module(borders) +font = {ch: rows.split() for ch, rows in borders.FONT.items()} data = open(shot, "rb").read() # P6: magic, width, height, maxval (comments allowed), one whitespace, then pixels tokens = []; pos = 0 @@ -152,6 +158,20 @@ def frame(col): "left": run(x, ym, 1, 0, col), "right": run(x1, ym, -1, 0, col)} return None, None +def bar(name, col): + """The top barpx rows over a fullscreen window as dwl draws them: the zone's + colour, and its name in capitals, black on a light colour, white on a dark.""" + ink = bytes(3) if (0.299 * col[0] + 0.587 * col[1] + 0.114 * col[2]) / 255 > 0.5 else b"\xff" * 3 + pad = (barpx - 7 * scale) // 2 + lit = set() + for i, ch in enumerate(name): + for r, bits in enumerate(font.get(ch, ["00000"] * 7)): + for k, b in enumerate(bits): + if b == "1": + lit |= {(pad + 6 * scale * i + scale * k + dx, pad + scale * r + dy) + for dx in range(scale) for dy in range(scale)} + return [[ink if (x, y) in lit else col for x in range(w)] for y in range(barpx)], ink + ok = True for spec in want: zone, state = spec.split(":") @@ -161,6 +181,20 @@ for spec in want: print(f" {zone} ({state}): no frame in #{col.hex()} on screen") ok = False continue + if state == "fullscreen": + rows, ink = bar("zone 0" if zone == "unzoned" else zone, col) + wrong = sum(at(x, y) != rows[y][x] for y in range(barpx) for x in range(w)) + good = (box == (0, 0, w - 1, hgt - 1) and sides["top"] == barpx + bw and not wrong + and sides["bottom"] == sides["left"] == sides["right"] == bw) + print(f" {zone} (fullscreen): frame {box[0]},{box[1]}-{box[2]},{box[3]} on {w}x{hgt}: top {sides['top']} bottom {sides['bottom']}" + f" left {sides['left']} right {sides['right']} px, want the whole screen, {barpx} bar + {bw} on top and {bw} elsewhere;" + f" bar: {wrong} px unlike its name{'' if good else ' <- WRONG'}") + if wrong: + # The bar's left end as seen: # ink, . the zone's colour, ? anything else. + for y in range(barpx): + print(" " + "".join("#" if at(x, y) == ink else "." if at(x, y) == col else "?" for x in range(min(w, 150)))) + ok = ok and good + continue expect = bw if state == "focused" else bw - band good = all(v == expect for v in sides.values()) if state == "unfocused" and good: @@ -177,14 +211,14 @@ print("FRAME-OK" if ok else "FRAME-BAD") PY )" printf '%s\n' "$verdict" | grep -v 'FRAME-' - [[ "$verdict" == *FRAME-OK* ]] && green "${what}: every window is framed in its zone's colour, at its width, on all four sides (${shot})" || red "${what}: a window's frame is missing or the wrong width (${shot})" + [[ "$verdict" == *FRAME-OK* ]] && green "${what}: every window is framed in its zone's colour, at its width, on all four sides (${shot})" || red "${what}: a window's frame or bar is missing or wrong (${shot})" else red "${what}: no screenshot was taken" fi } check_shot "$SHOT" "windowed" untrusted:focused unzoned:unfocused -# A zone's fullscreen request leaves it tiled beside the trusted chrome. -check_shot "$SHOT_FS" "fullscreen refused" untrusted:focused unzoned:unfocused +# Alt+e: the window fills the screen below a bar that names its zone. +check_shot "$SHOT_FS" "fullscreen" untrusted:fullscreen # wlprobe oversize commits a buffer 40 px larger than its configure: the # borders must stay above the surface, or its excess covers them. check_shot "$SHOT_OVER" "oversized buffer" untrusted:focused unzoned:unfocused From 6c499d794023d8cb7df61e19e90f72ea8d47765d Mon Sep 17 00:00:00 2001 From: DevomB Date: Fri, 2 Oct 2026 02:35:16 -0700 Subject: [PATCH 2/6] The bar names a zone only from an app_id that carries the kryptik. prefix, which also keeps the compiler from reading past dwl's fallback app_id --- tools/desktop/dwl-zone-borders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index f2b0077a..9a994975 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -75,7 +75,7 @@ \tsize_t i; \tzonecolors(c); -\tif (c->zoneborder != unzonedcolor) +\tif (c->zoneborder != unzonedcolor && strncmp(id, "kryptik.", 8) == 0) \t\tsnprintf(name, sizeof(name), "%.*s", (int)strcspn(id + 8, "."), id + 8); \tink = 0.299f * c->zoneborder[0] + 0.587f * c->zoneborder[1] \t\t\t+ 0.114f * c->zoneborder[2] > 0.5f ? black : white; From 55543a6fbcc22886af9df2afee4af6a0d44217c1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Tue, 6 Oct 2026 21:25:55 -0700 Subject: [PATCH 3/6] A fullscreen zone window keeps the focus from the windows it hides: the focus keys skip what a fullscreen window covers, and a child the zone opens ends its parent's fullscreen, since nothing of the zone's is drawn above the bar --- build/guest-tests/gui-check.sh | 46 +++++++++++++++ docs/architecture.md | 4 +- tools/desktop/dwl-zone-borders.py | 75 +++++++++++++++++++++++- tools/desktop/wlprobe.c | 95 +++++++++++++++++++++++++------ tools/image/gui-test.sh | 7 ++- 5 files changed, 206 insertions(+), 21 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 42bacadd..813970b3 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -6,6 +6,9 @@ # GT KEY-FOCUS-ZONE, GT KEY-FOCUS-OVERSIZE, # GT KEY-FOCUS-FORGED, GT KEY-FOCUS-PERSONAL press Alt+j (explicit focus) # GT KEY-FULLSCREEN, GT KEY-FULLSCREEN-AGAIN press Alt+e (fullscreen, then back) +# GT KEY-FOCUS-PARENT, GT KEY-FOCUS-BELOW press Alt+j +# GT KEY-PARENT-FULLSCREEN, GT KEY-PARENT-WINDOWED, +# GT KEY-LATE-FULLSCREEN press Alt+e # GT KEY-MENU press Alt+p (the chrome menu) # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) # GT CONSENT-WAIT 2 type y and Enter (not the code: refused) @@ -157,6 +160,49 @@ echo "GT SCREENSHOT-FULLSCREEN" sleep 6 echo "GT KEY-FULLSCREEN-AGAIN" wait_for 20 grep -q '^fullscreen=0' "$RT/kryptik/focus" && pass "fullscreen-off-again" || fail "fullscreen-off-again" +# --- a fullscreen zone window keeps the focus from what it hides ------------- +# wlprobe child maps a window and a child of it; a zone's child is tiled, +# never drawn above its parent. Alt+j walks the focus between the visible +# windows, and from the fullscreen parent it must find none: every other +# window on the output is hidden below it, some of them other zones'. +focus_is() { grep -q "^title=\[untrusted\] $1\$" "$RT/kryptik/focus" 2>/dev/null && grep -q "^fullscreen=$2" "$RT/kryptik/focus"; } +probe_gone() { ! pgrep -f 'wlprobe child' > /dev/null; } +child_ready() { since_mark child untrusted | grep -q 'child committed'; } +mark child untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe child 30" > "$LOG/launch-child.out" 2>&1 +if wait_for 20 child_ready; then + pass "zone-child-mapped" "$(since_mark child untrusted | grep -c committed) commits" +else + fail "zone-child-mapped" "$(since_mark child untrusted | tail -3 | tr '\n' ' '); $(tr '\n' ' ' < "$LOG/launch-child.out")" +fi +sleep 1 +echo "GT KEY-FOCUS-PARENT" +wait_for 10 focus_is child-parent 0 && pass "parent-focused" "$(tr '\n' ' ' < "$RT/kryptik/focus")" || fail "parent-focused" "focus after Alt+j: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +echo "GT KEY-PARENT-FULLSCREEN" +wait_for 10 focus_is child-parent 1 && pass "parent-fullscreen" || fail "parent-fullscreen" "focus after Alt+e: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +echo "GT KEY-FOCUS-BELOW" +sleep 3 +if focus_is child-parent 1; then + pass "fullscreen-keeps-focus" "Alt+j left the focus on the fullscreen window" +else + fail "fullscreen-keeps-focus" "Alt+j moved the focus to a hidden window: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +fi +echo "GT KEY-PARENT-WINDOWED" +wait_for 10 focus_is child-parent 0 && pass "parent-windowed-again" || fail "parent-windowed-again" "$(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +wait_for 40 probe_gone +# A child that maps under its fullscreen parent ends the fullscreen: the +# zone cannot have it drawn above the bar, so both are shown tiled instead. +late_done() { since_mark late untrusted | sed -n '/asked for a child/,$p' | grep committed | grep -v child | grep -qv '(fullscreen)'; } +mark late untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe child 20 late" > "$LOG/launch-late.out" 2>&1 +wait_for 20 focus_is child-parent 0 +echo "GT KEY-LATE-FULLSCREEN" +if wait_for 20 late_done && wait_for 10 grep -q '^fullscreen=0' "$RT/kryptik/focus"; then + pass "child-ends-fullscreen" "$(since_mark late untrusted | grep -E 'asked for a child|committed' | tail -3 | tr '\n' ' ')" +else + fail "child-ends-fullscreen" "$(since_mark late untrusted | tail -4 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +fi +wait_for 30 probe_gone # Alt+p opens one more menu window: the chrome's text menu in a zone 0 # terminal of its own, as at login. Closed again once seen. menu_windows() { pgrep -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' | wc -l; } diff --git a/docs/architecture.md b/docs/architecture.md index 37eb5ce1..477bca94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,7 +62,9 @@ zones have failed them. A fullscreen window keeps its border, and the compositor adds a bar across the top of the output in the zone's colour, with the zone's name, outside the window's frame, where its surfaces are clipped away. Only the user's key makes a zone's window fullscreen, never -the program's own request. +the program's own request. While it is fullscreen the focus keys do not +reach the windows hidden below it, and a child window the zone opens ends +the fullscreen, since nothing of the zone's is drawn above the bar. `zoneid audit` checks that every pair of zones stays distinguishable. ## Storage diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index 9a994975..a9b8857c 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -339,13 +339,84 @@ \t\tbreak; \t} """), - # A zone mapping must not cancel another zone's fullscreen either. + # A zone's child cannot be drawn above its fullscreen parent (it stays in + # the tile layer), so it ends that fullscreen; zone 0's child follows its + # parent up, as dwl has it. A zone mapping must not cancel another zone's + # fullscreen either. ("""\t\tif (w != c && w != p && w->isfullscreen && m == w->mon && (w->tags & c->tags)) \t\t\tsetfullscreen(w, 0); """, - """\t\tif (w != c && w != p && w->isfullscreen && m == w->mon && (w->tags & c->tags) + """\t\tif (w != c && (w != p || w->zoneborder != unzonedcolor) && w->isfullscreen && m == w->mon && (w->tags & c->tags) \t\t\t\t&& (c->zoneborder == unzonedcolor || w->zoneborder == c->zoneborder)) \t\t\tsetfullscreen(w, 0); +"""), + # A fullscreen window covers the tile and float layers, so while one shows + # only its own layer is on screen. The focus never walks to what is hidden: + # focustop and focusstack skip covered windows. Defined before focusstack, + # the first of the two. + ("""void +focusstack(const Arg *arg) +{ +""", + """/* Kryptik: with a fullscreen window on the monitor, a window in any other + * layer is hidden below it and must not take the focus. */ +static int +covered(Client *c, Monitor *m) +{ +\tClient *w; +\tif (c->scene->node.parent == layers[LyrFS]) +\t\treturn 0; +\twl_list_for_each(w, &clients, link) +\t\tif (w != c && VISIBLEON(w, m) && w->scene->node.parent == layers[LyrFS]) +\t\t\treturn 1; +\treturn 0; +} + +void +focusstack(const Arg *arg) +{ +"""), + ("""\tif (arg->i > 0) { +\t\twl_list_for_each(c, &sel->link, link) { +\t\t\tif (&c->link == &clients) +\t\t\t\tcontinue; /* wrap past the sentinel node */ +\t\t\tif (VISIBLEON(c, selmon)) +\t\t\t\tbreak; /* found it */ +\t\t} +\t} else { +\t\twl_list_for_each_reverse(c, &sel->link, link) { +\t\t\tif (&c->link == &clients) +\t\t\t\tcontinue; /* wrap past the sentinel node */ +\t\t\tif (VISIBLEON(c, selmon)) +\t\t\t\tbreak; /* found it */ +\t\t} +\t} +""", + """\tif (arg->i > 0) { +\t\twl_list_for_each(c, &sel->link, link) { +\t\t\tif (&c->link == &clients) +\t\t\t\tcontinue; /* wrap past the sentinel node */ +\t\t\tif (VISIBLEON(c, selmon) && !covered(c, selmon)) +\t\t\t\tbreak; /* found it */ +\t\t} +\t} else { +\t\twl_list_for_each_reverse(c, &sel->link, link) { +\t\t\tif (&c->link == &clients) +\t\t\t\tcontinue; /* wrap past the sentinel node */ +\t\t\tif (VISIBLEON(c, selmon) && !covered(c, selmon)) +\t\t\t\tbreak; /* found it */ +\t\t} +\t} +"""), + ("""\twl_list_for_each(c, &fstack, flink) { +\t\tif (VISIBLEON(c, m)) +\t\t\treturn c; +\t} +""", + """\twl_list_for_each(c, &fstack, flink) { +\t\tif (VISIBLEON(c, m) && !covered(c, m)) +\t\t\treturn c; +\t} """), # setmon also chooses focus after mapping; preserve another zone's actual # keyboard focus even when the selected monitor has changed. diff --git a/tools/desktop/wlprobe.c b/tools/desktop/wlprobe.c index 593db522..2054fb6f 100644 --- a/tools/desktop/wlprobe.c +++ b/tools/desktop/wlprobe.c @@ -14,7 +14,11 @@ * wlprobe fullscreen SECONDS * as oversize 0, and once drawn ask for fullscreen; * a commit says when its configure was fullscreen - * wlprobe charge map one unwritten 4 MiB shmem buffer for 10 s; + * wlprobe child SECONDS [late] + * as oversize 0, and once drawn map a second window + * that is a child of the first; with late, only + * once the first's configure was fullscreen + * wlprobe charge map one unwritten 4 MiB shmem buffer for 10 s; * compare zone and compositor cgroup memory.current * * Exit: 0 listed, bind accepted or window held; 3 refused (wl_display.error, @@ -111,17 +115,15 @@ static int errored; enum { COMPOSITOR = 4, SHM, WM_BASE, SURFACE, XDG_SURFACE, TOPLEVEL }; static int oversize, charge, drawn, draw_failed, extra, conf_w, conf_h, closed; static int askfs, conf_fs; +/* child: 1 wanted once drawn, 2 wanted once fullscreen, 3 mapped; its objects. */ +static int child, cconf_w, cconf_h; +static uint32_t csurface, cxdg, ctoplevel; static uint32_t next_id = TOPLEVEL + 1; -/* A buffer `extra` px wider and taller than the last configure asked for (a - * configure of 0 x 0 means the client chooses: 300 x 200), in magenta, which - * is no zone's colour. dwl clips a surface only to (w - bw) x (h - bw), so - * the excess lies under the right and bottom borders. */ -static void draw(void) +/* A w x h buffer in magenta, which is no zone's colour, attached and + * committed on a surface. A failure closes the probe. */ +static int blit(uint32_t surface, int w, int h) { - if (charge && drawn) return; /* one sparse pool for one measurement */ - int w = charge ? 1024 : (conf_w > 0 ? conf_w : 300) + extra; - int h = charge ? 1024 : (conf_h > 0 ? conf_h : 200) + extra; int stride = w * 4; size_t size = (size_t)stride * (size_t)h; int fd = memfd_create("wlprobe", MFD_CLOEXEC); @@ -129,11 +131,11 @@ static void draw(void) printf("memfd: %s\n", strerror(errno)); if (fd >= 0) close(fd); draw_failed = closed = 1; - return; + return -1; } if (!charge) { uint32_t *px = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (px == MAP_FAILED) { printf("mmap: %s\n", strerror(errno)); close(fd); draw_failed = closed = 1; return; } + if (px == MAP_FAILED) { printf("mmap: %s\n", strerror(errno)); close(fd); draw_failed = closed = 1; return -1; } for (size_t i = 0; i < size / 4; i++) px[i] = 0x00ff00ff; munmap(px, size); } @@ -143,14 +145,27 @@ static void draw(void) put32(b + 4, (uint32_t)size); int r = send_msg_fd(SHM, 0, b, 8, fd); /* wl_shm.create_pool(id, fd, size) */ close(fd); - if (r) { printf("create_pool: %s\n", strerror(errno)); draw_failed = closed = 1; return; } + if (r) { printf("create_pool: %s\n", strerror(errno)); draw_failed = closed = 1; return -1; } put32(b, buffer); put32(b + 4, 0); put32(b + 8, (uint32_t)w); put32(b + 12, (uint32_t)h); put32(b + 16, (uint32_t)stride); put32(b + 20, 1); - if (send_msg(pool, 0, b, 24)) { draw_failed = closed = 1; return; } /* wl_shm_pool.create_buffer */ + if (send_msg(pool, 0, b, 24)) { draw_failed = closed = 1; return -1; } /* wl_shm_pool.create_buffer */ put32(b, buffer); put32(b + 4, 0); put32(b + 8, 0); - if (send_msg(SURFACE, 1, b, 12)) { draw_failed = closed = 1; return; } /* wl_surface.attach */ + if (send_msg(surface, 1, b, 12)) { draw_failed = closed = 1; return -1; } /* wl_surface.attach */ put32(b, 0); put32(b + 4, 0); put32(b + 8, (uint32_t)w); put32(b + 12, (uint32_t)h); - if (send_msg(SURFACE, 2, b, 16) || send_msg(SURFACE, 6, b, 0)) { draw_failed = closed = 1; return; } + if (send_msg(surface, 2, b, 16) || send_msg(surface, 6, b, 0)) { draw_failed = closed = 1; return -1; } + return 0; +} + +/* The window's buffer: `extra` px wider and taller than the last configure + * asked for (a configure of 0 x 0 means the client chooses: 300 x 200). dwl + * clips a surface only to (w - bw) x (h - bw), so the excess lies under the + * right and bottom borders. */ +static void draw(void) +{ + if (charge && drawn) return; /* one sparse pool for one measurement */ + int w = charge ? 1024 : (conf_w > 0 ? conf_w : 300) + extra; + int h = charge ? 1024 : (conf_h > 0 ? conf_h : 200) + extra; + if (blit(SURFACE, w, h)) return; drawn = 1; printf("committed %dx%d for a %dx%d configure%s%s\n", w, h, conf_w, conf_h, conf_fs ? " (fullscreen)" : "", @@ -158,6 +173,40 @@ static void draw(void) fflush(stdout); } +static void draw_child(void) +{ + int w = cconf_w > 0 ? cconf_w : 300, h = cconf_h > 0 ? cconf_h : 200; + if (blit(csurface, w, h)) return; + printf("child committed %dx%d\n", w, h); + fflush(stdout); +} + +/* A second toplevel, a child of the first. dwl draws zone 0's above a + * fullscreen parent; a zone's would cover its parent's bar, so the + * compositor must keep it below or end the fullscreen. */ +static void open_child(void) +{ + unsigned char b[32]; + size_t n; + csurface = next_id++; cxdg = next_id++; ctoplevel = next_id++; + put32(b, csurface); + send_msg(COMPOSITOR, 0, b, 4); /* wl_compositor.create_surface */ + put32(b, cxdg); put32(b + 4, csurface); + send_msg(WM_BASE, 2, b, 8); /* xdg_wm_base.get_xdg_surface */ + put32(b, ctoplevel); + send_msg(cxdg, 1, b, 4); /* xdg_surface.get_toplevel */ + put32(b, TOPLEVEL); + send_msg(ctoplevel, 1, b, 4); /* xdg_toplevel.set_parent */ + n = put_string(b, "child"); + send_msg(ctoplevel, 2, b, n); /* xdg_toplevel.set_title */ + n = put_string(b, "wlprobe"); + send_msg(ctoplevel, 3, b, n); /* xdg_toplevel.set_app_id */ + send_msg(csurface, 6, b, 0); /* wl_surface.commit: ask for a configure */ + child = 3; + puts("asked for a child"); + fflush(stdout); +} + /* Consume one message if present. Returns 1 consumed, 0 need more, -1 malformed. */ static int handle_one(void) { @@ -205,6 +254,14 @@ static int handle_one(void) put32(b, get32(body)); send_msg(XDG_SURFACE, 4, b, 4); /* xdg_surface.ack_configure */ draw(); + } else if (child && object == ctoplevel && opcode == 0) { + cconf_w = (int)get32(body); /* the child's own configure */ + cconf_h = (int)get32(body + 4); + } else if (child && object == cxdg && opcode == 0) { + unsigned char b[4]; + put32(b, get32(body)); + send_msg(cxdg, 4, b, 4); /* xdg_surface.ack_configure */ + draw_child(); } else if (!oversize) { printf("event object=%u opcode=%u size=%u\n", object, opcode, size); } @@ -270,6 +327,8 @@ static int hold_oversize(int more, int seconds, const char *title) puts("asked for fullscreen"); fflush(stdout); } + if ((child == 1 && drawn) || (child == 2 && drawn && conf_fs)) + open_child(); } return draw_failed ? 1 : 0; } @@ -277,12 +336,13 @@ static int hold_oversize(int more, int seconds, const char *title) int main(int argc, char **argv) { if (argc < 2 || (strcmp(argv[1], "list") && strcmp(argv[1], "bind") && strcmp(argv[1], "oversize") - && strcmp(argv[1], "fullscreen") && strcmp(argv[1], "charge")) + && strcmp(argv[1], "fullscreen") && strcmp(argv[1], "child") && strcmp(argv[1], "charge")) || (!strcmp(argv[1], "bind") && argc < 3) || (!strcmp(argv[1], "oversize") && argc < 4) || (!strcmp(argv[1], "oversize") && argc > 4 && strlen(argv[4]) > 255) || (!strcmp(argv[1], "fullscreen") && argc != 3) + || (!strcmp(argv[1], "child") && (argc < 3 || argc > 4 || (argc == 4 && strcmp(argv[3], "late")))) || (!strcmp(argv[1], "charge") && argc != 2)) { - fprintf(stderr, "usage: wlprobe list | bind INTERFACE | oversize EXTRA SECONDS [TITLE] | fullscreen SECONDS | charge\n"); + fprintf(stderr, "usage: wlprobe list | bind INTERFACE | oversize EXTRA SECONDS [TITLE] | fullscreen SECONDS | child SECONDS [late] | charge\n"); return 2; } const char *disp = getenv("WAYLAND_DISPLAY"); @@ -311,6 +371,7 @@ int main(int argc, char **argv) if (!strcmp(argv[1], "list")) return errored ? 3 : 0; if (!strcmp(argv[1], "oversize")) return hold_oversize(atoi(argv[2]), atoi(argv[3]), argc > 4 ? argv[4] : "oversize"); if (!strcmp(argv[1], "fullscreen")) { askfs = 1; return hold_oversize(0, atoi(argv[2]), "fullscreen"); } + if (!strcmp(argv[1], "child")) { child = argc == 4 ? 2 : 1; return hold_oversize(0, atoi(argv[2]), "child-parent"); } if (!strcmp(argv[1], "charge")) { charge = 1; return hold_oversize(0, 10, "shm-charge"); } /* A filtered client cannot know a hidden global's name, so guess 1; the diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 34b621a0..ea3aebf4 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -63,6 +63,11 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:GT KEY-FULLSCREEN\r?\n" "key:alt+e" \ "expect:GT SCREENSHOT-FULLSCREEN" "sleep:2" "screendump:${SHOT_FS}" \ "expect:GT KEY-FULLSCREEN-AGAIN" "key:alt+e" \ + "expect:GT KEY-FOCUS-PARENT" "key:alt+j" \ + "expect:GT KEY-PARENT-FULLSCREEN" "key:alt+e" \ + "expect:GT KEY-FOCUS-BELOW" "key:alt+j" \ + "expect:GT KEY-PARENT-WINDOWED" "key:alt+e" \ + "expect:GT KEY-LATE-FULLSCREEN" "key:alt+e" \ "expect:GT KEY-MENU" "key:alt+p" \ "expect:GT KEY-FOCUS-OVERSIZE" "key:alt+j" \ "expect:GT SCREENSHOT-OVERSIZE" "sleep:2" "screendump:${SHOT_OVER}" \ @@ -83,7 +88,7 @@ gp="$(sed -n 's/.*passed=\([0-9]*\).*/\1/p' <<<"$summary")"; gf="$(sed -n 's/.*f if [[ -n "$summary" && "${gf:-1}" -eq 0 && "${gp:-0}" -ge 25 ]]; then green "every guest check passed (${gp})"; else red "guest checks: ${gp:-0} passed, ${gf:-?} failed"; fi grep 'GT FAIL' <<<"$T" | sed 's/^/ /' for name in session-socket compositor-running chrome-focus-record chrome-window-is-zone0 zone0-sees-capture zone-proxy-path zone-sees-needed zone-hidden-globals zone-bind-refused proxy-logged-refusal \ - zone0-fullscreen-granted zone-fullscreen-refused map-keeps-zone0-focus focus-shows-zone focus-shows-label title-prefixed last-zone-recorded fullscreen-by-key menu-opens-on-key menu-keeps-last-zone compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ + zone0-fullscreen-granted zone-fullscreen-refused map-keeps-zone0-focus focus-shows-zone focus-shows-label title-prefixed last-zone-recorded fullscreen-by-key zone-child-mapped parent-focused parent-fullscreen fullscreen-keeps-focus parent-windowed-again child-ends-fullscreen menu-opens-on-key menu-keeps-last-zone compositor-survives-close oversize-window forged-title-named-by-zone second-zone-window zone0-own-programs-only zone-app-in-cgroup no-virtual-input clipboard-isolated clipboard-move-gesture clipboard-moved \ transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent; do grep -q "GT PASS ${name}" <<<"$T" && green "guest: ${name}" || red "guest: ${name} (not passed)" done From 73dcf35790b4dd5ea1a3e60dda185dc4d78662e0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 01:22:41 -0700 Subject: [PATCH 4/6] The child probe starts the zone itself and the focus is walked to it from zone 0: a running zone takes one program through kryptik-launch, so the section runs before the untrusted terminal, and Alt+j reaches the newest window first --- build/guest-tests/gui-check.sh | 97 +++++++++++++++++++--------------- tools/image/gui-test.sh | 14 ++--- 2 files changed, 61 insertions(+), 50 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 7a2a20e5..70065284 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -6,7 +6,8 @@ # GT KEY-FOCUS-ZONE, GT KEY-FOCUS-OVERSIZE, # GT KEY-FOCUS-FORGED, GT KEY-FOCUS-PERSONAL press Alt+j (explicit focus) # GT KEY-FULLSCREEN, GT KEY-FULLSCREEN-AGAIN press Alt+e (fullscreen, then back) -# GT KEY-FOCUS-PARENT, GT KEY-FOCUS-BELOW press Alt+j +# GT KEY-FOCUS-CHILD, GT KEY-FOCUS-PARENT, +# GT KEY-FOCUS-BELOW, GT KEY-FOCUS-LATE press Alt+j # GT KEY-PARENT-FULLSCREEN, GT KEY-PARENT-WINDOWED, # GT KEY-LATE-FULLSCREEN press Alt+e # GT KEY-MENU press Alt+p (the chrome menu) @@ -128,6 +129,57 @@ else fail "zone-fullscreen-refused" "$(echo "$out" | tail -4 | tr '\n' ' '); $(tr '\n' ' ' < "$LOG/launch-fullscreen.out")" fi +# --- a fullscreen zone window keeps the focus from what it hides ------------- +# wlprobe child maps a window and a child of it, both tiled: a zone's child +# is never drawn above its parent. The zone starts for the probe, so its +# windows take no focus from zone 0, and Alt+j walks there, newest window +# first. From the fullscreen parent, Alt+j must then find nothing: every +# other window is hidden below it, zone 0's and other zones' alike. +focus_is() { grep -q "^title=\[untrusted\] $1\$" "$RT/kryptik/focus" 2>/dev/null && grep -q "^fullscreen=$2" "$RT/kryptik/focus"; } +zone_gone() { test ! -e /run/kryptik/zones/untrusted/init.pid; } +child_ready() { since_mark child untrusted | grep -q 'child committed'; } +mark child untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe child 45" > "$LOG/launch-child.out" 2>&1 +if wait_for 20 child_ready; then + pass "zone-child-mapped" "$(since_mark child untrusted | grep -c committed) commits" +else + fail "zone-child-mapped" "$(since_mark child untrusted | tail -3 | tr '\n' ' '); $(tr '\n' ' ' < "$LOG/launch-child.out")" +fi +sleep 1 +echo "GT KEY-FOCUS-CHILD" +wait_for 10 focus_is child 0 && pass "child-focused" "$(tr '\n' ' ' < "$RT/kryptik/focus")" || fail "child-focused" "focus after Alt+j: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +echo "GT KEY-FOCUS-PARENT" +wait_for 10 focus_is child-parent 0 && pass "parent-focused" "$(tr '\n' ' ' < "$RT/kryptik/focus")" || fail "parent-focused" "focus after Alt+j: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +echo "GT KEY-PARENT-FULLSCREEN" +wait_for 10 focus_is child-parent 1 && pass "parent-fullscreen" || fail "parent-fullscreen" "focus after Alt+e: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +echo "GT KEY-FOCUS-BELOW" +sleep 3 +if focus_is child-parent 1; then + pass "fullscreen-keeps-focus" "Alt+j left the focus on the fullscreen window" +else + fail "fullscreen-keeps-focus" "Alt+j moved the focus to a hidden window: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +fi +echo "GT KEY-PARENT-WINDOWED" +wait_for 10 focus_is child-parent 0 && pass "parent-windowed-again" || fail "parent-windowed-again" "$(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +wait_for 60 zone_gone +# A child that maps under its fullscreen parent ends the fullscreen: the +# zone cannot have it drawn above the bar, so both are shown tiled instead. +late_drawn() { since_mark late untrusted | grep -q committed; } +late_done() { since_mark late untrusted | sed -n '/asked for a child/,$p' | grep committed | grep -v child | grep -qv '(fullscreen)'; } +mark late untrusted +launch_plain untrusted "/usr/libexec/kryptik/wlprobe child 25 late" > "$LOG/launch-late.out" 2>&1 +wait_for 20 late_drawn +sleep 1 +echo "GT KEY-FOCUS-LATE" +wait_for 10 focus_is child-parent 0 +echo "GT KEY-LATE-FULLSCREEN" +if wait_for 20 late_done && wait_for 10 grep -q '^fullscreen=0' "$RT/kryptik/focus"; then + pass "child-ends-fullscreen" "$(since_mark late untrusted | grep -E 'asked for a child|committed' | tail -3 | tr '\n' ' ')" +else + fail "child-ends-fullscreen" "$(since_mark late untrusted | tail -4 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +fi +wait_for 40 zone_gone + # --- a mapped zone window cannot take the chrome's focus ---------------------- mark map untrusted launch_plain untrusted "/usr/libexec/kryptik/wlprobe oversize 0 8 map-focus" > "$LOG/map-focus.out" 2>&1 @@ -166,49 +218,6 @@ echo "GT SCREENSHOT-FULLSCREEN" sleep 6 echo "GT KEY-FULLSCREEN-AGAIN" wait_for 20 grep -q '^fullscreen=0' "$RT/kryptik/focus" && pass "fullscreen-off-again" || fail "fullscreen-off-again" -# --- a fullscreen zone window keeps the focus from what it hides ------------- -# wlprobe child maps a window and a child of it; a zone's child is tiled, -# never drawn above its parent. Alt+j walks the focus between the visible -# windows, and from the fullscreen parent it must find none: every other -# window on the output is hidden below it, some of them other zones'. -focus_is() { grep -q "^title=\[untrusted\] $1\$" "$RT/kryptik/focus" 2>/dev/null && grep -q "^fullscreen=$2" "$RT/kryptik/focus"; } -probe_gone() { ! pgrep -f 'wlprobe child' > /dev/null; } -child_ready() { since_mark child untrusted | grep -q 'child committed'; } -mark child untrusted -launch_plain untrusted "/usr/libexec/kryptik/wlprobe child 30" > "$LOG/launch-child.out" 2>&1 -if wait_for 20 child_ready; then - pass "zone-child-mapped" "$(since_mark child untrusted | grep -c committed) commits" -else - fail "zone-child-mapped" "$(since_mark child untrusted | tail -3 | tr '\n' ' '); $(tr '\n' ' ' < "$LOG/launch-child.out")" -fi -sleep 1 -echo "GT KEY-FOCUS-PARENT" -wait_for 10 focus_is child-parent 0 && pass "parent-focused" "$(tr '\n' ' ' < "$RT/kryptik/focus")" || fail "parent-focused" "focus after Alt+j: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" -echo "GT KEY-PARENT-FULLSCREEN" -wait_for 10 focus_is child-parent 1 && pass "parent-fullscreen" || fail "parent-fullscreen" "focus after Alt+e: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" -echo "GT KEY-FOCUS-BELOW" -sleep 3 -if focus_is child-parent 1; then - pass "fullscreen-keeps-focus" "Alt+j left the focus on the fullscreen window" -else - fail "fullscreen-keeps-focus" "Alt+j moved the focus to a hidden window: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" -fi -echo "GT KEY-PARENT-WINDOWED" -wait_for 10 focus_is child-parent 0 && pass "parent-windowed-again" || fail "parent-windowed-again" "$(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" -wait_for 40 probe_gone -# A child that maps under its fullscreen parent ends the fullscreen: the -# zone cannot have it drawn above the bar, so both are shown tiled instead. -late_done() { since_mark late untrusted | sed -n '/asked for a child/,$p' | grep committed | grep -v child | grep -qv '(fullscreen)'; } -mark late untrusted -launch_plain untrusted "/usr/libexec/kryptik/wlprobe child 20 late" > "$LOG/launch-late.out" 2>&1 -wait_for 20 focus_is child-parent 0 -echo "GT KEY-LATE-FULLSCREEN" -if wait_for 20 late_done && wait_for 10 grep -q '^fullscreen=0' "$RT/kryptik/focus"; then - pass "child-ends-fullscreen" "$(since_mark late untrusted | grep -E 'asked for a child|committed' | tail -3 | tr '\n' ' ')" -else - fail "child-ends-fullscreen" "$(since_mark late untrusted | tail -4 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" -fi -wait_for 30 probe_gone # Alt+p opens one more menu window: the chrome's text menu in a zone 0 # terminal of its own, as at login. Closed again once seen. menu_windows() { pgrep -u "$USER_NAME" -f 'havoc /usr/bin/kryptik-chrome --menu' | wc -l; } diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 9f191bad..646b6e32 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -64,16 +64,18 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "send:su - root -c 'bash /usr/lib/kryptik/guest-tests/gui-check.sh ${TUSER} 2>&1 | tee /var/log/kryptik/gui-check.log; echo GCHECK-DONE'" \ "expect:Password: ?" "send:${RPASS}" \ - "expect:GT KEY-FOCUS-ZONE" "key:alt+j" \ - "expect:GT SCREENSHOT-READY" "sleep:2" "screendump:${SHOT}" \ - "expect:GT KEY-FULLSCREEN\r?\n" "key:alt+e" \ - "expect:GT SCREENSHOT-FULLSCREEN" "sleep:2" "screendump:${SHOT_FS}" \ - "expect:GT KEY-FULLSCREEN-AGAIN" "key:alt+e" \ + "expect:GT KEY-FOCUS-CHILD" "key:alt+j" \ "expect:GT KEY-FOCUS-PARENT" "key:alt+j" \ "expect:GT KEY-PARENT-FULLSCREEN" "key:alt+e" \ "expect:GT KEY-FOCUS-BELOW" "key:alt+j" \ "expect:GT KEY-PARENT-WINDOWED" "key:alt+e" \ + "expect:GT KEY-FOCUS-LATE" "key:alt+j" \ "expect:GT KEY-LATE-FULLSCREEN" "key:alt+e" \ + "expect:GT KEY-FOCUS-ZONE" "key:alt+j" \ + "expect:GT SCREENSHOT-READY" "sleep:2" "screendump:${SHOT}" \ + "expect:GT KEY-FULLSCREEN\r?\n" "key:alt+e" \ + "expect:GT SCREENSHOT-FULLSCREEN" "sleep:2" "screendump:${SHOT_FS}" \ + "expect:GT KEY-FULLSCREEN-AGAIN" "key:alt+e" \ "expect:GT KEY-MENU" "key:alt+p" \ "expect:GT KEY-FOCUS-OVERSIZE" "key:alt+j" \ "expect:GT SCREENSHOT-OVERSIZE" "sleep:2" "screendump:${SHOT_OVER}" \ @@ -105,7 +107,7 @@ for name in session-socket compositor-running chrome-focus-record chrome-window- transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown \ - zone0-fullscreen-granted fullscreen-by-key zone-child-mapped parent-focused parent-fullscreen fullscreen-keeps-focus parent-windowed-again child-ends-fullscreen; do + zone0-fullscreen-granted fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus parent-windowed-again child-ends-fullscreen; do grep -q "GT PASS ${name}" <<<"$T" && green "guest: ${name}" || red "guest: ${name} (not passed)" done From 487ed73ada7818a9cf58a1a628a1f2011faa50ea Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 08:37:28 -0700 Subject: [PATCH 5/6] The keyboard never stays on a window a fullscreen one covers: a window shown by ending a fullscreen takes the focus, focusclient refuses covered windows, zoom skips them, covered() means a fullscreen window above, zone 0's own fullscreen needs the focus; the probe reports wl_keyboard enter and leave, and the checks read that rather than the record --- build/guest-tests/gui-check.sh | 29 +++++++++++--- tools/desktop/dwl-zone-borders.py | 66 ++++++++++++++++++++++++++----- tools/desktop/wlprobe.c | 17 ++++++++ tools/image/gui-test.sh | 4 +- 4 files changed, 98 insertions(+), 18 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index 70065284..d1198c8e 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -8,8 +8,8 @@ # GT KEY-FULLSCREEN, GT KEY-FULLSCREEN-AGAIN press Alt+e (fullscreen, then back) # GT KEY-FOCUS-CHILD, GT KEY-FOCUS-PARENT, # GT KEY-FOCUS-BELOW, GT KEY-FOCUS-LATE press Alt+j -# GT KEY-PARENT-FULLSCREEN, GT KEY-PARENT-WINDOWED, -# GT KEY-LATE-FULLSCREEN press Alt+e +# GT KEY-ZOOM-BELOW press Alt+Return (zoom) +# GT KEY-PARENT-FULLSCREEN, GT KEY-LATE-FULLSCREEN press Alt+e # GT KEY-MENU press Alt+p (the chrome menu) # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) # GT CONSENT-WAIT 2 type y and Enter (not the code: refused) @@ -155,12 +155,29 @@ wait_for 10 focus_is child-parent 1 && pass "parent-fullscreen" || fail "parent- echo "GT KEY-FOCUS-BELOW" sleep 3 if focus_is child-parent 1; then - pass "fullscreen-keeps-focus" "Alt+j left the focus on the fullscreen window" + pass "fullscreen-keeps-focus" "Alt+j left the record on the fullscreen window" else - fail "fullscreen-keeps-focus" "Alt+j moved the focus to a hidden window: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" + fail "fullscreen-keeps-focus" "Alt+j moved the record to a hidden window: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" fi -echo "GT KEY-PARENT-WINDOWED" -wait_for 10 focus_is child-parent 0 && pass "parent-windowed-again" || fail "parent-windowed-again" "$(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +# The record names the window the compositor believes on top, which is never +# a covered one; the probe's wl_keyboard events say where the keys go. +child_entries() { since_mark child untrusted | grep -c 'keyboard entered the child'; } +[[ "$(child_entries)" -eq 1 ]] && pass "keyboard-stays-on-fullscreen" "the child saw the keyboard once, before its parent went fullscreen" || fail "keyboard-stays-on-fullscreen" "the hidden child got the keyboard: $(since_mark child untrusted | grep keyboard | tr '\n' ' ')" +echo "GT KEY-ZOOM-BELOW" +sleep 3 +[[ "$(child_entries)" -eq 1 ]] && focus_is child-parent 1 && pass "zoom-keeps-keyboard" "Alt+Return left the keyboard on the fullscreen window" || fail "zoom-keeps-keyboard" "$(since_mark child untrusted | grep keyboard | tail -2 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +# A zone 0 window opened over the fullscreen zone window ends that fullscreen +# and takes the keyboard, as a passphrase prompt must. +as_user "/usr/libexec/kryptik/wlprobe oversize 0 8 over-fullscreen" > "$LOG/zone0-over.out" 2>&1 & +over_pid=$! +if wait_for 15 grep -q 'keyboard entered the window' "$LOG/zone0-over.out" && wait_for 10 grep -q '^title=over-fullscreen' "$RT/kryptik/focus"; then + pass "zone0-over-fullscreen-gets-keyboard" "$(tr '\n' ' ' < "$RT/kryptik/focus")" +else + fail "zone0-over-fullscreen-gets-keyboard" "zone 0's window: $(grep -E 'keyboard|committed' "$LOG/zone0-over.out" | tail -3 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +fi +parent_windowed() { since_mark child untrusted | sed -n '/configure (fullscreen)/,$p' | grep committed | grep -v child | grep -qv '(fullscreen)'; } +wait_for 10 parent_windowed && pass "zone0-window-ends-fullscreen" "the parent's next configure was not fullscreen" || fail "zone0-window-ends-fullscreen" "$(since_mark child untrusted | grep committed | tail -3 | tr '\n' ' ')" +wait "$over_pid" 2>/dev/null wait_for 60 zone_gone # A child that maps under its fullscreen parent ends the fullscreen: the # zone cannot have it drawn above the bar, so both are shown tiled instead. diff --git a/tools/desktop/dwl-zone-borders.py b/tools/desktop/dwl-zone-borders.py index 8fad8748..ea5de94c 100755 --- a/tools/desktop/dwl-zone-borders.py +++ b/tools/desktop/dwl-zone-borders.py @@ -291,9 +291,10 @@ \tsetfullscreen(c, client_wants_fullscreen(c)); """, """\tClient *c = wl_container_of(listener, c, fullscreen); -\t/* Kryptik: a zone window goes fullscreen only by the user's key. */ -\tsetfullscreen(c, client_wants_fullscreen(c) -\t\t\t&& (c->zoneborder == unzonedcolor || c->isfullscreen)); +\t/* Kryptik: a zone window goes fullscreen only by the user's key, and zone +\t * 0's only while it has the focus, so it never covers the focused window. */ +\tsetfullscreen(c, client_wants_fullscreen(c) && (c->isfullscreen +\t\t\t|| (c->zoneborder == unzonedcolor && c->mon && c == focustop(c->mon)))); """), # The bar, defined before setfullscreen, its first caller. ("""void @@ -343,19 +344,39 @@ # the tile layer), so it ends that fullscreen; zone 0's child follows its # parent up, as dwl has it. A zone mapping must not cancel another zone's # fullscreen either. + ("""\tMonitor *m; +\tint i; + +\t/* Create scene tree for this client and its border */ +""", + """\tMonitor *m; +\tint i, refocus = 0; + +\t/* Create scene tree for this client and its border */ +"""), ("""\t\tif (w != c && w != p && w->isfullscreen && m == w->mon && (w->tags & c->tags)) \t\t\tsetfullscreen(w, 0); +\t} +} """, """\t\tif (w != c && (w != p || w->zoneborder != unzonedcolor) && w->isfullscreen && m == w->mon && (w->tags & c->tags) -\t\t\t\t&& (c->zoneborder == unzonedcolor || w->zoneborder == c->zoneborder)) +\t\t\t\t&& (c->zoneborder == unzonedcolor || w->zoneborder == c->zoneborder)) { \t\t\tsetfullscreen(w, 0); +\t\t\trefocus = 1; +\t\t} +\t} +\t/* Kryptik: the focus was chosen above while the fullscreen window still +\t * covered the new one; shown now, it may take the keyboard. */ +\tif (refocus) +\t\tfocusclient(focustop(selmon), 1); +} """), # A fullscreen window covers the tile and float layers, so while one shows - # only its own layer is on screen. The focus never walks to what is hidden: - # focustop and focusstack skip covered windows. Defined before focusstack, - # the first of the two. + # only its own layer is on screen. The keyboard never goes to what is + # hidden: focusclient redirects it, and focustop, focusstack and zoom skip + # covered windows. Defined before focusclient, the first user. ("""void -focusstack(const Arg *arg) +focusclient(Client *c, int lift) { """, """/* Kryptik: with a fullscreen window on the monitor, a window in any other @@ -367,14 +388,39 @@ \tif (c->scene->node.parent == layers[LyrFS]) \t\treturn 0; \twl_list_for_each(w, &clients, link) -\t\tif (w != c && VISIBLEON(w, m) && w->scene->node.parent == layers[LyrFS]) +\t\tif (w != c && VISIBLEON(w, m) && w->isfullscreen && w->scene->node.parent == layers[LyrFS]) \t\t\treturn 1; \treturn 0; } void -focusstack(const Arg *arg) +focusclient(Client *c, int lift) { +"""), + ("""\tLayerSurface *old_l = NULL; + +\tif (locked) +\t\treturn; + +\t/* Raise client in stacking order if requested */ +""", + """\tLayerSurface *old_l = NULL; + +\tif (locked) +\t\treturn; +\t/* Kryptik: a window hidden under a fullscreen one never takes the keyboard. */ +\tif (c && c->mon && covered(c, c->mon)) +\t\tc = focustop(c->mon); + +\t/* Raise client in stacking order if requested */ +"""), + ("""\t\tif (VISIBLEON(c, selmon) && !c->isfloating) { +\t\t\tif (c != sel) +\t\t\t\tbreak; +""", + """\t\tif (VISIBLEON(c, selmon) && !c->isfloating && !covered(c, selmon)) { +\t\t\tif (c != sel) +\t\t\t\tbreak; """), ("""\tif (arg->i > 0) { \t\twl_list_for_each(c, &sel->link, link) { diff --git a/tools/desktop/wlprobe.c b/tools/desktop/wlprobe.c index 4e1add95..18ecfd09 100644 --- a/tools/desktop/wlprobe.c +++ b/tools/desktop/wlprobe.c @@ -18,6 +18,8 @@ * as oversize 0, and once drawn map a second window * that is a child of the first; with late, only * once the first's configure was fullscreen + * Every held window prints "keyboard entered|left the window|the child" + * as wl_keyboard reports it: where the keys go, not what a record says. * wlprobe charge map one unwritten 4 MiB shmem buffer for 10 s; * compare zone and compositor cgroup memory.current * wlprobe cursor SECONDS as oversize 0, and when the pointer enters, set a @@ -217,6 +219,7 @@ static void open_child(void) * else draws. */ static const uint32_t cursor_rgb = 0x13f7a5; static uint32_t seat_id, pointer_id, cursor_surf; +static uint32_t keyboard_id; /* every held window says where the keyboard is */ static int cursor; /* On every entry, as a client does; the image is made on the first. */ @@ -323,6 +326,11 @@ static int handle_one(void) /* wl_surface.enter(output) */ printf("%s entered an output\n", object == SURFACE ? "the window" : "the cursor image"); fflush(stdout); + } else if (keyboard_id && object == keyboard_id && (opcode == 1 || opcode == 2)) { + uint32_t s = get32(body + 4); /* wl_keyboard.enter|leave(serial, surface, ...) */ + printf("keyboard %s %s\n", opcode == 1 ? "entered" : "left", + s == SURFACE ? "the window" : (csurface && s == csurface) ? "the child" : "another surface"); + fflush(stdout); } else if (!oversize) { printf("event object=%u opcode=%u size=%u\n", object, opcode, size); } @@ -388,6 +396,15 @@ static int hold_oversize(int more, int seconds, const char *title) * compositor takes it. */ if (bind_global("wl_output", next_id++)) return 1; } + /* Where the keyboard is, as the compositor tells it: the record the chrome + * writes names the window it believes on top, not always the same one. */ + if (!seat_id) { + seat_id = next_id++; + if (bind_global("wl_seat", seat_id)) return 1; + } + keyboard_id = next_id++; + put32(b, keyboard_id); + send_msg(seat_id, 1, b, 4); /* wl_seat.get_keyboard */ send_msg(SURFACE, 6, b, 0); /* wl_surface.commit: ask for a configure */ time_t end = time(NULL) + seconds; while (time(NULL) < end && !closed) { diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 646b6e32..87434d22 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -68,7 +68,7 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:GT KEY-FOCUS-PARENT" "key:alt+j" \ "expect:GT KEY-PARENT-FULLSCREEN" "key:alt+e" \ "expect:GT KEY-FOCUS-BELOW" "key:alt+j" \ - "expect:GT KEY-PARENT-WINDOWED" "key:alt+e" \ + "expect:GT KEY-ZOOM-BELOW" "key:alt+ret" \ "expect:GT KEY-FOCUS-LATE" "key:alt+j" \ "expect:GT KEY-LATE-FULLSCREEN" "key:alt+e" \ "expect:GT KEY-FOCUS-ZONE" "key:alt+j" \ @@ -107,7 +107,7 @@ for name in session-socket compositor-running chrome-focus-record chrome-window- transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown \ - zone0-fullscreen-granted fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus parent-windowed-again child-ends-fullscreen; do + zone0-fullscreen-granted fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do grep -q "GT PASS ${name}" <<<"$T" && green "guest: ${name}" || red "guest: ${name} (not passed)" done From 6f2e9b6a34bb9032c5ee623bf8cda1237e60557b Mon Sep 17 00:00:00 2001 From: DevomB Date: Wed, 7 Oct 2026 11:02:54 -0700 Subject: [PATCH 6/6] Alt+Return is pressed twice in the zoom check: dwl's zoom moves the window it finds to the front of its list, and only from the front does the old search pass the fullscreen window and reach the hidden child --- build/guest-tests/gui-check.sh | 8 +++++++- tools/image/gui-test.sh | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/build/guest-tests/gui-check.sh b/build/guest-tests/gui-check.sh index d1198c8e..823833dc 100755 --- a/build/guest-tests/gui-check.sh +++ b/build/guest-tests/gui-check.sh @@ -8,7 +8,7 @@ # GT KEY-FULLSCREEN, GT KEY-FULLSCREEN-AGAIN press Alt+e (fullscreen, then back) # GT KEY-FOCUS-CHILD, GT KEY-FOCUS-PARENT, # GT KEY-FOCUS-BELOW, GT KEY-FOCUS-LATE press Alt+j -# GT KEY-ZOOM-BELOW press Alt+Return (zoom) +# GT KEY-ZOOM-BELOW, GT KEY-ZOOM-AGAIN press Alt+Return (zoom) # GT KEY-PARENT-FULLSCREEN, GT KEY-LATE-FULLSCREEN press Alt+e # GT KEY-MENU press Alt+p (the chrome menu) # GT CONSENT-CODE 1 NN type NN and Enter (the question's code) @@ -166,6 +166,12 @@ child_entries() { since_mark child untrusted | grep -c 'keyboard entered the chi echo "GT KEY-ZOOM-BELOW" sleep 3 [[ "$(child_entries)" -eq 1 ]] && focus_is child-parent 1 && pass "zoom-keeps-keyboard" "Alt+Return left the keyboard on the fullscreen window" || fail "zoom-keeps-keyboard" "$(since_mark child untrusted | grep keyboard | tail -2 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" +# Twice: dwl's zoom moves the window it finds to the front of its list, and +# only from the front does the old search pass the fullscreen window and +# land on the hidden child. +echo "GT KEY-ZOOM-AGAIN" +sleep 3 +[[ "$(child_entries)" -eq 1 ]] && focus_is child-parent 1 && pass "zoom-twice-keeps-keyboard" "a second Alt+Return left it there too" || fail "zoom-twice-keeps-keyboard" "$(since_mark child untrusted | grep keyboard | tail -2 | tr '\n' ' '); focus: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null)" # A zone 0 window opened over the fullscreen zone window ends that fullscreen # and takes the keyboard, as a passphrase prompt must. as_user "/usr/libexec/kryptik/wlprobe oversize 0 8 over-fullscreen" > "$LOG/zone0-over.out" 2>&1 & diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 87434d22..58b29435 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -69,6 +69,7 @@ python3 "$DRV" --serial "$SER" --qmp "$QMP" --timeout 600 \ "expect:GT KEY-PARENT-FULLSCREEN" "key:alt+e" \ "expect:GT KEY-FOCUS-BELOW" "key:alt+j" \ "expect:GT KEY-ZOOM-BELOW" "key:alt+ret" \ + "expect:GT KEY-ZOOM-AGAIN" "key:alt+ret" \ "expect:GT KEY-FOCUS-LATE" "key:alt+j" \ "expect:GT KEY-LATE-FULLSCREEN" "key:alt+e" \ "expect:GT KEY-FOCUS-ZONE" "key:alt+j" \ @@ -107,7 +108,7 @@ for name in session-socket compositor-running chrome-focus-record chrome-window- transfer-policy no-question-for-policy-refusal consent-code-shown transfer-approved transfer-landed plain-y-refused denied-file-absent \ second-head-appears chrome-follows-head second-head-zone-window second-head-names-zone second-head-gone compositor-survives-unplug zone-survives-unplug chrome-back-on-first-head \ zone0-cursor-set zone0-cursor-shown zone-cursor-asked zone-hears-of-outputs zone-cursor-not-shown \ - zone0-fullscreen-granted fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do + zone0-fullscreen-granted fullscreen-by-key zone-child-mapped child-focused parent-focused parent-fullscreen fullscreen-keeps-focus keyboard-stays-on-fullscreen zoom-keeps-keyboard zoom-twice-keeps-keyboard zone0-over-fullscreen-gets-keyboard zone0-window-ends-fullscreen child-ends-fullscreen; do grep -q "GT PASS ${name}" <<<"$T" && green "guest: ${name}" || red "guest: ${name} (not passed)" done