detached 8ffaccb

fix(portal): portal session environment, xdg-foreign dialogs and capture retries (#104)

* fix(launcher): restart portals after the session export and stop them at exit

* feat(wayland): serve xdg-foreign v2 and show a fullscreen window's dialogs over it

Portal dialogs import the requesting window through zxdg_importer_v2 and float over it. A dialog of the covering fullscreen window shows with the focus; binds act on it, and a focus move returns to the window without leaving fullscreen. Parent changes after map reach core (WindowParent).

* fix(capture): retry ext-image-copy-capture frames on transient failures

A frame whose target is not ready, whose exclusion or indicator is not confirmed, or whose renderer is busy is tried again for up to one second instead of failing with unknown, which ends the whole screencast in xdg-desktop-portal-wlr.

* fix(capture): retry only transient capture failures

Mark the renderer, indicator and exclusion errors that may clear with
ports.ErrCaptureTransient. An ext-image-copy-capture frame retries only
those; a bad buffer or unsupported size fails at once. Transient errors
log at debug, the final give-up at info. Test the retry deadline with a
mock clock and document that portals are per user.
commit
8ffaccb50991eaef0754e5f4c096c6724decbf41
author
bnema · · 2026-10-03 08:43 UTC
committer
GitHub · · 2026-10-03 08:43 UTC
parent
245942b
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
27 files · +906 -60
M docs/config.md +2 -2
@@ -124,7 +124,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
124124 | `spawn <command>` | Run a program |
125125 | `spawn-terminal` | Run `terminal` |
126126 | `close-window` | Close the focused window |
127-| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
127+| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
128128 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
129129 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
130130 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
@@ -154,7 +154,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
154154
155155 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
156156
157-Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive.
157+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does.
158158
159159 ### Free floating windows
160160
@@ -124,7 +124,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
124 | `spawn <command>` | Run a program |124 | `spawn <command>` | Run a program |
125 | `spawn-terminal` | Run `terminal` |125 | `spawn-terminal` | Run `terminal` |
126 | `close-window` | Close the focused window |126 | `close-window` | Close the focused window |
127-| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |127+| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows wait hidden until it leaves; a window opening meanwhile waits hidden too. The window's own dialogs are the exception: a dialog it opens (a file chooser, a screen-sharing or permission request from the desktop portal) shows over it with the focus, and a focus move goes back to the fullscreen window without leaving fullscreen. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
128 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |128 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
129 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |129 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
130 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |130 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
@@ -154,7 +154,7 @@ Each workspace has a stash: a horizontal strip of windows set aside with `toggle
154 154
155 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).155 While the stash has the focus, `focus-column-left/right` and the three-finger swipe move through it and stop at its ends; `focus-window-up/down` do nothing. Hide it with `toggle-stash-visible` to get back to the tiles. Scripts see each stashed window's place, and whether the stash is hidden, in the [state file](desktop.md#state-for-scripts).
156 156
157-Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive.157+Native floating windows are not in the stash: they stay centred in the usable output area. A window-sized float that fills that area (within two border widths plus two logical pixels per axis) stays below the columns when you focus a tile; `focus-window-up` at the top of a column raises it again. It does not hide bars or pin focus. Small dialogs and file pickers always stay above the columns; shrinking a float promotes it above them. Real fullscreen remains exclusive, except for the fullscreen window's own dialogs. A dialog belongs to a window through `xdg_toplevel.set_parent`, or through `zxdg_importer_v2` when another program opens it, as the desktop portal does.
158 158
159 ### Free floating windows159 ### Free floating windows
160 160
M docs/headless.md +3 -3
@@ -9,11 +9,11 @@ WAYLAND_DISPLAY=<name from the log> foot
99
1010 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
1111
12-Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait (their frames fail) until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
1313
14-`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
14+`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
1515
16-Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it. While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.
16+Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it (an ext-image-copy-capture frame then asks again, until its one-second deadline). While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.
1717
1818 Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order.
1919
@@ -9,11 +9,11 @@ WAYLAND_DISPLAY=<name from the log> foot
9 9
10 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.10 Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG.
11 11
12-Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; other sessions on a different hidden workspace wait (their frames fail) until it is gone. Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.12+Recording goes through `ext_image_copy_capture_v1`, with optional additions from `neferwl_image_capture_source_manager_v1` (XML in `internal/adapters/wayland/imagecapture/`): `create_workspace_source` (a workspace handle of `ext_workspace_manager_v1`, whether or not the workspace is on screen) and `create_output_region_source` (a rectangle of an output, in logical pixels, clipped). Both give an ordinary `ext_image_capture_source_v1`. A workspace source is the workspace's real layout without bars, not an overview preview; its buffer size follows the workspace frame (new constraints when it changes) and its sessions stop when the workspace is removed. `get_workspace_frame` gives a `neferwl_workspace_frame_v1` that sends the workspace frame (`frame`: x, y, width, height in output-local logical pixels) when created and whenever it changes, since ext-workspace carries no geometry. A region source stops when the output goes away or nothing of the rectangle is left. A workspace that is not on screen uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side), for one workspace at a time; frames of other sessions on a different hidden workspace are retried while it lasts, then fail (see below). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires whole-frame requests matching its exact physical dimensions.
13 13
14-`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).14+`neferwl_capture_exclusion_manager_v1` leaves a recorder's own HUD out of its frames. The recorder calls `get_exclusion` on its capture session and gets a random token (or `failed`: `busy` when another exclusion is live anywhere in the compositor, `session_stopped`). A HUD, usually on another connection of the same user, attaches up to 4 layer surfaces (top or overlay, not yet mapped, no exclusive keyboard) with the token. They stay visible over fullscreen without keyboard focus; frames of that session omit them and their popups, every other capture (wlr-screencopy included) keeps them. HUD authorization requires the token, a matching socket peer UID, and that the HUD's executable is also allowed to capture (see [Screen capture](capture.md)), never a layer namespace or app-id. The exclusion ends with its session, or when its object is destroyed; its HUD layers that are still listed then stay out of the session's frames (and no longer stay over a fullscreen window) until they are gone, so the HUD never shows in the recorder's frames after the exclusion ended. Output removal and workspace removal stop sessions. An ext-image-copy-capture frame whose target is briefly unavailable (not active yet, its exclusion or indicator not confirmed, the renderer busy) is tried again for up to one second before it fails, so a short hiccup does not end a screencast; a refused client, a stopped session or an error a retry cannot clear (a bad buffer) fails at once. One function of the Wayland adapter (`mayCapture`) decides whether a client may capture, for wlr-screencopy and every ext-image-copy-capture source, and for the attach of a HUD layer to an exclusion; see [Screen capture](capture.md).
15 15
16-Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it. While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.16+Every capture is visible on screen, and no client can turn that off. The compositor draws a red border (2 logical pixels, `#ff3b30`, inside the target's edge, above every window and layer) around the target of a capture: an ext-image-copy-capture session that has produced a frame keeps it for as long as the session lives, and every captured frame, from ext-image-copy-capture or wlr-screencopy, keeps it for one second after that frame, so a one-shot screenshot flashes for a second and periodic captures stay lit. The target is the output, the output region, or the workspace frame while the workspace is on screen. A workspace that is not on screen has no place to outline: the output that owns it shows a small red rounded square (12 logical pixels, inset 8 from the top-right corner) while it is captured. Several live captures show together. The indicator is in no captured image: every capture, excluded or not, of the displayed frame or of a workspace rendered off screen, is drawn without it. Core owns the indicator state and its one-second timer; the renderer only draws the marks of the physical output's scene. The indicator is on screen no later than the frame it captures: every capture request is held by its output until the scene it renders shows the mark the request needs (a border covering its region, or the pill for a workspace rendered off screen), and is then served from that very scene, so the indicator is composed in the same frame or an earlier one. A request whose mark does not come (its target vanished, so nothing is left to mark) fails after 250 ms instead of being served without it (an ext-image-copy-capture frame then asks again, until its one-second deadline). While an indicator is shown the output is composed (no direct scanout or overlay plane), and a capture of the displayed frame costs a second composition (the frame without the indicator, drawn first); with no capture live there is no cost.
17 17
18 Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order.18 Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. A capture that leaves out an exclusion's HUD, and any capture while the indicator is shown, can require two compositions to preserve the displayed z-order.
19 19
M internal/adapters/capture/excluded.go +4 -4
@@ -1,7 +1,7 @@
11 package capture
22
33 import (
4- "errors"
4+ "fmt"
55 "os"
66 "slices"
77
@@ -16,17 +16,17 @@ import (
1616
1717 // ErrSessionInactive fails an Exclude request whose session is not the one
1818 // the scene carries. Serving it as a normal capture would leak its HUD.
19-var ErrSessionInactive = errors.New("capture exclusion is not active")
19+var ErrSessionInactive = fmt.Errorf("%w: capture exclusion is not active", ports.ErrCaptureTransient)
2020
2121 // ErrSessionStale fails an Exclude request stamped with a revision newer than
2222 // the scene's: the scene predates a change of what an excluded frame must
2323 // hide (a new HUD layer, popup, target), so its exclusion list could leak.
24-var ErrSessionStale = errors.New("capture exclusion scene is older than the request")
24+var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient)
2525
2626 // ErrWorkspaceMoved fails a workspace request whose workspace is neither on
2727 // screen nor the one rendered off screen in the scene: it moved, went away,
2828 // or the scene predates the session.
29-var ErrWorkspaceMoved = errors.New("captured workspace is not where the request expected")
29+var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient)
3030
3131 // excluder derives the scene of Exclude captures: the frame without the
3232 // excluded layers and popups. Its scratch slices are reused,
@@ -1,7 +1,7 @@
1 package capture1 package capture
2 2
3 import (3 import (
4- "errors"4+ "fmt"
5 "os"5 "os"
6 "slices"6 "slices"
7 7
@@ -16,17 +16,17 @@ import (
16 16
17 // ErrSessionInactive fails an Exclude request whose session is not the one17 // ErrSessionInactive fails an Exclude request whose session is not the one
18 // the scene carries. Serving it as a normal capture would leak its HUD.18 // the scene carries. Serving it as a normal capture would leak its HUD.
19-var ErrSessionInactive = errors.New("capture exclusion is not active")19+var ErrSessionInactive = fmt.Errorf("%w: capture exclusion is not active", ports.ErrCaptureTransient)
20 20
21 // ErrSessionStale fails an Exclude request stamped with a revision newer than21 // ErrSessionStale fails an Exclude request stamped with a revision newer than
22 // the scene's: the scene predates a change of what an excluded frame must22 // the scene's: the scene predates a change of what an excluded frame must
23 // hide (a new HUD layer, popup, target), so its exclusion list could leak.23 // hide (a new HUD layer, popup, target), so its exclusion list could leak.
24-var ErrSessionStale = errors.New("capture exclusion scene is older than the request")24+var ErrSessionStale = fmt.Errorf("%w: capture exclusion scene is older than the request", ports.ErrCaptureTransient)
25 25
26 // ErrWorkspaceMoved fails a workspace request whose workspace is neither on26 // ErrWorkspaceMoved fails a workspace request whose workspace is neither on
27 // screen nor the one rendered off screen in the scene: it moved, went away,27 // screen nor the one rendered off screen in the scene: it moved, went away,
28 // or the scene predates the session.28 // or the scene predates the session.
29-var ErrWorkspaceMoved = errors.New("captured workspace is not where the request expected")29+var ErrWorkspaceMoved = fmt.Errorf("%w: captured workspace is not where the request expected", ports.ErrCaptureTransient)
30 30
31 // excluder derives the scene of Exclude captures: the frame without the31 // excluder derives the scene of Exclude captures: the frame without the
32 // excluded layers and popups. Its scratch slices are reused,32 // excluded layers and popups. Its scratch slices are reused,
M internal/adapters/capture/fence.go +2 -2
@@ -1,7 +1,7 @@
11 package capture
22
33 import (
4- "errors"
4+ "fmt"
55 "image"
66 "math"
77 "time"
@@ -46,7 +46,7 @@ const HoldFor = 250 * time.Millisecond
4646
4747 // ErrIndicatorMissing fails a request whose indicator was not on screen in
4848 // time.
49-var ErrIndicatorMissing = errors.New("capture indicator is not on screen")
49+var ErrIndicatorMissing = fmt.Errorf("%w: capture indicator is not on screen", ports.ErrCaptureTransient)
5050
5151 // IndicatorShown reports whether scene s shows the indicator request q needs.
5252 // A request that does not ask for one (Indicate false) needs none.
@@ -1,7 +1,7 @@
1 package capture1 package capture
2 2
3 import (3 import (
4- "errors"4+ "fmt"
5 "image"5 "image"
6 "math"6 "math"
7 "time"7 "time"
@@ -46,7 +46,7 @@ const HoldFor = 250 * time.Millisecond
46 46
47 // ErrIndicatorMissing fails a request whose indicator was not on screen in47 // ErrIndicatorMissing fails a request whose indicator was not on screen in
48 // time.48 // time.
49-var ErrIndicatorMissing = errors.New("capture indicator is not on screen")49+var ErrIndicatorMissing = fmt.Errorf("%w: capture indicator is not on screen", ports.ErrCaptureTransient)
50 50
51 // IndicatorShown reports whether scene s shows the indicator request q needs.51 // IndicatorShown reports whether scene s shows the indicator request q needs.
52 // A request that does not ask for one (Indicate false) needs none.52 // A request that does not ask for one (Indicate false) needs none.
M internal/adapters/capture/offscreen.go +4 -2
@@ -41,7 +41,7 @@ var (
4141 ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported")
4242 // ErrOffscreenBusy fails a request while the child is still busy with
4343 // another session or size, or has too many renders in flight.
44- ErrOffscreenBusy = errors.New("workspace capture is busy")
44+ ErrOffscreenBusy = fmt.Errorf("%w: workspace capture is busy", ports.ErrCaptureTransient)
4545 )
4646
4747 // fenceSlot is one child render whose GPU reads may not be finished: its sync
@@ -397,7 +397,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
397397 k := 0
398398 for _, q := range reqs {
399399 if q.Region.Dx() != w || q.Region.Dy() != h {
400- Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies)
400+ // A resize the request has not caught up with: transient, the
401+ // next try uses the new size or new buffer constraints.
402+ Fail(p.ctx, q, fmt.Errorf("%w: %w: region %v is not the whole %dx%d workspace frame", ports.ErrCaptureTransient, ErrOffscreenGeometry, q.Region, w, h), p.replies)
401403 continue
402404 }
403405 q.Region = image.Rect(0, 0, w, h)
@@ -41,7 +41,7 @@ var (
41 ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported")41 ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported")
42 // ErrOffscreenBusy fails a request while the child is still busy with42 // ErrOffscreenBusy fails a request while the child is still busy with
43 // another session or size, or has too many renders in flight.43 // another session or size, or has too many renders in flight.
44- ErrOffscreenBusy = errors.New("workspace capture is busy")44+ ErrOffscreenBusy = fmt.Errorf("%w: workspace capture is busy", ports.ErrCaptureTransient)
45 )45 )
46 46
47 // fenceSlot is one child render whose GPU reads may not be finished: its sync47 // fenceSlot is one child render whose GPU reads may not be finished: its sync
@@ -397,7 +397,9 @@ func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports
397 k := 0397 k := 0
398 for _, q := range reqs {398 for _, q := range reqs {
399 if q.Region.Dx() != w || q.Region.Dy() != h {399 if q.Region.Dx() != w || q.Region.Dy() != h {
400- Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies)400+ // A resize the request has not caught up with: transient, the
401+ // next try uses the new size or new buffer constraints.
402+ Fail(p.ctx, q, fmt.Errorf("%w: %w: region %v is not the whole %dx%d workspace frame", ports.ErrCaptureTransient, ErrOffscreenGeometry, q.Region, w, h), p.replies)
401 continue403 continue
402 }404 }
403 q.Region = image.Rect(0, 0, w, h)405 q.Region = image.Rect(0, 0, w, h)
M internal/adapters/capture/pipeline.go +1 -1
@@ -142,7 +142,7 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req
142142 }
143143 var err error
144144 if b == nil {
145- err = fmt.Errorf("capture pipeline full or stopped")
145+ err = fmt.Errorf("%w: capture pipeline full or stopped", ports.ErrCaptureTransient)
146146 } else if !p.captureAllowed(state) {
147147 err = ErrSecurityState
148148 } else {
@@ -142,7 +142,7 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req
142 }142 }
143 var err error143 var err error
144 if b == nil {144 if b == nil {
145- err = fmt.Errorf("capture pipeline full or stopped")145+ err = fmt.Errorf("%w: capture pipeline full or stopped", ports.ErrCaptureTransient)
146 } else if !p.captureAllowed(state) {146 } else if !p.captureAllowed(state) {
147 err = ErrSecurityState147 err = ErrSecurityState
148 } else {148 } else {
M internal/adapters/launcher/session.go +30 -8
@@ -13,43 +13,65 @@ import (
1313 // notification daemons) need to reach the session.
1414 var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"}
1515
16+// portalUnits are the desktop portal and its backends. They read the
17+// session variables once, at start: a portal started without them (before
18+// the export, or after the last session ended) never serves this session.
19+// Portals are per user, like the exported variables: another graphical
20+// session of the same user running at once is not supported.
21+const portalUnits = "xdg-desktop-portal*.service"
22+
1623 // ExportSession shares the session's displays with D-Bus and systemd user
17-// services, and waits for it: clients started next may activate them.
24+// services, and waits for it: clients started next may activate them. Portals
25+// already running restart to read them; the restart does not block, since a
26+// backend may connect to the display before it serves.
1827 func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) {
1928 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
2029 defer cancel()
21- runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported")
30+ if !runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported") {
31+ return
32+ }
33+ runSystemctl(ctx, env, log, []string{"--no-block", "try-restart", portalUnits}, "portal restart failed")
2234 }
2335
2436 // UnexportSession withdraws the displays from the systemd user manager on
25-// exit, so services started after logout do not reach a dead display.
37+// exit, so services started after logout do not reach a dead display, and
38+// stops the portals that still hold them.
2639 func UnexportSession(env []string, log zerowrap.Logger) {
2740 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
2841 defer cancel()
42+ runSystemctl(ctx, env, log, append([]string{"unset-environment"}, sessionVars...), "session unexport failed")
43+ runSystemctl(ctx, env, log, []string{"--no-block", "stop", portalUnits}, "portal stop failed")
44+}
45+
46+// runSystemctl runs systemctl --user with args; a missing systemctl is skipped.
47+func runSystemctl(ctx context.Context, env []string, log zerowrap.Logger, args []string, failed string) {
2948 path, err := childLookPath("systemctl", env)
3049 if err != nil {
3150 return
3251 }
33- cmd := exec.CommandContext(ctx, path, append([]string{"--user", "unset-environment"}, sessionVars...)...)
52+ cmd := exec.CommandContext(ctx, path, append([]string{"--user"}, args...)...)
3453 cmd.Env = env
3554 if err := cmd.Run(); err != nil {
36- log.Warn().Err(err).Msg("session unexport failed")
55+ log.Warn().Err(err).Strs("args", args).Msg(failed)
3756 }
3857 }
3958
40-func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) {
59+// runSessionCommand runs dbus-update-activation-environment and reports
60+// whether it succeeded.
61+func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) bool {
4162 path, err := childLookPath("dbus-update-activation-environment", env)
4263 if err != nil {
4364 log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found")
44- return
65+ return false
4566 }
4667 cmd := exec.CommandContext(ctx, path, args...)
4768 cmd.Env = env
4869 if err := cmd.Run(); err != nil {
4970 log.Warn().Err(err).Msg("session export failed")
50- return
71+ return false
5172 }
5273 log.Info().Strs("vars", sessionVars).Msg(done)
74+ return true
5375 }
5476
5577 // NotifyReady tells the systemd user manager that the compositor is ready.
@@ -13,43 +13,65 @@ import (
13 // notification daemons) need to reach the session.13 // notification daemons) need to reach the session.
14 var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"}14 var sessionVars = []string{"WAYLAND_DISPLAY", "DISPLAY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE"}
15 15
16+// portalUnits are the desktop portal and its backends. They read the
17+// session variables once, at start: a portal started without them (before
18+// the export, or after the last session ended) never serves this session.
19+// Portals are per user, like the exported variables: another graphical
20+// session of the same user running at once is not supported.
21+const portalUnits = "xdg-desktop-portal*.service"
22+
16 // ExportSession shares the session's displays with D-Bus and systemd user23 // ExportSession shares the session's displays with D-Bus and systemd user
17-// services, and waits for it: clients started next may activate them.24+// services, and waits for it: clients started next may activate them. Portals
25+// already running restart to read them; the restart does not block, since a
26+// backend may connect to the display before it serves.
18 func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) {27 func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) {
19 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)28 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
20 defer cancel()29 defer cancel()
21- runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported")30+ if !runSessionCommand(ctx, env, log, append([]string{"--systemd"}, sessionVars...), "session exported") {
31+ return
32+ }
33+ runSystemctl(ctx, env, log, []string{"--no-block", "try-restart", portalUnits}, "portal restart failed")
22 }34 }
23 35
24 // UnexportSession withdraws the displays from the systemd user manager on36 // UnexportSession withdraws the displays from the systemd user manager on
25-// exit, so services started after logout do not reach a dead display.37+// exit, so services started after logout do not reach a dead display, and
38+// stops the portals that still hold them.
26 func UnexportSession(env []string, log zerowrap.Logger) {39 func UnexportSession(env []string, log zerowrap.Logger) {
27 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)40 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
28 defer cancel()41 defer cancel()
42+ runSystemctl(ctx, env, log, append([]string{"unset-environment"}, sessionVars...), "session unexport failed")
43+ runSystemctl(ctx, env, log, []string{"--no-block", "stop", portalUnits}, "portal stop failed")
44+}
45+
46+// runSystemctl runs systemctl --user with args; a missing systemctl is skipped.
47+func runSystemctl(ctx context.Context, env []string, log zerowrap.Logger, args []string, failed string) {
29 path, err := childLookPath("systemctl", env)48 path, err := childLookPath("systemctl", env)
30 if err != nil {49 if err != nil {
31 return50 return
32 }51 }
33- cmd := exec.CommandContext(ctx, path, append([]string{"--user", "unset-environment"}, sessionVars...)...)52+ cmd := exec.CommandContext(ctx, path, append([]string{"--user"}, args...)...)
34 cmd.Env = env53 cmd.Env = env
35 if err := cmd.Run(); err != nil {54 if err := cmd.Run(); err != nil {
36- log.Warn().Err(err).Msg("session unexport failed")55+ log.Warn().Err(err).Strs("args", args).Msg(failed)
37 }56 }
38 }57 }
39 58
40-func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) {59+// runSessionCommand runs dbus-update-activation-environment and reports
60+// whether it succeeded.
61+func runSessionCommand(ctx context.Context, env []string, log zerowrap.Logger, args []string, done string) bool {
41 path, err := childLookPath("dbus-update-activation-environment", env)62 path, err := childLookPath("dbus-update-activation-environment", env)
42 if err != nil {63 if err != nil {
43 log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found")64 log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found")
44- return65+ return false
45 }66 }
46 cmd := exec.CommandContext(ctx, path, args...)67 cmd := exec.CommandContext(ctx, path, args...)
47 cmd.Env = env68 cmd.Env = env
48 if err := cmd.Run(); err != nil {69 if err := cmd.Run(); err != nil {
49 log.Warn().Err(err).Msg("session export failed")70 log.Warn().Err(err).Msg("session export failed")
50- return71+ return false
51 }72 }
52 log.Info().Strs("vars", sessionVars).Msg(done)73 log.Info().Strs("vars", sessionVars).Msg(done)
74+ return true
53 }75 }
54 76
55 // NotifyReady tells the systemd user manager that the compositor is ready.77 // NotifyReady tells the systemd user manager that the compositor is ready.
M internal/adapters/launcher/session_test.go +24 -2
@@ -32,17 +32,39 @@ func TestExportSession(t *testing.T) {
3232 t.Fatal(err)
3333 }
3434 want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
35- "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n"
35+ "systemctl --user --no-block try-restart xdg-desktop-portal*.service wayland-9\n" +
36+ "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
37+ "systemctl --user --no-block stop xdg-desktop-portal*.service wayland-9\n"
3638 if string(data) != want {
3739 t.Fatalf("calls:\n%s", data)
3840 }
3941 // Missing tools are skipped, not fatal.
4042 ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher"))
41- if strings.Count(string(data), "\n") != 2 {
43+ if strings.Count(string(data), "\n") != 4 {
4244 t.Fatal(string(data))
4345 }
4446 }
4547
48+// A failed export restarts no portal: it would read the old variables again.
49+func TestExportSessionFailedSkipsPortals(t *testing.T) {
50+ dir := t.TempDir()
51+ out := filepath.Join(dir, "calls")
52+ scripts := map[string]string{
53+ "dbus-update-activation-environment": "#!/bin/sh\nexit 1\n",
54+ "systemctl": "#!/bin/sh\necho \"$*\" >> " + out + "\n",
55+ }
56+ for name, script := range scripts {
57+ if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
58+ t.Fatal(err)
59+ }
60+ }
61+ ctx := context.Background()
62+ ExportSession(ctx, []string{"PATH=" + dir}, logging.For(ctx, "launcher"))
63+ if data, err := os.ReadFile(out); err == nil {
64+ t.Fatalf("systemctl called: %s", data)
65+ }
66+}
67+
4668 func TestNotifyReady(t *testing.T) {
4769 ctx := context.Background()
4870 log := logging.For(ctx, "launcher")
@@ -32,17 +32,39 @@ func TestExportSession(t *testing.T) {
32 t.Fatal(err)32 t.Fatal(err)
33 }33 }
34 want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +34 want := "dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
35- "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n"35+ "systemctl --user --no-block try-restart xdg-desktop-portal*.service wayland-9\n" +
36+ "systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE wayland-9\n" +
37+ "systemctl --user --no-block stop xdg-desktop-portal*.service wayland-9\n"
36 if string(data) != want {38 if string(data) != want {
37 t.Fatalf("calls:\n%s", data)39 t.Fatalf("calls:\n%s", data)
38 }40 }
39 // Missing tools are skipped, not fatal.41 // Missing tools are skipped, not fatal.
40 ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher"))42 ExportSession(ctx, []string{"PATH=" + t.TempDir()}, logging.For(ctx, "launcher"))
41- if strings.Count(string(data), "\n") != 2 {43+ if strings.Count(string(data), "\n") != 4 {
42 t.Fatal(string(data))44 t.Fatal(string(data))
43 }45 }
44 }46 }
45 47
48+// A failed export restarts no portal: it would read the old variables again.
49+func TestExportSessionFailedSkipsPortals(t *testing.T) {
50+ dir := t.TempDir()
51+ out := filepath.Join(dir, "calls")
52+ scripts := map[string]string{
53+ "dbus-update-activation-environment": "#!/bin/sh\nexit 1\n",
54+ "systemctl": "#!/bin/sh\necho \"$*\" >> " + out + "\n",
55+ }
56+ for name, script := range scripts {
57+ if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
58+ t.Fatal(err)
59+ }
60+ }
61+ ctx := context.Background()
62+ ExportSession(ctx, []string{"PATH=" + dir}, logging.For(ctx, "launcher"))
63+ if data, err := os.ReadFile(out); err == nil {
64+ t.Fatalf("systemctl called: %s", data)
65+ }
66+}
67+
46 func TestNotifyReady(t *testing.T) {68 func TestNotifyReady(t *testing.T) {
47 ctx := context.Background()69 ctx := context.Background()
48 log := logging.For(ctx, "launcher")70 log := logging.For(ctx, "launcher")
M internal/adapters/wayland/capture_admission_test.go +7 -2
@@ -189,7 +189,9 @@ func (h *admissionHarness) accepted(t *testing.T, p pendingCapture) (ports.Captu
189189 h.held = append(h.held, req)
190190 return req, true
191191 default:
192- if last != p.failed {
192+ // A refused wlr frame fails at once; an ext frame waits for a
193+ // retry (captureRetryFor) rather than ending the client's stream.
194+ if last != p.failed && (!p.ext || last != 0) {
193195 t.Fatalf("refused capture not failed: last event %d", last)
194196 }
195197 return ports.CaptureRequest{}, false
@@ -287,9 +289,12 @@ func TestExtCaptureAdmissionSaturation(t *testing.T) {
287289 }
288290 a.destroy(t, p)
289291 h.waitInflight(t, maxCaptureInflight)
290- if _, ok := h.accepted(t, a.captureExt(t)); ok {
292+ p = a.captureExt(t)
293+ if _, ok := h.accepted(t, p); ok {
291294 t.Fatal("cap exceeded after ext refill")
292295 }
296+ // A live refused frame would retry into the freed credit: drop it.
297+ a.destroy(t, p)
293298 h.complete(t, req, maxCaptureInflight-1)
294299 for i, r := range reqs[1:] {
295300 h.complete(t, r, maxCaptureInflight-2-i)
@@ -189,7 +189,9 @@ func (h *admissionHarness) accepted(t *testing.T, p pendingCapture) (ports.Captu
189 h.held = append(h.held, req)189 h.held = append(h.held, req)
190 return req, true190 return req, true
191 default:191 default:
192- if last != p.failed {192+ // A refused wlr frame fails at once; an ext frame waits for a
193+ // retry (captureRetryFor) rather than ending the client's stream.
194+ if last != p.failed && (!p.ext || last != 0) {
193 t.Fatalf("refused capture not failed: last event %d", last)195 t.Fatalf("refused capture not failed: last event %d", last)
194 }196 }
195 return ports.CaptureRequest{}, false197 return ports.CaptureRequest{}, false
@@ -287,9 +289,12 @@ func TestExtCaptureAdmissionSaturation(t *testing.T) {
287 }289 }
288 a.destroy(t, p)290 a.destroy(t, p)
289 h.waitInflight(t, maxCaptureInflight)291 h.waitInflight(t, maxCaptureInflight)
290- if _, ok := h.accepted(t, a.captureExt(t)); ok {292+ p = a.captureExt(t)
293+ if _, ok := h.accepted(t, p); ok {
291 t.Fatal("cap exceeded after ext refill")294 t.Fatal("cap exceeded after ext refill")
292 }295 }
296+ // A live refused frame would retry into the freed credit: drop it.
297+ a.destroy(t, p)
293 h.complete(t, req, maxCaptureInflight-1)298 h.complete(t, req, maxCaptureInflight-1)
294 for i, r := range reqs[1:] {299 for i, r := range reqs[1:] {
295 h.complete(t, r, maxCaptureInflight-2-i)300 h.complete(t, r, maxCaptureInflight-2-i)
A internal/adapters/wayland/foreign.go +159 -0
@@ -0,0 +1,159 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "slices"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgforeign"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// xdg-foreign-unstable-v2 lets a client make its toplevel the child of
13+// another client's toplevel. The desktop portal uses it: the application
14+// exports its window and passes the handle over D-Bus, the portal backend
15+// imports it and parents its dialog to it, so the dialog floats over the
16+// application that asked (window.parent) instead of opening as a new tile.
17+
18+// foreignExport is one zxdg_exported_v2: the handle of a toplevel.
19+type foreignExport struct {
20+ handle string
21+ window *window
22+ imports []*foreignImport
23+}
24+
25+// foreignImport is one zxdg_imported_v2 and the toplevels it parented.
26+type foreignImport struct {
27+ res *xdgforeign.ZxdgImportedV2
28+ export *foreignExport // nil once invalid
29+ children []*window
30+}
31+
32+func registerForeign(d *server.Display, s *Server) error {
33+ if err := xdgforeign.NewZxdgExporterV2Global(d, 1, func(c server.Client, v, id uint32) {
34+ _, _ = xdgforeign.NewZxdgExporterV2(c, int32(v), id, foreignExporter{s})
35+ }); err != nil {
36+ return err
37+ }
38+ return xdgforeign.NewZxdgImporterV2Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xdgforeign.NewZxdgImporterV2(c, int32(v), id, foreignImporter{s})
40+ })
41+}
42+
43+// toplevelOf is the xdg_toplevel window of a wl_surface, or nil.
44+func (s *Server) toplevelOf(surf *wayland.Surface) *window {
45+ st := s.surfaceOf(surf)
46+ if st == nil || st.xdg == nil || st.xdg.window == nil || st.xdg.window.toplevel == nil {
47+ return nil
48+ }
49+ return st.xdg.window
50+}
51+
52+type foreignExporter struct{ s *Server }
53+
54+func (foreignExporter) Destroy(*xdgforeign.ZxdgExporterV2) {}
55+
56+func (e foreignExporter) ExportToplevel(r *xdgforeign.ZxdgExporterV2, id uint32, surf *wayland.Surface) {
57+ w := e.s.toplevelOf(surf)
58+ if w == nil {
59+ r.PostError(uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface), "surface is not an xdg_toplevel")
60+ return
61+ }
62+ ex := &foreignExport{handle: rand.Text(), window: w}
63+ res, err := xdgforeign.NewZxdgExportedV2(r.Client(), r.Version(), id, exportedHandler{})
64+ if err != nil {
65+ return
66+ }
67+ e.s.foreignExports[ex.handle] = ex
68+ res.OnDestroy = func() { e.s.revokeExport(ex) }
69+ res.SendHandle(ex.handle)
70+}
71+
72+type exportedHandler struct{}
73+
74+func (exportedHandler) Destroy(*xdgforeign.ZxdgExportedV2) {}
75+
76+type foreignImporter struct{ s *Server }
77+
78+func (foreignImporter) Destroy(*xdgforeign.ZxdgImporterV2) {}
79+
80+// ImportToplevel imports a handle; an unknown one is destroyed at once, as
81+// the protocol asks.
82+func (i foreignImporter) ImportToplevel(r *xdgforeign.ZxdgImporterV2, id uint32, handle string) {
83+ imp := &foreignImport{}
84+ res, err := xdgforeign.NewZxdgImportedV2(r.Client(), r.Version(), id, importedHandler{s: i.s, imp: imp})
85+ if err != nil {
86+ return
87+ }
88+ imp.res = res
89+ res.OnDestroy = func() { imp.invalidate(false) }
90+ ex := i.s.foreignExports[handle]
91+ if ex == nil {
92+ res.SendDestroyed()
93+ return
94+ }
95+ imp.export = ex
96+ ex.imports = append(ex.imports, imp)
97+}
98+
99+type importedHandler struct {
100+ s *Server
101+ imp *foreignImport
102+}
103+
104+func (importedHandler) Destroy(*xdgforeign.ZxdgImportedV2) {}
105+
106+// SetParentOf makes the imported toplevel the parent of the client's own
107+// toplevel, as xdg_toplevel.set_parent.
108+func (h importedHandler) SetParentOf(r *xdgforeign.ZxdgImportedV2, surf *wayland.Surface) {
109+ child := h.s.toplevelOf(surf)
110+ if child == nil || child.toplevel.Client() != r.Client() {
111+ r.PostError(uint32(xdgforeign.ZxdgImportedV2ErrorInvalidSurface), "surface is not an xdg_toplevel of this client")
112+ return
113+ }
114+ ex := h.imp.export
115+ if ex == nil || ex.window == child {
116+ return
117+ }
118+ child.setParent(ex.window)
119+ if !slices.Contains(h.imp.children, child) {
120+ h.imp.children = append(h.imp.children, child)
121+ }
122+}
123+
124+// invalidate ends the relationships an import set up. send tells the client
125+// (destroyed) when the export, not the import, went away.
126+func (imp *foreignImport) invalidate(send bool) {
127+ for _, c := range imp.children {
128+ if imp.export != nil && c.parent == imp.export.window {
129+ c.setParent(nil)
130+ }
131+ }
132+ imp.children = nil
133+ if ex := imp.export; ex != nil {
134+ ex.imports = removeItem(ex.imports, imp)
135+ imp.export = nil
136+ }
137+ if send && imp.res.Alive() {
138+ imp.res.SendDestroyed()
139+ }
140+}
141+
142+// revokeExport drops a handle and every relationship made with it.
143+func (s *Server) revokeExport(ex *foreignExport) {
144+ if s.foreignExports[ex.handle] == ex {
145+ delete(s.foreignExports, ex.handle)
146+ }
147+ for len(ex.imports) > 0 {
148+ ex.imports[0].invalidate(true)
149+ }
150+}
151+
152+// foreignToplevelGone revokes the handles of a destroyed toplevel.
153+func (s *Server) foreignToplevelGone(w *window) {
154+ for _, ex := range s.foreignExports {
155+ if ex.window == w {
156+ s.revokeExport(ex)
157+ }
158+ }
159+}
@@ -0,0 +1,159 @@
1+package wayland
2+
3+import (
4+ "crypto/rand"
5+ "slices"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgforeign"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// xdg-foreign-unstable-v2 lets a client make its toplevel the child of
13+// another client's toplevel. The desktop portal uses it: the application
14+// exports its window and passes the handle over D-Bus, the portal backend
15+// imports it and parents its dialog to it, so the dialog floats over the
16+// application that asked (window.parent) instead of opening as a new tile.
17+
18+// foreignExport is one zxdg_exported_v2: the handle of a toplevel.
19+type foreignExport struct {
20+ handle string
21+ window *window
22+ imports []*foreignImport
23+}
24+
25+// foreignImport is one zxdg_imported_v2 and the toplevels it parented.
26+type foreignImport struct {
27+ res *xdgforeign.ZxdgImportedV2
28+ export *foreignExport // nil once invalid
29+ children []*window
30+}
31+
32+func registerForeign(d *server.Display, s *Server) error {
33+ if err := xdgforeign.NewZxdgExporterV2Global(d, 1, func(c server.Client, v, id uint32) {
34+ _, _ = xdgforeign.NewZxdgExporterV2(c, int32(v), id, foreignExporter{s})
35+ }); err != nil {
36+ return err
37+ }
38+ return xdgforeign.NewZxdgImporterV2Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xdgforeign.NewZxdgImporterV2(c, int32(v), id, foreignImporter{s})
40+ })
41+}
42+
43+// toplevelOf is the xdg_toplevel window of a wl_surface, or nil.
44+func (s *Server) toplevelOf(surf *wayland.Surface) *window {
45+ st := s.surfaceOf(surf)
46+ if st == nil || st.xdg == nil || st.xdg.window == nil || st.xdg.window.toplevel == nil {
47+ return nil
48+ }
49+ return st.xdg.window
50+}
51+
52+type foreignExporter struct{ s *Server }
53+
54+func (foreignExporter) Destroy(*xdgforeign.ZxdgExporterV2) {}
55+
56+func (e foreignExporter) ExportToplevel(r *xdgforeign.ZxdgExporterV2, id uint32, surf *wayland.Surface) {
57+ w := e.s.toplevelOf(surf)
58+ if w == nil {
59+ r.PostError(uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface), "surface is not an xdg_toplevel")
60+ return
61+ }
62+ ex := &foreignExport{handle: rand.Text(), window: w}
63+ res, err := xdgforeign.NewZxdgExportedV2(r.Client(), r.Version(), id, exportedHandler{})
64+ if err != nil {
65+ return
66+ }
67+ e.s.foreignExports[ex.handle] = ex
68+ res.OnDestroy = func() { e.s.revokeExport(ex) }
69+ res.SendHandle(ex.handle)
70+}
71+
72+type exportedHandler struct{}
73+
74+func (exportedHandler) Destroy(*xdgforeign.ZxdgExportedV2) {}
75+
76+type foreignImporter struct{ s *Server }
77+
78+func (foreignImporter) Destroy(*xdgforeign.ZxdgImporterV2) {}
79+
80+// ImportToplevel imports a handle; an unknown one is destroyed at once, as
81+// the protocol asks.
82+func (i foreignImporter) ImportToplevel(r *xdgforeign.ZxdgImporterV2, id uint32, handle string) {
83+ imp := &foreignImport{}
84+ res, err := xdgforeign.NewZxdgImportedV2(r.Client(), r.Version(), id, importedHandler{s: i.s, imp: imp})
85+ if err != nil {
86+ return
87+ }
88+ imp.res = res
89+ res.OnDestroy = func() { imp.invalidate(false) }
90+ ex := i.s.foreignExports[handle]
91+ if ex == nil {
92+ res.SendDestroyed()
93+ return
94+ }
95+ imp.export = ex
96+ ex.imports = append(ex.imports, imp)
97+}
98+
99+type importedHandler struct {
100+ s *Server
101+ imp *foreignImport
102+}
103+
104+func (importedHandler) Destroy(*xdgforeign.ZxdgImportedV2) {}
105+
106+// SetParentOf makes the imported toplevel the parent of the client's own
107+// toplevel, as xdg_toplevel.set_parent.
108+func (h importedHandler) SetParentOf(r *xdgforeign.ZxdgImportedV2, surf *wayland.Surface) {
109+ child := h.s.toplevelOf(surf)
110+ if child == nil || child.toplevel.Client() != r.Client() {
111+ r.PostError(uint32(xdgforeign.ZxdgImportedV2ErrorInvalidSurface), "surface is not an xdg_toplevel of this client")
112+ return
113+ }
114+ ex := h.imp.export
115+ if ex == nil || ex.window == child {
116+ return
117+ }
118+ child.setParent(ex.window)
119+ if !slices.Contains(h.imp.children, child) {
120+ h.imp.children = append(h.imp.children, child)
121+ }
122+}
123+
124+// invalidate ends the relationships an import set up. send tells the client
125+// (destroyed) when the export, not the import, went away.
126+func (imp *foreignImport) invalidate(send bool) {
127+ for _, c := range imp.children {
128+ if imp.export != nil && c.parent == imp.export.window {
129+ c.setParent(nil)
130+ }
131+ }
132+ imp.children = nil
133+ if ex := imp.export; ex != nil {
134+ ex.imports = removeItem(ex.imports, imp)
135+ imp.export = nil
136+ }
137+ if send && imp.res.Alive() {
138+ imp.res.SendDestroyed()
139+ }
140+}
141+
142+// revokeExport drops a handle and every relationship made with it.
143+func (s *Server) revokeExport(ex *foreignExport) {
144+ if s.foreignExports[ex.handle] == ex {
145+ delete(s.foreignExports, ex.handle)
146+ }
147+ for len(ex.imports) > 0 {
148+ ex.imports[0].invalidate(true)
149+ }
150+}
151+
152+// foreignToplevelGone revokes the handles of a destroyed toplevel.
153+func (s *Server) foreignToplevelGone(w *window) {
154+ for _, ex := range s.foreignExports {
155+ if ex.window == w {
156+ s.revokeExport(ex)
157+ }
158+ }
159+}
A internal/adapters/wayland/foreign_test.go +205 -0
@@ -0,0 +1,205 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgforeign"
9+ xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// handleProxy receives zxdg_exported_v2.handle.
16+type handleProxy struct {
17+ wlturbo.BaseProxy
18+ handles chan string
19+}
20+
21+func (p *handleProxy) Dispatch(e *wlturbo.Event) { p.handles <- e.String() }
22+
23+// destroyedProxy receives zxdg_imported_v2.destroyed.
24+type destroyedProxy struct {
25+ wlturbo.BaseProxy
26+ destroyed chan struct{}
27+}
28+
29+func (p *destroyedProxy) Dispatch(*wlturbo.Event) { p.destroyed <- struct{}{} }
30+
31+// exportToplevel exports surf and returns the exported object and handle.
32+func exportToplevel(t *testing.T, c *wlturbo.Display, surf uint32) (uint32, string) {
33+ t.Helper()
34+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
35+ id := c.AllocateID()
36+ p := &handleProxy{handles: make(chan string, 1)}
37+ p.SetID(id)
38+ registerWireProxy(c, p)
39+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
40+ if err := c.Roundtrip(); err != nil {
41+ t.Fatal(err)
42+ }
43+ select {
44+ case h := <-p.handles:
45+ return id, h
46+ default:
47+ t.Fatal("no handle")
48+ return 0, ""
49+ }
50+}
51+
52+// importToplevel imports handle; the proxy reports destroyed.
53+func importToplevel(t *testing.T, c *wlturbo.Display, handle string) (uint32, *destroyedProxy) {
54+ t.Helper()
55+ importer := bindProtocol(t, c, "zxdg_importer_v2")
56+ id := c.AllocateID()
57+ p := &destroyedProxy{destroyed: make(chan struct{}, 1)}
58+ p.SetID(id)
59+ registerWireProxy(c, p)
60+ requestProtocol(t, c, importer, xdgforeign.ZxdgImporterV2RequestImportToplevel, id, handle)
61+ if err := c.Roundtrip(); err != nil {
62+ t.Fatal(err)
63+ }
64+ return id, p
65+}
66+
67+// childToplevel maps a toplevel of c whose parent is set by parent, called
68+// before its first buffer (as a portal dialog does).
69+func childToplevel(t *testing.T, c *wlturbo.Display, parent func(surf uint32)) uint32 {
70+ t.Helper()
71+ comp := bindProtocol(t, c, "wl_compositor")
72+ wm := bindProtocol(t, c, "xdg_wm_base")
73+ shm := bindProtocol(t, c, "wl_shm")
74+ registerProtocol(t, c, shm)
75+ fd, err := unix.MemfdCreate("dialog-buffer", 0)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer unix.Close(fd)
80+ if err := unix.Ftruncate(fd, 4); err != nil {
81+ t.Fatal(err)
82+ }
83+ pool, buffer := c.AllocateID(), c.AllocateID()
84+ registerProtocol(t, c, pool)
85+ registerProtocol(t, c, buffer)
86+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
87+ t.Fatal(err)
88+ }
89+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
90+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
91+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
92+ registerProtocol(t, c, surf)
93+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
94+ serials := make(chan uint32, 1)
95+ xp := &configureProxy{serial: serials}
96+ xp.SetID(xdg)
97+ registerWireProxy(c, xp)
98+ registerProtocol(t, c, top)
99+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
100+ parent(surf)
101+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
102+ if err := c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ select {
106+ case serial := <-serials:
107+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
108+ default:
109+ t.Fatal("missing configure")
110+ }
111+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
112+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ return surf
117+}
118+
119+// A portal backend parents its dialog to another client's window through
120+// xdg-foreign: the dialog maps floating, with that window as its parent.
121+func TestForeignParentsDialog(t *testing.T) {
122+ s, events, _, dir := lifecycleServer(t)
123+ app := protocolClient(t, s, dir)
124+ appWin, appSurf, _ := surfaceMapper(t, app, events)()
125+ _, handle := exportToplevel(t, app, appSurf)
126+
127+ portal := protocolClient(t, s, dir)
128+ imported, _ := importToplevel(t, portal, handle)
129+ childToplevel(t, portal, func(surf uint32) {
130+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
131+ })
132+ dialog := mapped(t, events, 2*time.Second)
133+ if !dialog.Floating || dialog.Parent != appWin.ID {
134+ t.Fatalf("dialog %+v, want floating over %d", dialog, appWin.ID)
135+ }
136+
137+ // Destroying the import ends the relationship: core hears it.
138+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestDestroy)
139+ if err := portal.Roundtrip(); err != nil {
140+ t.Fatal(err)
141+ }
142+ for deadline := time.After(2 * time.Second); ; {
143+ select {
144+ case ev := <-events:
145+ if p, ok := ev.(ports.WindowParent); ok {
146+ if p.ID != dialog.ID || p.Parent != 0 {
147+ t.Fatalf("parent change %+v", p)
148+ }
149+ return
150+ }
151+ case <-deadline:
152+ t.Fatal("no parent change")
153+ }
154+ }
155+}
156+
157+// An unknown handle is destroyed at once; revoking an export destroys its
158+// imports and drops the parent they set.
159+func TestForeignInvalidHandles(t *testing.T) {
160+ s, events, _, dir := lifecycleServer(t)
161+ portal := protocolClient(t, s, dir)
162+ _, unknown := importToplevel(t, portal, "not-a-handle")
163+ select {
164+ case <-unknown.destroyed:
165+ default:
166+ t.Fatal("unknown handle not destroyed")
167+ }
168+
169+ app := protocolClient(t, s, dir)
170+ _, appSurf, _ := surfaceMapper(t, app, events)()
171+ exported, handle := exportToplevel(t, app, appSurf)
172+ imported, p := importToplevel(t, portal, handle)
173+ requestProtocol(t, app, exported, xdgforeign.ZxdgExportedV2RequestDestroy)
174+ if err := app.Roundtrip(); err != nil {
175+ t.Fatal(err)
176+ }
177+ if err := portal.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case <-p.destroyed:
182+ default:
183+ t.Fatal("import not destroyed with its export")
184+ }
185+ childToplevel(t, portal, func(surf uint32) {
186+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
187+ })
188+ if dialog := mapped(t, events, 2*time.Second); dialog.Floating || dialog.Parent != 0 {
189+ t.Fatalf("revoked parent still applied: %+v", dialog)
190+ }
191+}
192+
193+// Only toplevels can be exported.
194+func TestForeignExportNeedsToplevel(t *testing.T) {
195+ s, _, _, dir := lifecycleServer(t)
196+ c := protocolClient(t, s, dir)
197+ comp := bindProtocol(t, c, "wl_compositor")
198+ surf := c.AllocateID()
199+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
200+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
201+ id := c.AllocateID()
202+ registerProtocol(t, c, id)
203+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
204+ expectProtocolError(t, c, exporter, uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface))
205+}
@@ -0,0 +1,205 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/bnema/go-wayland-bindings/server/xdgforeign"
9+ xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/wlturbo"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// handleProxy receives zxdg_exported_v2.handle.
16+type handleProxy struct {
17+ wlturbo.BaseProxy
18+ handles chan string
19+}
20+
21+func (p *handleProxy) Dispatch(e *wlturbo.Event) { p.handles <- e.String() }
22+
23+// destroyedProxy receives zxdg_imported_v2.destroyed.
24+type destroyedProxy struct {
25+ wlturbo.BaseProxy
26+ destroyed chan struct{}
27+}
28+
29+func (p *destroyedProxy) Dispatch(*wlturbo.Event) { p.destroyed <- struct{}{} }
30+
31+// exportToplevel exports surf and returns the exported object and handle.
32+func exportToplevel(t *testing.T, c *wlturbo.Display, surf uint32) (uint32, string) {
33+ t.Helper()
34+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
35+ id := c.AllocateID()
36+ p := &handleProxy{handles: make(chan string, 1)}
37+ p.SetID(id)
38+ registerWireProxy(c, p)
39+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
40+ if err := c.Roundtrip(); err != nil {
41+ t.Fatal(err)
42+ }
43+ select {
44+ case h := <-p.handles:
45+ return id, h
46+ default:
47+ t.Fatal("no handle")
48+ return 0, ""
49+ }
50+}
51+
52+// importToplevel imports handle; the proxy reports destroyed.
53+func importToplevel(t *testing.T, c *wlturbo.Display, handle string) (uint32, *destroyedProxy) {
54+ t.Helper()
55+ importer := bindProtocol(t, c, "zxdg_importer_v2")
56+ id := c.AllocateID()
57+ p := &destroyedProxy{destroyed: make(chan struct{}, 1)}
58+ p.SetID(id)
59+ registerWireProxy(c, p)
60+ requestProtocol(t, c, importer, xdgforeign.ZxdgImporterV2RequestImportToplevel, id, handle)
61+ if err := c.Roundtrip(); err != nil {
62+ t.Fatal(err)
63+ }
64+ return id, p
65+}
66+
67+// childToplevel maps a toplevel of c whose parent is set by parent, called
68+// before its first buffer (as a portal dialog does).
69+func childToplevel(t *testing.T, c *wlturbo.Display, parent func(surf uint32)) uint32 {
70+ t.Helper()
71+ comp := bindProtocol(t, c, "wl_compositor")
72+ wm := bindProtocol(t, c, "xdg_wm_base")
73+ shm := bindProtocol(t, c, "wl_shm")
74+ registerProtocol(t, c, shm)
75+ fd, err := unix.MemfdCreate("dialog-buffer", 0)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer unix.Close(fd)
80+ if err := unix.Ftruncate(fd, 4); err != nil {
81+ t.Fatal(err)
82+ }
83+ pool, buffer := c.AllocateID(), c.AllocateID()
84+ registerProtocol(t, c, pool)
85+ registerProtocol(t, c, buffer)
86+ if err := wireRequest(c, shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
87+ t.Fatal(err)
88+ }
89+ requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
90+ surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
91+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
92+ registerProtocol(t, c, surf)
93+ requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
94+ serials := make(chan uint32, 1)
95+ xp := &configureProxy{serial: serials}
96+ xp.SetID(xdg)
97+ registerWireProxy(c, xp)
98+ registerProtocol(t, c, top)
99+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top)
100+ parent(surf)
101+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
102+ if err := c.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ select {
106+ case serial := <-serials:
107+ requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial)
108+ default:
109+ t.Fatal("missing configure")
110+ }
111+ requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
112+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
113+ if err := c.Roundtrip(); err != nil {
114+ t.Fatal(err)
115+ }
116+ return surf
117+}
118+
119+// A portal backend parents its dialog to another client's window through
120+// xdg-foreign: the dialog maps floating, with that window as its parent.
121+func TestForeignParentsDialog(t *testing.T) {
122+ s, events, _, dir := lifecycleServer(t)
123+ app := protocolClient(t, s, dir)
124+ appWin, appSurf, _ := surfaceMapper(t, app, events)()
125+ _, handle := exportToplevel(t, app, appSurf)
126+
127+ portal := protocolClient(t, s, dir)
128+ imported, _ := importToplevel(t, portal, handle)
129+ childToplevel(t, portal, func(surf uint32) {
130+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
131+ })
132+ dialog := mapped(t, events, 2*time.Second)
133+ if !dialog.Floating || dialog.Parent != appWin.ID {
134+ t.Fatalf("dialog %+v, want floating over %d", dialog, appWin.ID)
135+ }
136+
137+ // Destroying the import ends the relationship: core hears it.
138+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestDestroy)
139+ if err := portal.Roundtrip(); err != nil {
140+ t.Fatal(err)
141+ }
142+ for deadline := time.After(2 * time.Second); ; {
143+ select {
144+ case ev := <-events:
145+ if p, ok := ev.(ports.WindowParent); ok {
146+ if p.ID != dialog.ID || p.Parent != 0 {
147+ t.Fatalf("parent change %+v", p)
148+ }
149+ return
150+ }
151+ case <-deadline:
152+ t.Fatal("no parent change")
153+ }
154+ }
155+}
156+
157+// An unknown handle is destroyed at once; revoking an export destroys its
158+// imports and drops the parent they set.
159+func TestForeignInvalidHandles(t *testing.T) {
160+ s, events, _, dir := lifecycleServer(t)
161+ portal := protocolClient(t, s, dir)
162+ _, unknown := importToplevel(t, portal, "not-a-handle")
163+ select {
164+ case <-unknown.destroyed:
165+ default:
166+ t.Fatal("unknown handle not destroyed")
167+ }
168+
169+ app := protocolClient(t, s, dir)
170+ _, appSurf, _ := surfaceMapper(t, app, events)()
171+ exported, handle := exportToplevel(t, app, appSurf)
172+ imported, p := importToplevel(t, portal, handle)
173+ requestProtocol(t, app, exported, xdgforeign.ZxdgExportedV2RequestDestroy)
174+ if err := app.Roundtrip(); err != nil {
175+ t.Fatal(err)
176+ }
177+ if err := portal.Roundtrip(); err != nil {
178+ t.Fatal(err)
179+ }
180+ select {
181+ case <-p.destroyed:
182+ default:
183+ t.Fatal("import not destroyed with its export")
184+ }
185+ childToplevel(t, portal, func(surf uint32) {
186+ requestProtocol(t, portal, imported, xdgforeign.ZxdgImportedV2RequestSetParentOf, surf)
187+ })
188+ if dialog := mapped(t, events, 2*time.Second); dialog.Floating || dialog.Parent != 0 {
189+ t.Fatalf("revoked parent still applied: %+v", dialog)
190+ }
191+}
192+
193+// Only toplevels can be exported.
194+func TestForeignExportNeedsToplevel(t *testing.T) {
195+ s, _, _, dir := lifecycleServer(t)
196+ c := protocolClient(t, s, dir)
197+ comp := bindProtocol(t, c, "wl_compositor")
198+ surf := c.AllocateID()
199+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
200+ exporter := bindProtocol(t, c, "zxdg_exporter_v2")
201+ id := c.AllocateID()
202+ registerProtocol(t, c, id)
203+ requestProtocol(t, c, exporter, xdgforeign.ZxdgExporterV2RequestExportToplevel, id, surf)
204+ expectProtocolError(t, c, exporter, uint32(xdgforeign.ZxdgExporterV2ErrorInvalidSurface))
205+}
M internal/adapters/wayland/globals.go +1 -0
@@ -42,6 +42,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
4242 func() error { return registerExclusion(d, s) },
4343 func() error { return registerSecurityContext(d, s) },
4444 func() error { return registerActivation(d, s) },
45+ func() error { return registerForeign(d, s) },
4546 func() error { return registerVirtualKeyboard(d, s) },
4647 func() error { return registerTextInput(d, s) },
4748 func() error { return registerInputMethod(d, s) },
@@ -42,6 +42,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
42 func() error { return registerExclusion(d, s) },42 func() error { return registerExclusion(d, s) },
43 func() error { return registerSecurityContext(d, s) },43 func() error { return registerSecurityContext(d, s) },
44 func() error { return registerActivation(d, s) },44 func() error { return registerActivation(d, s) },
45+ func() error { return registerForeign(d, s) },
45 func() error { return registerVirtualKeyboard(d, s) },46 func() error { return registerVirtualKeyboard(d, s) },
46 func() error { return registerTextInput(d, s) },47 func() error { return registerTextInput(d, s) },
47 func() error { return registerInputMethod(d, s) },48 func() error { return registerInputMethod(d, s) },
M internal/adapters/wayland/imagecapture_test.go +116 -8
@@ -2,6 +2,8 @@ package wayland
22
33 import (
44 "context"
5+ "errors"
6+ "fmt"
57 "image"
68 "strings"
79 "sync/atomic"
@@ -16,9 +18,11 @@ import (
1618 wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
1719 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
1820 "github.com/bnema/neferwl/internal/logging"
21+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
1922 "github.com/bnema/neferwl/internal/ports"
2023 "github.com/bnema/purego-libwayland/server"
2124 "github.com/bnema/wlturbo"
25+ "github.com/stretchr/testify/mock"
2226 "golang.org/x/sys/unix"
2327 )
2428
@@ -316,6 +320,22 @@ func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEv
316320 return ev
317321 }
318322
323+// liveExtFrame is extFrame whose frame object stays alive, so a held frame
324+// can still be served.
325+func (cc *cclient) liveExtFrame(t *testing.T, session uint32, w, h int32) *captureEvents {
326+ t.Helper()
327+ buf, _ := captureShm(t, cc.c, w, h)
328+ frame := cc.c.AllocateID()
329+ ev := &captureEvents{events: make(chan uint16, 16)}
330+ ev.SetID(frame)
331+ registerWireProxy(cc.c, ev)
332+ requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
333+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
334+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
335+ cc.roundtrip(t)
336+ return ev
337+}
338+
319339 // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.
320340 func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {
321341 t.Helper()
@@ -947,21 +967,21 @@ func TestExclusionDestroyFreesTheSlot(t *testing.T) {
947967 }
948968 }
949969
950-// Frames of the session that owns the exclusion are fenced: refused until
951-// core knows the exclusion, then tagged with its revision. Every other
952-// capture, wlr-screencopy included, carries no tag.
970+// Frames of the session that owns the exclusion are fenced: held until core
971+// knows the exclusion, then tagged with its revision. Every other capture,
972+// wlr-screencopy included, carries no tag.
953973 func TestExclusionFrameTagging(t *testing.T) {
954974 h := newCaptureHarness(t)
955975 owner, session, open, _, _ := h.exclusionOwner(t)
956- // Core has not confirmed the exclusion: the frame fails.
957- ev := owner.extFrame(t, session, 4, 4)
958- if !eventsInclude(ev, frameFailed) {
959- t.Fatal("a frame before core confirmed the exclusion was served")
976+ // Core has not confirmed the exclusion: the frame waits, unserved.
977+ ev := owner.liveExtFrame(t, session, 4, 4)
978+ if eventsInclude(ev, frameFailed) || eventsInclude(ev, frameReady) {
979+ t.Fatal("a frame before core confirmed the exclusion was answered")
960980 }
961981 noCapture(t, h.captures)
982+ // Once core confirms it, the held frame is served, tagged.
962983 h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})
963984 h.settle(t, owner)
964- owner.extFrame(t, session, 4, 4)
965985 r := receiveCapture(t, h.captures)
966986 if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {
967987 t.Fatalf("owner frame %+v", r)
@@ -986,6 +1006,94 @@ func TestExclusionFrameTagging(t *testing.T) {
9861006 }
9871007 }
9881008
1009+// A transient renderer failure is asked again and served: failing the frame
1010+// would end a portal screencast. A permanent one fails at once, unretried.
1011+func TestExtFrameRendererFailure(t *testing.T) {
1012+ for _, tc := range []struct {
1013+ name string
1014+ err error
1015+ retry bool
1016+ }{
1017+ {"transient", fmt.Errorf("%w: indicator", ports.ErrCaptureTransient), true},
1018+ {"permanent", errors.New("capture buffer truncated"), false},
1019+ } {
1020+ t.Run(tc.name, func(t *testing.T) {
1021+ h := newCaptureHarness(t)
1022+ cc := h.client(t)
1023+ session, ev := cc.session(t, cc.outputSource(t))
1024+ constraints(t, cc, ev)
1025+ open := h.open(t)
1026+ h.send(active(open.ID))
1027+ h.settle(t, cc)
1028+ frame := cc.liveExtFrame(t, session, 4, 4)
1029+ r := receiveCapture(t, h.captures)
1030+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: tc.err}
1031+ if tc.retry {
1032+ r = receiveCapture(t, h.captures)
1033+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
1034+ }
1035+ for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) {
1036+ cc.roundtrip(t)
1037+ if time.Now().After(deadline) {
1038+ t.Fatal("frame not answered")
1039+ }
1040+ }
1041+ if failed := eventsInclude(frame, frameFailed); failed == tc.retry {
1042+ t.Fatalf("failed = %v", failed)
1043+ }
1044+ if !tc.retry {
1045+ noCapture(t, h.captures)
1046+ }
1047+ })
1048+ }
1049+}
1050+
1051+// A frame whose target stays unavailable fails once its retry deadline has
1052+// passed, so a client is never left waiting.
1053+func TestExtFrameRetryDeadline(t *testing.T) {
1054+ var now atomic.Int64
1055+ timers := make(chan func(), 4)
1056+ clock := portsmocks.NewMockClock(t)
1057+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(0, now.Load()) }).Maybe()
1058+ clock.EXPECT().AfterFunc(mock.Anything, mock.Anything).RunAndReturn(func(_ time.Duration, f func()) ports.Timer {
1059+ timers <- f
1060+ return portsmocks.NewMockTimer(t)
1061+ }).Maybe()
1062+ h := newCaptureHarnessWith(t, func(o *Options, _ *Channels) { o.Clock = clock })
1063+ cc := h.client(t)
1064+ session, ev := cc.session(t, cc.outputSource(t))
1065+ constraints(t, cc, ev)
1066+ open := h.open(t)
1067+ h.send(active(open.ID))
1068+ h.settle(t, cc)
1069+ frame := cc.liveExtFrame(t, session, 4, 4)
1070+ busy := fmt.Errorf("%w: busy", ports.ErrCaptureTransient)
1071+ r := receiveCapture(t, h.captures)
1072+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy}
1073+ var fire func()
1074+ select {
1075+ case fire = <-timers:
1076+ case <-time.After(2 * time.Second):
1077+ t.Fatal("no retry armed")
1078+ }
1079+ now.Store(int64(captureRetryFor))
1080+ fire()
1081+ r = receiveCapture(t, h.captures)
1082+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy}
1083+ for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) {
1084+ cc.roundtrip(t)
1085+ if time.Now().After(deadline) {
1086+ t.Fatal("frame not failed past its deadline")
1087+ }
1088+ }
1089+ if !eventsInclude(frame, frameFailed) {
1090+ t.Fatal("frame not failed past its deadline")
1091+ }
1092+ if len(timers) > 0 {
1093+ t.Fatal("retried past its deadline")
1094+ }
1095+}
1096+
9891097 // hud is a second connection: a layer surface it attaches with the token.
9901098 type hud struct {
9911099 *cclient
@@ -2,6 +2,8 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "fmt"
5 "image"7 "image"
6 "strings"8 "strings"
7 "sync/atomic"9 "sync/atomic"
@@ -16,9 +18,11 @@ import (
16 wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"18 wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
17 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"19 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
18 "github.com/bnema/neferwl/internal/logging"20 "github.com/bnema/neferwl/internal/logging"
21+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
19 "github.com/bnema/neferwl/internal/ports"22 "github.com/bnema/neferwl/internal/ports"
20 "github.com/bnema/purego-libwayland/server"23 "github.com/bnema/purego-libwayland/server"
21 "github.com/bnema/wlturbo"24 "github.com/bnema/wlturbo"
25+ "github.com/stretchr/testify/mock"
22 "golang.org/x/sys/unix"26 "golang.org/x/sys/unix"
23 )27 )
24 28
@@ -316,6 +320,22 @@ func (cc *cclient) extFrame(t *testing.T, session uint32, w, h int32) *captureEv
316 return ev320 return ev
317 }321 }
318 322
323+// liveExtFrame is extFrame whose frame object stays alive, so a held frame
324+// can still be served.
325+func (cc *cclient) liveExtFrame(t *testing.T, session uint32, w, h int32) *captureEvents {
326+ t.Helper()
327+ buf, _ := captureShm(t, cc.c, w, h)
328+ frame := cc.c.AllocateID()
329+ ev := &captureEvents{events: make(chan uint16, 16)}
330+ ev.SetID(frame)
331+ registerWireProxy(cc.c, ev)
332+ requestProtocol(t, cc.c, session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
333+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, buf)
334+ requestProtocol(t, cc.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
335+ cc.roundtrip(t)
336+ return ev
337+}
338+
319 // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.339 // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer.
320 func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {340 func (cc *cclient) wlrFrame(t *testing.T) *captureEvents {
321 t.Helper()341 t.Helper()
@@ -947,21 +967,21 @@ func TestExclusionDestroyFreesTheSlot(t *testing.T) {
947 }967 }
948 }968 }
949 969
950-// Frames of the session that owns the exclusion are fenced: refused until970+// Frames of the session that owns the exclusion are fenced: held until core
951-// core knows the exclusion, then tagged with its revision. Every other971+// knows the exclusion, then tagged with its revision. Every other capture,
952-// capture, wlr-screencopy included, carries no tag.972+// wlr-screencopy included, carries no tag.
953 func TestExclusionFrameTagging(t *testing.T) {973 func TestExclusionFrameTagging(t *testing.T) {
954 h := newCaptureHarness(t)974 h := newCaptureHarness(t)
955 owner, session, open, _, _ := h.exclusionOwner(t)975 owner, session, open, _, _ := h.exclusionOwner(t)
956- // Core has not confirmed the exclusion: the frame fails.976+ // Core has not confirmed the exclusion: the frame waits, unserved.
957- ev := owner.extFrame(t, session, 4, 4)977+ ev := owner.liveExtFrame(t, session, 4, 4)
958- if !eventsInclude(ev, frameFailed) {978+ if eventsInclude(ev, frameFailed) || eventsInclude(ev, frameReady) {
959- t.Fatal("a frame before core confirmed the exclusion was served")979+ t.Fatal("a frame before core confirmed the exclusion was answered")
960 }980 }
961 noCapture(t, h.captures)981 noCapture(t, h.captures)
982+ // Once core confirms it, the held frame is served, tagged.
962 h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})983 h.send(ports.CaptureSessionState{ID: open.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 4, H: 4}, Active: true, Exclusion: true, Revision: 9})
963 h.settle(t, owner)984 h.settle(t, owner)
964- owner.extFrame(t, session, 4, 4)
965 r := receiveCapture(t, h.captures)985 r := receiveCapture(t, h.captures)
966 if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {986 if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 {
967 t.Fatalf("owner frame %+v", r)987 t.Fatalf("owner frame %+v", r)
@@ -986,6 +1006,94 @@ func TestExclusionFrameTagging(t *testing.T) {
986 }1006 }
987 }1007 }
988 1008
1009+// A transient renderer failure is asked again and served: failing the frame
1010+// would end a portal screencast. A permanent one fails at once, unretried.
1011+func TestExtFrameRendererFailure(t *testing.T) {
1012+ for _, tc := range []struct {
1013+ name string
1014+ err error
1015+ retry bool
1016+ }{
1017+ {"transient", fmt.Errorf("%w: indicator", ports.ErrCaptureTransient), true},
1018+ {"permanent", errors.New("capture buffer truncated"), false},
1019+ } {
1020+ t.Run(tc.name, func(t *testing.T) {
1021+ h := newCaptureHarness(t)
1022+ cc := h.client(t)
1023+ session, ev := cc.session(t, cc.outputSource(t))
1024+ constraints(t, cc, ev)
1025+ open := h.open(t)
1026+ h.send(active(open.ID))
1027+ h.settle(t, cc)
1028+ frame := cc.liveExtFrame(t, session, 4, 4)
1029+ r := receiveCapture(t, h.captures)
1030+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: tc.err}
1031+ if tc.retry {
1032+ r = receiveCapture(t, h.captures)
1033+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()}
1034+ }
1035+ for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) {
1036+ cc.roundtrip(t)
1037+ if time.Now().After(deadline) {
1038+ t.Fatal("frame not answered")
1039+ }
1040+ }
1041+ if failed := eventsInclude(frame, frameFailed); failed == tc.retry {
1042+ t.Fatalf("failed = %v", failed)
1043+ }
1044+ if !tc.retry {
1045+ noCapture(t, h.captures)
1046+ }
1047+ })
1048+ }
1049+}
1050+
1051+// A frame whose target stays unavailable fails once its retry deadline has
1052+// passed, so a client is never left waiting.
1053+func TestExtFrameRetryDeadline(t *testing.T) {
1054+ var now atomic.Int64
1055+ timers := make(chan func(), 4)
1056+ clock := portsmocks.NewMockClock(t)
1057+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(0, now.Load()) }).Maybe()
1058+ clock.EXPECT().AfterFunc(mock.Anything, mock.Anything).RunAndReturn(func(_ time.Duration, f func()) ports.Timer {
1059+ timers <- f
1060+ return portsmocks.NewMockTimer(t)
1061+ }).Maybe()
1062+ h := newCaptureHarnessWith(t, func(o *Options, _ *Channels) { o.Clock = clock })
1063+ cc := h.client(t)
1064+ session, ev := cc.session(t, cc.outputSource(t))
1065+ constraints(t, cc, ev)
1066+ open := h.open(t)
1067+ h.send(active(open.ID))
1068+ h.settle(t, cc)
1069+ frame := cc.liveExtFrame(t, session, 4, 4)
1070+ busy := fmt.Errorf("%w: busy", ports.ErrCaptureTransient)
1071+ r := receiveCapture(t, h.captures)
1072+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy}
1073+ var fire func()
1074+ select {
1075+ case fire = <-timers:
1076+ case <-time.After(2 * time.Second):
1077+ t.Fatal("no retry armed")
1078+ }
1079+ now.Store(int64(captureRetryFor))
1080+ fire()
1081+ r = receiveCapture(t, h.captures)
1082+ h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Err: busy}
1083+ for deadline := time.Now().Add(2 * time.Second); len(frame.events) == 0; time.Sleep(time.Millisecond) {
1084+ cc.roundtrip(t)
1085+ if time.Now().After(deadline) {
1086+ t.Fatal("frame not failed past its deadline")
1087+ }
1088+ }
1089+ if !eventsInclude(frame, frameFailed) {
1090+ t.Fatal("frame not failed past its deadline")
1091+ }
1092+ if len(timers) > 0 {
1093+ t.Fatal("retried past its deadline")
1094+ }
1095+}
1096+
989 // hud is a second connection: a layer surface it attaches with the token.1097 // hud is a second connection: a layer surface it attaches with the token.
990 type hud struct {1098 type hud struct {
991 *cclient1099 *cclient
M internal/adapters/wayland/imagecopy.go +54 -6
@@ -1,8 +1,10 @@
11 package wayland
22
33 import (
4+ "errors"
45 "image"
56 "math"
7+ "time"
68
79 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
810 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
@@ -441,8 +443,20 @@ type captureExtFrame struct {
441443 buf *wayland.Buffer
442444 used bool
443445 replyID uint64
446+ // deadline ends the retries of a frame whose target is not ready yet.
447+ deadline time.Time
444448 }
445449
450+// A frame whose target is briefly unavailable (not active yet, rendered off
451+// screen by another session, its indicator or exclusion not on screen yet,
452+// a renderer slot busy) is tried again rather than failed: failed(unknown)
453+// ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only
454+// a stopped session, a refused client or a wrong buffer fail at once.
455+const (
456+ captureRetryEvery = 16 * time.Millisecond
457+ captureRetryFor = time.Second
458+)
459+
446460 func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
447461 func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
448462 if f.used {
@@ -488,9 +502,34 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
488502 f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
489503 return
490504 }
505+ f.deadline = c.s.clock.Now().Add(captureRetryFor)
506+ f.attempt()
507+}
508+
509+// retry tries the frame again shortly, or fails it past its deadline.
510+func (f *captureExtFrame) retry(why string) {
511+ s := f.session.s
512+ if !s.clock.Now().Before(f.deadline) {
513+ s.log.Info().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries")
514+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
515+ return
516+ }
517+ s.clock.AfterFunc(captureRetryEvery, func() { s.display.Do(f.attempt) })
518+}
519+
520+// attempt serves the frame from the current target. Display goroutine only.
521+func (f *captureExtFrame) attempt() {
522+ if !f.res.Alive() {
523+ return
524+ }
525+ c := f.session
526+ if c.stopped || !c.s.mayCaptureFrame(f.res.Client()) {
527+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
528+ return
529+ }
491530 o, region, hidden, ok := c.geometry()
492531 if !ok || c.haveState && !c.st.Active {
493- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
532+ f.retry("target not ready")
494533 return
495534 }
496535 tag, ok := c.tag(hidden)
@@ -501,11 +540,16 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
501540 return
502541 case !ok:
503542 // The exclusion is not confirmed by core yet: nothing is served.
504- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
543+ f.retry("exclusion not confirmed")
505544 return
506545 }
507546 f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
508- if done.Err != nil {
547+ switch {
548+ case errors.Is(done.Err, ports.ErrCaptureTransient):
549+ f.retry(done.Err.Error())
550+ return
551+ case done.Err != nil:
552+ // Retrying cannot clear it (a bad buffer, an unsupported size).
509553 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
510554 return
511555 }
@@ -516,10 +560,14 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
516560 f.res.SendReady()
517561 }, f.res.Resource, tag)
518562 if !ok {
519- if !c.s.validCaptureBuffer(f.buf, region, buf.format) {
563+ switch {
564+ case !c.s.validCaptureBuffer(f.buf, region, buf.format):
520565 f.constraintsFailed(region)
521- } else {
522- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
566+ case !c.s.mayCaptureFrame(f.res.Client()):
567+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
568+ default:
569+ // The renderer queue or the in-flight bound is full.
570+ f.retry("capture queue full")
523571 }
524572 }
525573 }
@@ -1,8 +1,10 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "errors"
4 "image"5 "image"
5 "math"6 "math"
7+ "time"
6 8
7 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"9 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
8 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"10 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
@@ -441,8 +443,20 @@ type captureExtFrame struct {
441 buf *wayland.Buffer443 buf *wayland.Buffer
442 used bool444 used bool
443 replyID uint64445 replyID uint64
446+ // deadline ends the retries of a frame whose target is not ready yet.
447+ deadline time.Time
444 }448 }
445 449
450+// A frame whose target is briefly unavailable (not active yet, rendered off
451+// screen by another session, its indicator or exclusion not on screen yet,
452+// a renderer slot busy) is tried again rather than failed: failed(unknown)
453+// ends the whole screencast in clients such as xdg-desktop-portal-wlr. Only
454+// a stopped session, a refused client or a wrong buffer fail at once.
455+const (
456+ captureRetryEvery = 16 * time.Millisecond
457+ captureRetryFor = time.Second
458+)
459+
446 func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}460 func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {}
447 func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {461 func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) {
448 if f.used {462 if f.used {
@@ -488,9 +502,34 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
488 f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")502 f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached")
489 return503 return
490 }504 }
505+ f.deadline = c.s.clock.Now().Add(captureRetryFor)
506+ f.attempt()
507+}
508+
509+// retry tries the frame again shortly, or fails it past its deadline.
510+func (f *captureExtFrame) retry(why string) {
511+ s := f.session.s
512+ if !s.clock.Now().Before(f.deadline) {
513+ s.log.Info().Uint64("session", f.session.id).Str("reason", why).Msg("capture frame failed after retries")
514+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
515+ return
516+ }
517+ s.clock.AfterFunc(captureRetryEvery, func() { s.display.Do(f.attempt) })
518+}
519+
520+// attempt serves the frame from the current target. Display goroutine only.
521+func (f *captureExtFrame) attempt() {
522+ if !f.res.Alive() {
523+ return
524+ }
525+ c := f.session
526+ if c.stopped || !c.s.mayCaptureFrame(f.res.Client()) {
527+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
528+ return
529+ }
491 o, region, hidden, ok := c.geometry()530 o, region, hidden, ok := c.geometry()
492 if !ok || c.haveState && !c.st.Active {531 if !ok || c.haveState && !c.st.Active {
493- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))532+ f.retry("target not ready")
494 return533 return
495 }534 }
496 tag, ok := c.tag(hidden)535 tag, ok := c.tag(hidden)
@@ -501,11 +540,16 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
501 return540 return
502 case !ok:541 case !ok:
503 // The exclusion is not confirmed by core yet: nothing is served.542 // The exclusion is not confirmed by core yet: nothing is served.
504- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))543+ f.retry("exclusion not confirmed")
505 return544 return
506 }545 }
507 f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {546 f.replyID, ok = c.s.requestCapture(o, region, c.cursor, f.buf, buf.format, func(done ports.CaptureDone) {
508- if done.Err != nil {547+ switch {
548+ case errors.Is(done.Err, ports.ErrCaptureTransient):
549+ f.retry(done.Err.Error())
550+ return
551+ case done.Err != nil:
552+ // Retrying cannot clear it (a bad buffer, an unsupported size).
509 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))553 f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))
510 return554 return
511 }555 }
@@ -516,10 +560,14 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) {
516 f.res.SendReady()560 f.res.SendReady()
517 }, f.res.Resource, tag)561 }, f.res.Resource, tag)
518 if !ok {562 if !ok {
519- if !c.s.validCaptureBuffer(f.buf, region, buf.format) {563+ switch {
564+ case !c.s.validCaptureBuffer(f.buf, region, buf.format):
520 f.constraintsFailed(region)565 f.constraintsFailed(region)
521- } else {566+ case !c.s.mayCaptureFrame(f.res.Client()):
522- f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown))567+ f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonStopped))
568+ default:
569+ // The renderer queue or the in-flight bound is full.
570+ f.retry("capture queue full")
523 }571 }
524 }572 }
525 }573 }
M internal/adapters/wayland/screencopy.go +4 -2
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "errors"
45 "image"
56 "math"
67 "os"
@@ -209,9 +210,10 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
209210 s.emit(taken)
210211 }
211212 ev := s.log.Debug()
212- if done.Err != nil {
213- ev = s.log.Info().Err(done.Err)
213+ if done.Err != nil && !errors.Is(done.Err, ports.ErrCaptureTransient) {
214+ ev = s.log.Info()
214215 }
216+ ev = ev.Err(done.Err)
215217 ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done")
216218 if life.Alive() {
217219 reply(done)
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "errors"
4 "image"5 "image"
5 "math"6 "math"
6 "os"7 "os"
@@ -209,9 +210,10 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b
209 s.emit(taken)210 s.emit(taken)
210 }211 }
211 ev := s.log.Debug()212 ev := s.log.Debug()
212- if done.Err != nil {213+ if done.Err != nil && !errors.Is(done.Err, ports.ErrCaptureTransient) {
213- ev = s.log.Info().Err(done.Err)214+ ev = s.log.Info()
214 }215 }
216+ ev = ev.Err(done.Err)
215 ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done")217 ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done")
216 if life.Alive() {218 if life.Alive() {
217 reply(done)219 reply(done)
M internal/adapters/wayland/server.go +7 -4
@@ -170,10 +170,12 @@ type Server struct {
170170 readinessGeneration uint64
171171 lastFlip map[string]time.Time
172172 // tokens are the issued xdg-activation tokens (activation.go).
173- tokens map[string]activationToken
174- surfaces map[*server.Resource]*surface
175- buffers map[*server.Resource]clientBuffer
176- dmabuf *dmabufGlobal
173+ tokens map[string]activationToken
174+ // foreignExports are the live xdg-foreign handles (foreign.go).
175+ foreignExports map[string]*foreignExport
176+ surfaces map[*server.Resource]*surface
177+ buffers map[*server.Resource]clientBuffer
178+ dmabuf *dmabufGlobal
177179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
178180 hdrOutputs map[string]ports.OutputHDR
179181 colorID uint64
@@ -391,6 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
391393 s.colorOutputs = map[*colorOutput]struct{}{}
392394 s.colorFeedbacks = map[*colorFeedback]struct{}{}
393395 s.tokens = map[string]activationToken{}
396+ s.foreignExports = map[string]*foreignExport{}
394397 if opts.Keymap != "" {
395398 fd, size, e := keymapFile(opts.Keymap)
396399 if e != nil {
@@ -170,10 +170,12 @@ type Server struct {
170 readinessGeneration uint64170 readinessGeneration uint64
171 lastFlip map[string]time.Time171 lastFlip map[string]time.Time
172 // tokens are the issued xdg-activation tokens (activation.go).172 // tokens are the issued xdg-activation tokens (activation.go).
173- tokens map[string]activationToken173+ tokens map[string]activationToken
174- surfaces map[*server.Resource]*surface174+ // foreignExports are the live xdg-foreign handles (foreign.go).
175- buffers map[*server.Resource]clientBuffer175+ foreignExports map[string]*foreignExport
176- dmabuf *dmabufGlobal176+ surfaces map[*server.Resource]*surface
177+ buffers map[*server.Resource]clientBuffer
178+ dmabuf *dmabufGlobal
177 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.179 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
178 hdrOutputs map[string]ports.OutputHDR180 hdrOutputs map[string]ports.OutputHDR
179 colorID uint64181 colorID uint64
@@ -391,6 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
391 s.colorOutputs = map[*colorOutput]struct{}{}393 s.colorOutputs = map[*colorOutput]struct{}{}
392 s.colorFeedbacks = map[*colorFeedback]struct{}{}394 s.colorFeedbacks = map[*colorFeedback]struct{}{}
393 s.tokens = map[string]activationToken{}395 s.tokens = map[string]activationToken{}
396+ s.foreignExports = map[string]*foreignExport{}
394 if opts.Keymap != "" {397 if opts.Keymap != "" {
395 fd, size, e := keymapFile(opts.Keymap)398 fd, size, e := keymapFile(opts.Keymap)
396 if e != nil {399 if e != nil {
M internal/adapters/wayland/wire_client_test.go +5 -0
@@ -47,6 +47,7 @@ import (
4747 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"
4848 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
4949 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"
50+ wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
5051 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
5152 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
5253 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
@@ -189,6 +190,10 @@ var wireSchemas = map[string]wireSchema{
189190 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
190191 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
191192 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
193+ "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},
194+ "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},
195+ "zxdg_exported_v2": {wire_xdgforeign.ZxdgExportedV2Interface, nil, map[uint16]bool{0: true}, nil},
196+ "zxdg_imported_v2": {wire_xdgforeign.ZxdgImportedV2Interface, nil, map[uint16]bool{0: true}, nil},
192197 "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},
193198 "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},
194199 "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},
@@ -47,6 +47,7 @@ import (
47 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"47 wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement"
48 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"48 wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
49 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"49 wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation"
50+ wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
50 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"51 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
51 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"52 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
52 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"53 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
@@ -189,6 +190,10 @@ var wireSchemas = map[string]wireSchema{
189 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},190 "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil},
190 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},191 "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil},
191 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},192 "xdg_activation_v1": {wire_xdgactivation.ActivationV1Interface, &client_xdgactivation.XdgActivation{}, map[uint16]bool{0: true}, nil},
193+ "zxdg_exporter_v2": {wire_xdgforeign.ZxdgExporterV2Interface, nil, map[uint16]bool{0: true}, nil},
194+ "zxdg_importer_v2": {wire_xdgforeign.ZxdgImporterV2Interface, nil, map[uint16]bool{0: true}, nil},
195+ "zxdg_exported_v2": {wire_xdgforeign.ZxdgExportedV2Interface, nil, map[uint16]bool{0: true}, nil},
196+ "zxdg_imported_v2": {wire_xdgforeign.ZxdgImportedV2Interface, nil, map[uint16]bool{0: true}, nil},
192 "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},197 "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil},
193 "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},198 "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil},
194 "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},199 "xdg_surface": {wire_xdgshell.SurfaceInterface, &client_xdgshell.XdgSurface{}, map[uint16]bool{0: true}, nil},
M internal/adapters/wayland/xdg.go +29 -7
@@ -235,7 +235,11 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
235235 slot := x.server.slotToken(r.Client())
236236 // Its size comes with the commit, in WindowResized (afterCommit).
237237 w.floating = w.floats()
238- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH})
238+ var parent ports.WindowID
239+ if w.parent != nil && w.parent.mapped {
240+ parent = w.parent.id
241+ }
242+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent})
239243 x.server.syncInhibitors()
240244 x.server.toplevelChanged(w)
241245 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -245,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
245249 }
246250 t.OnDestroy = func() {
247251 w.unmap()
252+ x.server.foreignToplevelGone(w)
248253 x.surface.dropQueue()
249254 x.surface.current, x.surface.next.buffer = nil, nil
250255 x.surface.next.attached = false
@@ -294,15 +299,32 @@ type top struct{ w *window }
294299
295300 func (top) Destroy(*xdgshell.Toplevel) {}
296301 func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {
297- t.w.parent = nil
298- if parent == nil {
302+ var p *window
303+ if parent != nil {
304+ for _, w := range t.w.xdg.server.windows {
305+ if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {
306+ p = w
307+ }
308+ }
309+ }
310+ t.w.setParent(p)
311+}
312+
313+// setParent changes the dialog parent; core follows it once mapped (the
314+// map carries the parent of the first frame).
315+func (w *window) setParent(p *window) {
316+ if w.parent == p {
299317 return
300318 }
301- for _, w := range t.w.xdg.server.windows {
302- if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {
303- t.w.parent = w
304- }
319+ w.parent = p
320+ if !w.mapped {
321+ return
322+ }
323+ var id ports.WindowID
324+ if p != nil && p.mapped {
325+ id = p.id
305326 }
327+ w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
306328 }
307329 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
308330 if title == t.w.title {
@@ -235,7 +235,11 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
235 slot := x.server.slotToken(r.Client())235 slot := x.server.slotToken(r.Client())
236 // Its size comes with the commit, in WindowResized (afterCommit).236 // Its size comes with the commit, in WindowResized (afterCommit).
237 w.floating = w.floats()237 w.floating = w.floats()
238- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH})238+ var parent ports.WindowID
239+ if w.parent != nil && w.parent.mapped {
240+ parent = w.parent.id
241+ }
242+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent})
239 x.server.syncInhibitors()243 x.server.syncInhibitors()
240 x.server.toplevelChanged(w)244 x.server.toplevelChanged(w)
241 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")245 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped")
@@ -245,6 +249,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
245 }249 }
246 t.OnDestroy = func() {250 t.OnDestroy = func() {
247 w.unmap()251 w.unmap()
252+ x.server.foreignToplevelGone(w)
248 x.surface.dropQueue()253 x.surface.dropQueue()
249 x.surface.current, x.surface.next.buffer = nil, nil254 x.surface.current, x.surface.next.buffer = nil, nil
250 x.surface.next.attached = false255 x.surface.next.attached = false
@@ -294,15 +299,32 @@ type top struct{ w *window }
294 299
295 func (top) Destroy(*xdgshell.Toplevel) {}300 func (top) Destroy(*xdgshell.Toplevel) {}
296 func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {301 func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) {
297- t.w.parent = nil302+ var p *window
298- if parent == nil {303+ if parent != nil {
304+ for _, w := range t.w.xdg.server.windows {
305+ if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {
306+ p = w
307+ }
308+ }
309+ }
310+ t.w.setParent(p)
311+}
312+
313+// setParent changes the dialog parent; core follows it once mapped (the
314+// map carries the parent of the first frame).
315+func (w *window) setParent(p *window) {
316+ if w.parent == p {
299 return317 return
300 }318 }
301- for _, w := range t.w.xdg.server.windows {319+ w.parent = p
302- if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w {320+ if !w.mapped {
303- t.w.parent = w321+ return
304- }322+ }
323+ var id ports.WindowID
324+ if p != nil && p.mapped {
325+ id = p.id
305 }326 }
327+ w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
306 }328 }
307 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {329 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
308 if title == t.w.title {330 if title == t.w.title {
M internal/core/core.go +4 -0
@@ -1041,6 +1041,10 @@ func (c *Core) Run(ctx context.Context) error {
10411041 continue
10421042 case ports.WindowAppID:
10431043 c.windows.setAppID(v.ID, v.AppID)
1044+ case ports.WindowParent:
1045+ if _, w := c.screenOf(v.ID); w != nil {
1046+ w.SetDialogParent(v.ID, v.Parent)
1047+ }
10441048 case ports.WindowUnmapped:
10451049 if c.popups[v.ID] != nil {
10461050 if err := c.dropPopup(ctx, v.ID); err != nil {
@@ -1041,6 +1041,10 @@ func (c *Core) Run(ctx context.Context) error {
1041 continue1041 continue
1042 case ports.WindowAppID:1042 case ports.WindowAppID:
1043 c.windows.setAppID(v.ID, v.AppID)1043 c.windows.setAppID(v.ID, v.AppID)
1044+ case ports.WindowParent:
1045+ if _, w := c.screenOf(v.ID); w != nil {
1046+ w.SetDialogParent(v.ID, v.Parent)
1047+ }
1044 case ports.WindowUnmapped:1048 case ports.WindowUnmapped:
1045 if c.popups[v.ID] != nil {1049 if c.popups[v.ID] != nil {
1046 if err := c.dropPopup(ctx, v.ID); err != nil {1050 if err := c.dropPopup(ctx, v.ID); err != nil {
M internal/core/core_test.go +43 -0
@@ -797,6 +797,49 @@ func TestFloatOverFullscreenHit(t *testing.T) {
797797 }
798798 }
799799
800+// A dialog of the fullscreen window (a portal dialog parented through
801+// xdg-foreign) shows over it and takes the pointer at once.
802+func TestDialogOverFullscreenHit(t *testing.T) {
803+ cfg := config.Defaults()
804+ cfg.Border.Width = 0
805+ client := make(chan ports.ClientEvent, 8)
806+ input := make(chan ports.InputEvent, 8)
807+ output := make(chan ports.OutputEvent, 8)
808+ commands := make(chan ports.ClientCommand, 64)
809+ scenes := make(chan []ports.Scene, 1)
810+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
811+ if err != nil {
812+ t.Fatal(err)
813+ }
814+ ctx, cancel := context.WithCancel(context.Background())
815+ defer cancel()
816+ go c.Run(ctx)
817+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
818+ scene(t, scenes)
819+ client <- ports.WindowMapped{ID: 1}
820+ scene(t, scenes)
821+ client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
822+ scene(t, scenes)
823+ client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10, Parent: 1}
824+ s := scene(t, scenes)
825+ shown := false
826+ for _, w := range s.Windows {
827+ if w.ID == 2 {
828+ shown = !w.Hidden && w.Rect.W > 0
829+ }
830+ }
831+ if !shown {
832+ t.Fatalf("dialog not shown: %+v", s.Windows)
833+ }
834+ for len(commands) > 0 {
835+ <-commands
836+ }
837+ input <- ports.PointerMotion{X: 50, Y: 40}
838+ if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
839+ t.Fatalf("pointer went to %v, want the dialog", v)
840+ }
841+}
842+
800843 // A window that asks for fullscreen as it maps (Wine at a remembered
801844 // monitor size) stays in its column: the windows it opens next are seen.
802845 // A later request is honoured.
@@ -797,6 +797,49 @@ func TestFloatOverFullscreenHit(t *testing.T) {
797 }797 }
798 }798 }
799 799
800+// A dialog of the fullscreen window (a portal dialog parented through
801+// xdg-foreign) shows over it and takes the pointer at once.
802+func TestDialogOverFullscreenHit(t *testing.T) {
803+ cfg := config.Defaults()
804+ cfg.Border.Width = 0
805+ client := make(chan ports.ClientEvent, 8)
806+ input := make(chan ports.InputEvent, 8)
807+ output := make(chan ports.OutputEvent, 8)
808+ commands := make(chan ports.ClientCommand, 64)
809+ scenes := make(chan []ports.Scene, 1)
810+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
811+ if err != nil {
812+ t.Fatal(err)
813+ }
814+ ctx, cancel := context.WithCancel(context.Background())
815+ defer cancel()
816+ go c.Run(ctx)
817+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
818+ scene(t, scenes)
819+ client <- ports.WindowMapped{ID: 1}
820+ scene(t, scenes)
821+ client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true}
822+ scene(t, scenes)
823+ client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10, Parent: 1}
824+ s := scene(t, scenes)
825+ shown := false
826+ for _, w := range s.Windows {
827+ if w.ID == 2 {
828+ shown = !w.Hidden && w.Rect.W > 0
829+ }
830+ }
831+ if !shown {
832+ t.Fatalf("dialog not shown: %+v", s.Windows)
833+ }
834+ for len(commands) > 0 {
835+ <-commands
836+ }
837+ input <- ports.PointerMotion{X: 50, Y: 40}
838+ if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 {
839+ t.Fatalf("pointer went to %v, want the dialog", v)
840+ }
841+}
842+
800 // A window that asks for fullscreen as it maps (Wine at a remembered843 // A window that asks for fullscreen as it maps (Wine at a remembered
801 // monitor size) stays in its column: the windows it opens next are seen.844 // monitor size) stays in its column: the windows it opens next are seen.
802 // A later request is honoured.845 // A later request is honoured.
A internal/core/dialog_test.go +42 -0
@@ -0,0 +1,42 @@
1+package core
2+
3+import "testing"
4+
5+// A dialog of the covering fullscreen window shows over it with the focus;
6+// other floats stay hidden. A focus move goes back to the fullscreen window
7+// without leaving fullscreen.
8+func TestDialogOverFullscreenParent(t *testing.T) {
9+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
10+ t.Run(string(overflow), func(t *testing.T) {
11+ w := workspace()
12+ w.Overflow = overflow
13+ w.AddWindow(1)
14+ w.AddWindow(2)
15+ w.FocusID(1)
16+ w.ToggleFullscreen()
17+ w.AddFloating(3, 20, 10) // a dialog of nobody
18+ w.AddDialog(4, 1, 20, 10) // the dialog of the fullscreen window
19+ w.AddDialog(5, 2, 20, 10) // the dialog of a hidden window
20+ wantOrder(t, w, 1, 4)
21+ if id, _ := w.Focused(); id != 4 {
22+ t.Fatalf("focus %d, want the dialog", id)
23+ }
24+ if !w.FocusColumn(1) {
25+ t.Fatal("focus move refused")
26+ }
27+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
28+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
29+ }
30+ wantOrder(t, w, 1, 4)
31+ w.FocusID(4)
32+ if id, _ := w.Focused(); id != 4 {
33+ t.Fatalf("focus %d, want the dialog back", id)
34+ }
35+ w.RemoveWindow(4)
36+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
37+ t.Fatalf("after close focus %d fullscreen %d", id, w.fullscreen)
38+ }
39+ wantOrder(t, w, 1)
40+ })
41+ }
42+}
@@ -0,0 +1,42 @@
1+package core
2+
3+import "testing"
4+
5+// A dialog of the covering fullscreen window shows over it with the focus;
6+// other floats stay hidden. A focus move goes back to the fullscreen window
7+// without leaving fullscreen.
8+func TestDialogOverFullscreenParent(t *testing.T) {
9+ for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
10+ t.Run(string(overflow), func(t *testing.T) {
11+ w := workspace()
12+ w.Overflow = overflow
13+ w.AddWindow(1)
14+ w.AddWindow(2)
15+ w.FocusID(1)
16+ w.ToggleFullscreen()
17+ w.AddFloating(3, 20, 10) // a dialog of nobody
18+ w.AddDialog(4, 1, 20, 10) // the dialog of the fullscreen window
19+ w.AddDialog(5, 2, 20, 10) // the dialog of a hidden window
20+ wantOrder(t, w, 1, 4)
21+ if id, _ := w.Focused(); id != 4 {
22+ t.Fatalf("focus %d, want the dialog", id)
23+ }
24+ if !w.FocusColumn(1) {
25+ t.Fatal("focus move refused")
26+ }
27+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
28+ t.Fatalf("focus %d fullscreen %d", id, w.fullscreen)
29+ }
30+ wantOrder(t, w, 1, 4)
31+ w.FocusID(4)
32+ if id, _ := w.Focused(); id != 4 {
33+ t.Fatalf("focus %d, want the dialog back", id)
34+ }
35+ w.RemoveWindow(4)
36+ if id, _ := w.Focused(); id != 1 || w.fullscreen != 1 {
37+ t.Fatalf("after close focus %d fullscreen %d", id, w.fullscreen)
38+ }
39+ wantOrder(t, w, 1)
40+ })
41+ }
42+}
M internal/core/layout.go +71 -4
@@ -229,6 +229,9 @@ type Float struct {
229229 // usable area, so it keeps its place on any output.
230230 free bool
231231 cx, cy float64
232+ // over is the parent of a dialog: while the parent covers the output
233+ // fullscreen, the dialog shows over it and may take the focus.
234+ over WindowID
232235 }
233236
234237 func (w *Workspace) empty() bool {
@@ -315,6 +318,52 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
315318 w.floatFocus = true
316319 }
317320
321+// AddDialog is AddFloating for a dialog of parent: it shows, focused, over
322+// its parent's fullscreen. A dialog follows a user action in its parent
323+// (a portal request, a file chooser), so the user expects to see it.
324+func (w *Workspace) AddDialog(id, parent WindowID, width, height int) {
325+ if id == 0 || w.has(id) {
326+ return
327+ }
328+ w.Floats = append(w.Floats, Float{ID: id, W: width, H: height, over: parent})
329+ w.floatFocus = true
330+}
331+
332+// SetDialogParent follows a dialog's parent after it mapped: a cleared or
333+// changed parent ends its place over the old one's fullscreen.
334+func (w *Workspace) SetDialogParent(id, parent WindowID) {
335+ if i := w.floatIndex(id); i >= 0 {
336+ w.Floats[i].over = parent
337+ w.reconcileFloats()
338+ }
339+}
340+
341+// coverDialog reports whether f is a dialog of the covering fullscreen
342+// window cover: the one float drawn over it.
343+func (f Float) coverDialog(cover WindowID) bool {
344+ return cover != 0 && f.over == cover && f.ID != cover
345+}
346+
347+// coverDialog is the topmost dialog shown over the covering fullscreen
348+// window when the floats have the focus, else 0. The floats it hides cannot
349+// take the focus.
350+func (w *Workspace) coverDialog() WindowID {
351+ if !w.floatFocus {
352+ return 0
353+ }
354+ cover := w.cover()
355+ for i := len(w.Floats) - 1; i >= 0; i-- {
356+ if f := w.Floats[i]; !f.below && f.coverDialog(cover) {
357+ return f.ID
358+ }
359+ }
360+ return 0
361+}
362+
363+// coverDialogFocused reports whether a dialog over the covering fullscreen
364+// window has the focus.
365+func (w *Workspace) coverDialogFocused() bool { return w.coverDialog() != 0 }
366+
318367 // ResizeFloating records the size a floating window draws. Core sizes a
319368 // free float: its reports only fill a missing size.
320369 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
@@ -340,8 +389,12 @@ func (w *Workspace) has(id WindowID) bool {
340389 }
341390
342391 func (w *Workspace) Focused() (WindowID, bool) {
343- // Nothing else is drawn: the covering fullscreen window has the focus.
392+ // Nothing else is drawn but its dialogs: the covering fullscreen window
393+ // has the focus unless one of them took it.
344394 if full := w.cover(); full != 0 {
395+ if d := w.coverDialog(); d != 0 {
396+ return d, true
397+ }
345398 return full, true
346399 }
347400 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
@@ -545,6 +598,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
545598 // fullscreen window it leaves fullscreen for the window there (leaveCover);
546599 // false means it stays, with no window on that side.
547600 func (w *Workspace) FocusColumn(dir int) bool {
601+ if w.coverDialogFocused() {
602+ // The first move goes back to the fullscreen window under it.
603+ w.floatFocus = false
604+ return true
605+ }
548606 if w.pinned() {
549607 return w.leaveCover(func() { w.focusColumn(dir) })
550608 }
@@ -604,6 +662,10 @@ func (w *Workspace) onScreenFocus() bool {
604662 // float; false means no window is available in that direction. From a
605663 // covering fullscreen window it leaves fullscreen for that window.
606664 func (w *Workspace) FocusWindow(dir int) bool {
665+ if w.coverDialogFocused() {
666+ w.floatFocus = false
667+ return true
668+ }
607669 if w.pinned() {
608670 return w.leaveCover(func() { w.focusWindow(dir) })
609671 }
@@ -1147,6 +1209,11 @@ func (w *Workspace) focusCover() {
11471209 if c == 0 {
11481210 return
11491211 }
1212+ if d := w.coverDialog(); d != 0 {
1213+ // Its dialog is on screen with the focus: the action is on it.
1214+ w.FocusID(d)
1215+ return
1216+ }
11501217 if w.isFloat(c) {
11511218 w.FocusID(c)
11521219 return
@@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement {
14511518 if order != group {
14521519 continue
14531520 }
1454- // A covering fullscreen window hides every float, its own dialogs
1455- // too: they show again when it leaves fullscreen.
1456- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}
1521+ // A covering fullscreen window hides every float but its own
1522+ // dialogs: the others show again when it leaves fullscreen.
1523+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)}
14571524 if p.Hidden {
14581525 p.Rect = Rect{}
14591526 result = append(result, p)
@@ -229,6 +229,9 @@ type Float struct {
229 // usable area, so it keeps its place on any output.229 // usable area, so it keeps its place on any output.
230 free bool230 free bool
231 cx, cy float64231 cx, cy float64
232+ // over is the parent of a dialog: while the parent covers the output
233+ // fullscreen, the dialog shows over it and may take the focus.
234+ over WindowID
232 }235 }
233 236
234 func (w *Workspace) empty() bool {237 func (w *Workspace) empty() bool {
@@ -315,6 +318,52 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) {
315 w.floatFocus = true318 w.floatFocus = true
316 }319 }
317 320
321+// AddDialog is AddFloating for a dialog of parent: it shows, focused, over
322+// its parent's fullscreen. A dialog follows a user action in its parent
323+// (a portal request, a file chooser), so the user expects to see it.
324+func (w *Workspace) AddDialog(id, parent WindowID, width, height int) {
325+ if id == 0 || w.has(id) {
326+ return
327+ }
328+ w.Floats = append(w.Floats, Float{ID: id, W: width, H: height, over: parent})
329+ w.floatFocus = true
330+}
331+
332+// SetDialogParent follows a dialog's parent after it mapped: a cleared or
333+// changed parent ends its place over the old one's fullscreen.
334+func (w *Workspace) SetDialogParent(id, parent WindowID) {
335+ if i := w.floatIndex(id); i >= 0 {
336+ w.Floats[i].over = parent
337+ w.reconcileFloats()
338+ }
339+}
340+
341+// coverDialog reports whether f is a dialog of the covering fullscreen
342+// window cover: the one float drawn over it.
343+func (f Float) coverDialog(cover WindowID) bool {
344+ return cover != 0 && f.over == cover && f.ID != cover
345+}
346+
347+// coverDialog is the topmost dialog shown over the covering fullscreen
348+// window when the floats have the focus, else 0. The floats it hides cannot
349+// take the focus.
350+func (w *Workspace) coverDialog() WindowID {
351+ if !w.floatFocus {
352+ return 0
353+ }
354+ cover := w.cover()
355+ for i := len(w.Floats) - 1; i >= 0; i-- {
356+ if f := w.Floats[i]; !f.below && f.coverDialog(cover) {
357+ return f.ID
358+ }
359+ }
360+ return 0
361+}
362+
363+// coverDialogFocused reports whether a dialog over the covering fullscreen
364+// window has the focus.
365+func (w *Workspace) coverDialogFocused() bool { return w.coverDialog() != 0 }
366+
318 // ResizeFloating records the size a floating window draws. Core sizes a367 // ResizeFloating records the size a floating window draws. Core sizes a
319 // free float: its reports only fill a missing size.368 // free float: its reports only fill a missing size.
320 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {369 func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
@@ -340,8 +389,12 @@ func (w *Workspace) has(id WindowID) bool {
340 }389 }
341 390
342 func (w *Workspace) Focused() (WindowID, bool) {391 func (w *Workspace) Focused() (WindowID, bool) {
343- // Nothing else is drawn: the covering fullscreen window has the focus.392+ // Nothing else is drawn but its dialogs: the covering fullscreen window
393+ // has the focus unless one of them took it.
344 if full := w.cover(); full != 0 {394 if full := w.cover(); full != 0 {
395+ if d := w.coverDialog(); d != 0 {
396+ return d, true
397+ }
345 return full, true398 return full, true
346 }399 }
347 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {400 if w.floatFocus && len(w.Floats) > 0 && !w.Floats[len(w.Floats)-1].below {
@@ -545,6 +598,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
545 // fullscreen window it leaves fullscreen for the window there (leaveCover);598 // fullscreen window it leaves fullscreen for the window there (leaveCover);
546 // false means it stays, with no window on that side.599 // false means it stays, with no window on that side.
547 func (w *Workspace) FocusColumn(dir int) bool {600 func (w *Workspace) FocusColumn(dir int) bool {
601+ if w.coverDialogFocused() {
602+ // The first move goes back to the fullscreen window under it.
603+ w.floatFocus = false
604+ return true
605+ }
548 if w.pinned() {606 if w.pinned() {
549 return w.leaveCover(func() { w.focusColumn(dir) })607 return w.leaveCover(func() { w.focusColumn(dir) })
550 }608 }
@@ -604,6 +662,10 @@ func (w *Workspace) onScreenFocus() bool {
604 // float; false means no window is available in that direction. From a662 // float; false means no window is available in that direction. From a
605 // covering fullscreen window it leaves fullscreen for that window.663 // covering fullscreen window it leaves fullscreen for that window.
606 func (w *Workspace) FocusWindow(dir int) bool {664 func (w *Workspace) FocusWindow(dir int) bool {
665+ if w.coverDialogFocused() {
666+ w.floatFocus = false
667+ return true
668+ }
607 if w.pinned() {669 if w.pinned() {
608 return w.leaveCover(func() { w.focusWindow(dir) })670 return w.leaveCover(func() { w.focusWindow(dir) })
609 }671 }
@@ -1147,6 +1209,11 @@ func (w *Workspace) focusCover() {
1147 if c == 0 {1209 if c == 0 {
1148 return1210 return
1149 }1211 }
1212+ if d := w.coverDialog(); d != 0 {
1213+ // Its dialog is on screen with the focus: the action is on it.
1214+ w.FocusID(d)
1215+ return
1216+ }
1150 if w.isFloat(c) {1217 if w.isFloat(c) {
1151 w.FocusID(c)1218 w.FocusID(c)
1152 return1219 return
@@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement {
1451 if order != group {1518 if order != group {
1452 continue1519 continue
1453 }1520 }
1454- // A covering fullscreen window hides every float, its own dialogs1521+ // A covering fullscreen window hides every float but its own
1455- // too: they show again when it leaves fullscreen.1522+ // dialogs: the others show again when it leaves fullscreen.
1456- p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover}1523+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)}
1457 if p.Hidden {1524 if p.Hidden {
1458 p.Rect = Rect{}1525 p.Rect = Rect{}
1459 result = append(result, p)1526 result = append(result, p)
M internal/core/screens_test.go +59 -0
@@ -1003,6 +1003,65 @@ func TestStashKeepsFocusAtEdges(t *testing.T) {
10031003 }
10041004 }
10051005
1006+// Binds act on the dialog shown over a fullscreen window, not on the window:
1007+// close closes the dialog, a focus move goes back to the window and keeps it
1008+// fullscreen. A dialog of a window on another output opens there.
1009+func TestBindsOnDialogOverFullscreen(t *testing.T) {
1010+ for _, overflow := range []string{"scroll", "fixed"} {
1011+ t.Run(overflow, func(t *testing.T) {
1012+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = overflow }, left, right)
1013+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1014+ r.mapWindow(t, 1)
1015+ r.mapWindow(t, 2)
1016+ r.key(t, "Left", ports.ModAlt)
1017+ r.key(t, "f", ports.ModAlt|ports.ModShift) // 1 fullscreen
1018+ r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 10, Height: 10, Parent: 1}
1019+ receive(t, r.scenes)
1020+ st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 9 })
1021+ if st.Window.ID != 9 {
1022+ t.Fatalf("focus %+v, want the dialog", st.Window)
1023+ }
1024+ for len(r.commands) > 0 {
1025+ <-r.commands
1026+ }
1027+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt, Pressed: true}
1028+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt}
1029+ for {
1030+ if v, ok := receive(t, r.commands).(ports.CloseWindow); ok {
1031+ if v.ID != 9 {
1032+ t.Fatalf("closed %d, want the dialog", v.ID)
1033+ }
1034+ break
1035+ }
1036+ }
1037+ scenes := r.key(t, "Right", ports.ModAlt)
1038+ st = stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID != 9 })
1039+ if st.Window.ID != 1 {
1040+ t.Fatalf("focus %+v, want the fullscreen window", st.Window)
1041+ }
1042+ for _, s := range scenes {
1043+ for _, w := range s.Windows {
1044+ if s.Output == "DP-1" && w.ID == 1 && !w.Fullscreen {
1045+ t.Fatalf("window 1 left fullscreen: %+v", w)
1046+ }
1047+ }
1048+ }
1049+ // A dialog of the window on DP-1 opens on DP-1 while DP-2 has the focus.
1050+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1051+ r.client <- ports.WindowMapped{ID: 10, Floating: true, Width: 10, Height: 10, Parent: 1}
1052+ for {
1053+ seen := shown(receive(t, r.scenes))
1054+ if slices.Contains(seen["DP-2"], 10) {
1055+ t.Fatalf("dialog on the focused output: %v", seen)
1056+ }
1057+ if slices.Contains(seen["DP-1"], 10) {
1058+ break
1059+ }
1060+ }
1061+ })
1062+ }
1063+}
1064+
10061065 // A focused native dialog: focus-column first leaves the dialog for the
10071066 // tiles below, it does not hop to the neighbor monitor.
10081067 func TestDialogFocusStaysOnMonitor(t *testing.T) {
@@ -1003,6 +1003,65 @@ func TestStashKeepsFocusAtEdges(t *testing.T) {
1003 }1003 }
1004 }1004 }
1005 1005
1006+// Binds act on the dialog shown over a fullscreen window, not on the window:
1007+// close closes the dialog, a focus move goes back to the window and keeps it
1008+// fullscreen. A dialog of a window on another output opens there.
1009+func TestBindsOnDialogOverFullscreen(t *testing.T) {
1010+ for _, overflow := range []string{"scroll", "fixed"} {
1011+ t.Run(overflow, func(t *testing.T) {
1012+ r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = overflow }, left, right)
1013+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl) // DP-1
1014+ r.mapWindow(t, 1)
1015+ r.mapWindow(t, 2)
1016+ r.key(t, "Left", ports.ModAlt)
1017+ r.key(t, "f", ports.ModAlt|ports.ModShift) // 1 fullscreen
1018+ r.client <- ports.WindowMapped{ID: 9, Floating: true, Width: 10, Height: 10, Parent: 1}
1019+ receive(t, r.scenes)
1020+ st := stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID == 9 })
1021+ if st.Window.ID != 9 {
1022+ t.Fatalf("focus %+v, want the dialog", st.Window)
1023+ }
1024+ for len(r.commands) > 0 {
1025+ <-r.commands
1026+ }
1027+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt, Pressed: true}
1028+ r.input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModAlt}
1029+ for {
1030+ if v, ok := receive(t, r.commands).(ports.CloseWindow); ok {
1031+ if v.ID != 9 {
1032+ t.Fatalf("closed %d, want the dialog", v.ID)
1033+ }
1034+ break
1035+ }
1036+ }
1037+ scenes := r.key(t, "Right", ports.ModAlt)
1038+ st = stateAfter(t, r.state, func(st ports.State) bool { return st.Window != nil && st.Window.ID != 9 })
1039+ if st.Window.ID != 1 {
1040+ t.Fatalf("focus %+v, want the fullscreen window", st.Window)
1041+ }
1042+ for _, s := range scenes {
1043+ for _, w := range s.Windows {
1044+ if s.Output == "DP-1" && w.ID == 1 && !w.Fullscreen {
1045+ t.Fatalf("window 1 left fullscreen: %+v", w)
1046+ }
1047+ }
1048+ }
1049+ // A dialog of the window on DP-1 opens on DP-1 while DP-2 has the focus.
1050+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
1051+ r.client <- ports.WindowMapped{ID: 10, Floating: true, Width: 10, Height: 10, Parent: 1}
1052+ for {
1053+ seen := shown(receive(t, r.scenes))
1054+ if slices.Contains(seen["DP-2"], 10) {
1055+ t.Fatalf("dialog on the focused output: %v", seen)
1056+ }
1057+ if slices.Contains(seen["DP-1"], 10) {
1058+ break
1059+ }
1060+ }
1061+ })
1062+ }
1063+}
1064+
1006 // A focused native dialog: focus-column first leaves the dialog for the1065 // A focused native dialog: focus-column first leaves the dialog for the
1007 // tiles below, it does not hop to the neighbor monitor.1066 // tiles below, it does not hop to the neighbor monitor.
1008 func TestDialogFocusStaysOnMonitor(t *testing.T) {1067 func TestDialogFocusStaysOnMonitor(t *testing.T) {
M internal/core/spawn_placement.go +9 -2
@@ -73,9 +73,16 @@ func (p *spawnPlacement) expireTerminals(now time.Time) {
7373 // Untagged windows return before any map lookup or allocation.
7474 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
7575 if v.Floating {
76- if s, _ := c.screenOf(v.ID); s == nil {
77- c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
76+ if s, _ := c.screenOf(v.ID); s != nil {
77+ return
7878 }
79+ // A dialog opens on its parent's workspace, over it, fullscreen too.
80+ if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {
81+ pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)
82+ ps.mon.normalize()
83+ return
84+ }
85+ c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
7986 return
8087 }
8188 if v.Slot != "" {
@@ -73,9 +73,16 @@ func (p *spawnPlacement) expireTerminals(now time.Time) {
73 // Untagged windows return before any map lookup or allocation.73 // Untagged windows return before any map lookup or allocation.
74 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {74 func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
75 if v.Floating {75 if v.Floating {
76- if s, _ := c.screenOf(v.ID); s == nil {76+ if s, _ := c.screenOf(v.ID); s != nil {
77- c.cur().mon.AddFloating(v.ID, v.Width, v.Height)77+ return
78 }78 }
79+ // A dialog opens on its parent's workspace, over it, fullscreen too.
80+ if ps, pw := c.screenOf(v.Parent); v.Parent != 0 && pw != nil {
81+ pw.AddDialog(v.ID, v.Parent, v.Width, v.Height)
82+ ps.mon.normalize()
83+ return
84+ }
85+ c.cur().mon.AddFloating(v.ID, v.Width, v.Height)
79 return86 return
80 }87 }
81 if v.Slot != "" {88 if v.Slot != "" {
M internal/ports/client_events.go +12 -0
@@ -16,6 +16,10 @@ type WindowMapped struct {
1616 // size, Width×Height logical (0 when unknown).
1717 Floating bool
1818 Width, Height int
19+ // Parent is the window a dialog belongs to (xdg_toplevel.set_parent or
20+ // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21+ // it.
22+ Parent WindowID
1923 }
2024
2125 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -138,6 +142,14 @@ type WindowAppID struct {
138142
139143 func (WindowAppID) clientEvent() {}
140144
145+// WindowParent carries the new parent of a mapped dialog (0: none), after
146+// set_parent or an xdg-foreign import changed or ended the relationship.
147+type WindowParent struct {
148+ ID, Parent WindowID
149+}
150+
151+func (WindowParent) clientEvent() {}
152+
141153 // WindowResized carries wayland → core the new size of a floating window.
142154 type WindowResized struct {
143155 ID WindowID
@@ -16,6 +16,10 @@ type WindowMapped struct {
16 // size, Width×Height logical (0 when unknown).16 // size, Width×Height logical (0 when unknown).
17 Floating bool17 Floating bool
18 Width, Height int18 Width, Height int
19+ // Parent is the window a dialog belongs to (xdg_toplevel.set_parent or
20+ // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21+ // it.
22+ Parent WindowID
19 }23 }
20 24
21 // SlotEnv is the environment variable neferwl sets on processes it spawns25 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -138,6 +142,14 @@ type WindowAppID struct {
138 142
139 func (WindowAppID) clientEvent() {}143 func (WindowAppID) clientEvent() {}
140 144
145+// WindowParent carries the new parent of a mapped dialog (0: none), after
146+// set_parent or an xdg-foreign import changed or ended the relationship.
147+type WindowParent struct {
148+ ID, Parent WindowID
149+}
150+
151+func (WindowParent) clientEvent() {}
152+
141 // WindowResized carries wayland → core the new size of a floating window.153 // WindowResized carries wayland → core the new size of a floating window.
142 type WindowResized struct {154 type WindowResized struct {
143 ID WindowID155 ID WindowID
M internal/ports/devices.go +7 -1
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "errors"
5+ "fmt"
56 "image"
67 "os"
78 "time"
@@ -12,8 +13,13 @@ import (
1213 // at least this capacity, so an output never blocks on a reply.
1314 const MaxCaptureInflight = 32
1415
16+// ErrCaptureTransient marks a capture failure that may clear on its own (the
17+// renderer busy, the indicator or an exclusion not shown yet): a protocol
18+// frame tries again shortly instead of failing. Test with errors.Is.
19+var ErrCaptureTransient = errors.New("capture: transient")
20+
1521 // ErrCaptureBusy: every capture slot of the renderer is leased.
16-var ErrCaptureBusy = errors.New("capture: no free slot")
22+var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient)
1723
1824 // CaptureFrame is a lease on one renderer readback slot holding a GPU
1925 // copy of a rendered frame. Done and Read make no Vulkan call: a worker
@@ -2,6 +2,7 @@ package ports
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "fmt"
5 "image"6 "image"
6 "os"7 "os"
7 "time"8 "time"
@@ -12,8 +13,13 @@ import (
12 // at least this capacity, so an output never blocks on a reply.13 // at least this capacity, so an output never blocks on a reply.
13 const MaxCaptureInflight = 3214 const MaxCaptureInflight = 32
14 15
16+// ErrCaptureTransient marks a capture failure that may clear on its own (the
17+// renderer busy, the indicator or an exclusion not shown yet): a protocol
18+// frame tries again shortly instead of failing. Test with errors.Is.
19+var ErrCaptureTransient = errors.New("capture: transient")
20+
15 // ErrCaptureBusy: every capture slot of the renderer is leased.21 // ErrCaptureBusy: every capture slot of the renderer is leased.
16-var ErrCaptureBusy = errors.New("capture: no free slot")22+var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient)
17 23
18 // CaptureFrame is a lease on one renderer readback slot holding a GPU24 // CaptureFrame is a lease on one renderer readback slot holding a GPU
19 // copy of a rendered frame. Done and Read make no Vulkan call: a worker25 // copy of a rendered frame. Done and Read make no Vulkan call: a worker
M packaging/neferwl-session +2 -0
@@ -17,3 +17,5 @@ fi
1717 systemctl --user --wait start neferwl.service
1818 systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target
1919 systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE
20+# Portals keep the session variables they started with: stop them, even after a crash.
21+systemctl --user --no-block stop 'xdg-desktop-portal*.service'
@@ -17,3 +17,5 @@ fi
17 systemctl --user --wait start neferwl.service17 systemctl --user --wait start neferwl.service
18 systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target18 systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target
19 systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE19 systemctl --user unset-environment WAYLAND_DISPLAY DISPLAY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP NEFERWL_STATE
20+# Portals keep the session variables they started with: stop them, even after a crash.
21+systemctl --user --no-block stop 'xdg-desktop-portal*.service'

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close