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 Unverified
B5690EEEBB952194The 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 | |||
| 124 | 124 | | `spawn <command>` | Run a program | | |
| 125 | 125 | | `spawn-terminal` | Run `terminal` | | |
| 126 | 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 | 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 | 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 | 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 | 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 | 159 | ### Free floating windows | |
| 160 | 160 | ||
| @@ -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 windows | 159 | ### Free floating windows | ||
| 160 | 160 | ||||
M docs/headless.md +3 -3
| @@ -9,11 +9,11 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 9 | 9 | ||
| 10 | 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 | 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 | ||
| @@ -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 @@ | |||
| 1 | 1 | package capture | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 4 | + | "fmt" | |
| 5 | 5 | "os" | |
| 6 | 6 | "slices" | |
| 7 | 7 | ||
| @@ -16,17 +16,17 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // ErrSessionInactive fails an Exclude request whose session is not the one | |
| 18 | 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 | 21 | // ErrSessionStale fails an Exclude request stamped with a revision newer than | |
| 22 | 22 | // the scene's: the scene predates a change of what an excluded frame must | |
| 23 | 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 | 26 | // ErrWorkspaceMoved fails a workspace request whose workspace is neither on | |
| 27 | 27 | // screen nor the one rendered off screen in the scene: it moved, went away, | |
| 28 | 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 | 31 | // excluder derives the scene of Exclude captures: the frame without the | |
| 32 | 32 | // excluded layers and popups. Its scratch slices are reused, | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package capture | 1 | 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 one | 17 | // 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 than | 21 | // 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 must | 22 | // 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 on | 26 | // 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 the | 31 | // 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 @@ | |||
| 1 | 1 | package capture | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "errors" | |
| 4 | + | "fmt" | |
| 5 | 5 | "image" | |
| 6 | 6 | "math" | |
| 7 | 7 | "time" | |
| @@ -46,7 +46,7 @@ const HoldFor = 250 * time.Millisecond | |||
| 46 | 46 | ||
| 47 | 47 | // ErrIndicatorMissing fails a request whose indicator was not on screen in | |
| 48 | 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 | 51 | // IndicatorShown reports whether scene s shows the indicator request q needs. | |
| 52 | 52 | // A request that does not ask for one (Indicate false) needs none. | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package capture | 1 | 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 in | 47 | // 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 ( | |||
| 41 | 41 | ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported") | |
| 42 | 42 | // ErrOffscreenBusy fails a request while the child is still busy with | |
| 43 | 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 | 47 | // 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 | 397 | k := 0 | |
| 398 | 398 | for _, q := range reqs { | |
| 399 | 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 | 403 | continue | |
| 402 | 404 | } | |
| 403 | 405 | 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 with | 42 | // 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 sync | 47 | // 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 := 0 | 397 | 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 | continue | 403 | 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 | |||
| 142 | 142 | } | |
| 143 | 143 | var err error | |
| 144 | 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 | 146 | } else if !p.captureAllowed(state) { | |
| 147 | 147 | err = ErrSecurityState | |
| 148 | 148 | } else { | |
| @@ -142,7 +142,7 @@ func (p *Pipeline) SubmitScoped(state ports.SecurityState, r ports.Renderer, req | |||||
| 142 | } | 142 | } | ||
| 143 | var err error | 143 | 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 = ErrSecurityState | 147 | err = ErrSecurityState | ||
| 148 | } else { | 148 | } else { | ||
M internal/adapters/launcher/session.go +30 -8
| @@ -13,43 +13,65 @@ import ( | |||
| 13 | 13 | // notification daemons) need to reach the session. | |
| 14 | 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 | 23 | // 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 | 27 | func ExportSession(ctx context.Context, env []string, log zerowrap.Logger) { | |
| 19 | 28 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) | |
| 20 | 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 | 36 | // 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 | 39 | func UnexportSession(env []string, log zerowrap.Logger) { | |
| 27 | 40 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 28 | 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 | 48 | path, err := childLookPath("systemctl", env) | |
| 30 | 49 | if err != nil { | |
| 31 | 50 | 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 | 53 | cmd.Env = env | |
| 35 | 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 | 62 | path, err := childLookPath("dbus-update-activation-environment", env) | |
| 42 | 63 | if err != nil { | |
| 43 | 64 | log.Warn().Err(err).Msg("session export skipped: dbus-update-activation-environment not found") | |
| 44 | - | return | |
| 65 | + | return false | |
| 45 | 66 | } | |
| 46 | 67 | cmd := exec.CommandContext(ctx, path, args...) | |
| 47 | 68 | cmd.Env = env | |
| 48 | 69 | if err := cmd.Run(); err != nil { | |
| 49 | 70 | log.Warn().Err(err).Msg("session export failed") | |
| 50 | - | return | |
| 71 | + | return false | |
| 51 | 72 | } | |
| 52 | 73 | log.Info().Strs("vars", sessionVars).Msg(done) | |
| 74 | + | return true | |
| 53 | 75 | } | |
| 54 | 76 | ||
| 55 | 77 | // 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 user | 23 | // 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 on | 36 | // 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 | return | 50 | 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 = env | 53 | 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 | - | return | 65 | + | return false |
| 45 | } | 66 | } | ||
| 46 | cmd := exec.CommandContext(ctx, path, args...) | 67 | cmd := exec.CommandContext(ctx, path, args...) | ||
| 47 | cmd.Env = env | 68 | 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 | - | return | 71 | + | 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) { | |||
| 32 | 32 | t.Fatal(err) | |
| 33 | 33 | } | |
| 34 | 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 | 38 | if string(data) != want { | |
| 37 | 39 | t.Fatalf("calls:\n%s", data) | |
| 38 | 40 | } | |
| 39 | 41 | // Missing tools are skipped, not fatal. | |
| 40 | 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 | 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 | 68 | func TestNotifyReady(t *testing.T) { | |
| 47 | 69 | ctx := context.Background() | |
| 48 | 70 | 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 | |||
| 189 | 189 | h.held = append(h.held, req) | |
| 190 | 190 | return req, true | |
| 191 | 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 | 195 | t.Fatalf("refused capture not failed: last event %d", last) | |
| 194 | 196 | } | |
| 195 | 197 | return ports.CaptureRequest{}, false | |
| @@ -287,9 +289,12 @@ func TestExtCaptureAdmissionSaturation(t *testing.T) { | |||
| 287 | 289 | } | |
| 288 | 290 | a.destroy(t, p) | |
| 289 | 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 | 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 | 298 | h.complete(t, req, maxCaptureInflight-1) | |
| 294 | 299 | for i, r := range reqs[1:] { | |
| 295 | 300 | 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, true | 190 | 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{}, false | 197 | 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 { | |||
| 42 | 42 | func() error { return registerExclusion(d, s) }, | |
| 43 | 43 | func() error { return registerSecurityContext(d, s) }, | |
| 44 | 44 | func() error { return registerActivation(d, s) }, | |
| 45 | + | func() error { return registerForeign(d, s) }, | |
| 45 | 46 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 46 | 47 | func() error { return registerTextInput(d, s) }, | |
| 47 | 48 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 5 | 7 | "image" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "sync/atomic" | |
| @@ -16,9 +18,11 @@ import ( | |||
| 16 | 18 | wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | |
| 17 | 19 | "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| 18 | 20 | "github.com/bnema/neferwl/internal/logging" | |
| 21 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 19 | 22 | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 23 | "github.com/bnema/purego-libwayland/server" | |
| 21 | 24 | "github.com/bnema/wlturbo" | |
| 25 | + | "github.com/stretchr/testify/mock" | |
| 22 | 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 | 320 | 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 | 339 | // wlrFrame asks a wlr-screencopy capture of the whole output into a buffer. | |
| 320 | 340 | func (cc *cclient) wlrFrame(t *testing.T) *captureEvents { | |
| 321 | 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 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. | |
| 953 | 973 | func TestExclusionFrameTagging(t *testing.T) { | |
| 954 | 974 | h := newCaptureHarness(t) | |
| 955 | 975 | 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") | |
| 960 | 980 | } | |
| 961 | 981 | noCapture(t, h.captures) | |
| 982 | + | // Once core confirms it, the held frame is served, tagged. | |
| 962 | 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 | 984 | h.settle(t, owner) | |
| 964 | - | owner.extFrame(t, session, 4, 4) | |
| 965 | 985 | r := receiveCapture(t, h.captures) | |
| 966 | 986 | if !r.Exclude || r.Session != open.ID || r.CaptureRevision != 9 { | |
| 967 | 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 | 1097 | // hud is a second connection: a layer surface it attaches with the token. | |
| 990 | 1098 | type hud struct { | |
| 991 | 1099 | *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 ev | 320 | 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 until | 970 | + | // Frames of the session that owns the exclusion are fenced: held until core |
| 951 | - | // core knows the exclusion, then tagged with its revision. Every other | 971 | + | // 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 | *cclient | 1099 | *cclient | ||
M internal/adapters/wayland/imagecopy.go +54 -6
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "image" | |
| 5 | 6 | "math" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource" | |
| 8 | 10 | ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture" | |
| @@ -441,8 +443,20 @@ type captureExtFrame struct { | |||
| 441 | 443 | buf *wayland.Buffer | |
| 442 | 444 | used bool | |
| 443 | 445 | 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 | 460 | func (*captureExtFrame) Destroy(*ext.ExtImageCopyCaptureFrameV1) {} | |
| 447 | 461 | func (f *captureExtFrame) AttachBuffer(_ *ext.ExtImageCopyCaptureFrameV1, b *wayland.Buffer) { | |
| 448 | 462 | if f.used { | |
| @@ -488,9 +502,34 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 488 | 502 | f.res.PostError(uint32(ext.ExtImageCopyCaptureFrameV1ErrorNoBuffer), "no buffer attached") | |
| 489 | 503 | 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 | 530 | o, region, hidden, ok := c.geometry() | |
| 492 | 531 | if !ok || c.haveState && !c.st.Active { | |
| 493 | - | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 532 | + | f.retry("target not ready") | |
| 494 | 533 | return | |
| 495 | 534 | } | |
| 496 | 535 | tag, ok := c.tag(hidden) | |
| @@ -501,11 +540,16 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 501 | 540 | return | |
| 502 | 541 | case !ok: | |
| 503 | 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 | 544 | return | |
| 506 | 545 | } | |
| 507 | 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 | 553 | f.res.SendFailed(uint32(ext.ExtImageCopyCaptureFrameV1FailureReasonUnknown)) | |
| 510 | 554 | return | |
| 511 | 555 | } | |
| @@ -516,10 +560,14 @@ func (f *captureExtFrame) Capture(*ext.ExtImageCopyCaptureFrameV1) { | |||
| 516 | 560 | f.res.SendReady() | |
| 517 | 561 | }, f.res.Resource, tag) | |
| 518 | 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 | 565 | 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") | |
| 523 | 571 | } | |
| 524 | 572 | } | |
| 525 | 573 | } | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package wayland | 1 | 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.Buffer | 443 | buf *wayland.Buffer | ||
| 442 | used bool | 444 | used bool | ||
| 443 | replyID uint64 | 445 | 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 | return | 503 | 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 | return | 533 | 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 | return | 540 | 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 | return | 544 | 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 | return | 554 | 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 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "image" | |
| 5 | 6 | "math" | |
| 6 | 7 | "os" | |
| @@ -209,9 +210,10 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 209 | 210 | s.emit(taken) | |
| 210 | 211 | } | |
| 211 | 212 | 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() | |
| 214 | 215 | } | |
| 216 | + | ev = ev.Err(done.Err) | |
| 215 | 217 | ev.Uint64("id", id).Str("output", done.Output).Dur("took", time.Since(start)).Bool("delivered", life.Alive()).Msg("capture done") | |
| 216 | 218 | if life.Alive() { | |
| 217 | 219 | reply(done) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | 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 { | |||
| 170 | 170 | readinessGeneration uint64 | |
| 171 | 171 | lastFlip map[string]time.Time | |
| 172 | 172 | // 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 | |
| 177 | 179 | // hdrOutputs records confirmed DRM modesets; absent outputs are SDR. | |
| 178 | 180 | hdrOutputs map[string]ports.OutputHDR | |
| 179 | 181 | colorID uint64 | |
| @@ -391,6 +393,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 391 | 393 | s.colorOutputs = map[*colorOutput]struct{}{} | |
| 392 | 394 | s.colorFeedbacks = map[*colorFeedback]struct{}{} | |
| 393 | 395 | s.tokens = map[string]activationToken{} | |
| 396 | + | s.foreignExports = map[string]*foreignExport{} | |
| 394 | 397 | if opts.Keymap != "" { | |
| 395 | 398 | fd, size, e := keymapFile(opts.Keymap) | |
| 396 | 399 | if e != nil { | |
| @@ -170,10 +170,12 @@ type Server struct { | |||||
| 170 | readinessGeneration uint64 | 170 | readinessGeneration uint64 | ||
| 171 | lastFlip map[string]time.Time | 171 | 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]activationToken | 173 | + | tokens map[string]activationToken |
| 174 | - | surfaces map[*server.Resource]*surface | 174 | + | // foreignExports are the live xdg-foreign handles (foreign.go). |
| 175 | - | buffers map[*server.Resource]clientBuffer | 175 | + | foreignExports map[string]*foreignExport |
| 176 | - | dmabuf *dmabufGlobal | 176 | + | 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.OutputHDR | 180 | hdrOutputs map[string]ports.OutputHDR | ||
| 179 | colorID uint64 | 181 | 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 ( | |||
| 47 | 47 | wire_wlroutputpower "github.com/bnema/go-wayland-bindings/server/wlroutputpowermanagement" | |
| 48 | 48 | wire_wlrscreencopy "github.com/bnema/go-wayland-bindings/server/wlrscreencopy" | |
| 49 | 49 | wire_xdgactivation "github.com/bnema/go-wayland-bindings/server/xdgactivation" | |
| 50 | + | wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign" | |
| 50 | 51 | wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput" | |
| 51 | 52 | wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell" | |
| 52 | 53 | wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | |
| @@ -189,6 +190,10 @@ var wireSchemas = map[string]wireSchema{ | |||
| 189 | 190 | "wp_viewporter": {wire_viewporter.WpViewporterInterface, &client_viewporter.WpViewporter{}, map[uint16]bool{0: true}, nil}, | |
| 190 | 191 | "xdg_activation_token_v1": {wire_xdgactivation.ActivationTokenV1Interface, &client_xdgactivation.XdgActivationToken{}, map[uint16]bool{4: true}, nil}, | |
| 191 | 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 | 197 | "xdg_popup": {wire_xdgshell.PopupInterface, &client_xdgshell.XdgPopup{}, map[uint16]bool{0: true}, nil}, | |
| 193 | 198 | "xdg_positioner": {wire_xdgshell.PositionerInterface, &client_xdgshell.XdgPositioner{}, map[uint16]bool{0: true}, nil}, | |
| 194 | 199 | "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) { | |||
| 235 | 235 | slot := x.server.slotToken(r.Client()) | |
| 236 | 236 | // Its size comes with the commit, in WindowResized (afterCommit). | |
| 237 | 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 | 243 | x.server.syncInhibitors() | |
| 240 | 244 | x.server.toplevelChanged(w) | |
| 241 | 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 | 250 | t.OnDestroy = func() { | |
| 247 | 251 | w.unmap() | |
| 252 | + | x.server.foreignToplevelGone(w) | |
| 248 | 253 | x.surface.dropQueue() | |
| 249 | 254 | x.surface.current, x.surface.next.buffer = nil, nil | |
| 250 | 255 | x.surface.next.attached = false | |
| @@ -294,15 +299,32 @@ type top struct{ w *window } | |||
| 294 | 299 | ||
| 295 | 300 | func (top) Destroy(*xdgshell.Toplevel) {} | |
| 296 | 301 | 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 { | |
| 299 | 317 | return | |
| 300 | 318 | } | |
| 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 | |
| 305 | 326 | } | |
| 327 | + | w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id}) | |
| 306 | 328 | } | |
| 307 | 329 | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | |
| 308 | 330 | 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, nil | 254 | x.surface.current, x.surface.next.buffer = nil, nil | ||
| 250 | x.surface.next.attached = false | 255 | 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 = nil | 302 | + | 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 | return | 317 | 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 = w | 321 | + | 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 { | |||
| 1041 | 1041 | continue | |
| 1042 | 1042 | case ports.WindowAppID: | |
| 1043 | 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 | 1048 | case ports.WindowUnmapped: | |
| 1045 | 1049 | if c.popups[v.ID] != nil { | |
| 1046 | 1050 | if err := c.dropPopup(ctx, v.ID); err != nil { | |
| @@ -1041,6 +1041,10 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1041 | continue | 1041 | 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) { | |||
| 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 | 843 | // A window that asks for fullscreen as it maps (Wine at a remembered | |
| 801 | 844 | // monitor size) stays in its column: the windows it opens next are seen. | |
| 802 | 845 | // 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 remembered | 843 | // 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 { | |||
| 229 | 229 | // usable area, so it keeps its place on any output. | |
| 230 | 230 | free bool | |
| 231 | 231 | 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 | 237 | func (w *Workspace) empty() bool { | |
| @@ -315,6 +318,52 @@ func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 315 | 318 | 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 | 367 | // ResizeFloating records the size a floating window draws. Core sizes a | |
| 319 | 368 | // free float: its reports only fill a missing size. | |
| 320 | 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 | 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 | 394 | if full := w.cover(); full != 0 { | |
| 395 | + | if d := w.coverDialog(); d != 0 { | |
| 396 | + | return d, true | |
| 397 | + | } | |
| 345 | 398 | return full, true | |
| 346 | 399 | } | |
| 347 | 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 | 598 | // fullscreen window it leaves fullscreen for the window there (leaveCover); | |
| 546 | 599 | // false means it stays, with no window on that side. | |
| 547 | 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 | 606 | if w.pinned() { | |
| 549 | 607 | return w.leaveCover(func() { w.focusColumn(dir) }) | |
| 550 | 608 | } | |
| @@ -604,6 +662,10 @@ func (w *Workspace) onScreenFocus() bool { | |||
| 604 | 662 | // float; false means no window is available in that direction. From a | |
| 605 | 663 | // covering fullscreen window it leaves fullscreen for that window. | |
| 606 | 664 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 665 | + | if w.coverDialogFocused() { | |
| 666 | + | w.floatFocus = false | |
| 667 | + | return true | |
| 668 | + | } | |
| 607 | 669 | if w.pinned() { | |
| 608 | 670 | return w.leaveCover(func() { w.focusWindow(dir) }) | |
| 609 | 671 | } | |
| @@ -1147,6 +1209,11 @@ func (w *Workspace) focusCover() { | |||
| 1147 | 1209 | if c == 0 { | |
| 1148 | 1210 | 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 | 1217 | if w.isFloat(c) { | |
| 1151 | 1218 | w.FocusID(c) | |
| 1152 | 1219 | return | |
| @@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement { | |||
| 1451 | 1518 | if order != group { | |
| 1452 | 1519 | continue | |
| 1453 | 1520 | } | |
| 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)} | |
| 1457 | 1524 | if p.Hidden { | |
| 1458 | 1525 | p.Rect = Rect{} | |
| 1459 | 1526 | 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 bool | 230 | free bool | ||
| 231 | cx, cy float64 | 231 | 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 = true | 318 | 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 a | 367 | // 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, true | 398 | 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 a | 662 | // 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 | return | 1210 | 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 | return | 1219 | return | ||
| @@ -1451,9 +1518,9 @@ func (w *Workspace) Layout() []Placement { | |||||
| 1451 | if order != group { | 1518 | if order != group { | ||
| 1452 | continue | 1519 | continue | ||
| 1453 | } | 1520 | } | ||
| 1454 | - | // A covering fullscreen window hides every float, its own dialogs | 1521 | + | // 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) { | |||
| 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 | 1065 | // A focused native dialog: focus-column first leaves the dialog for the | |
| 1007 | 1066 | // tiles below, it does not hop to the neighbor monitor. | |
| 1008 | 1067 | 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 the | 1065 | // 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) { | |||
| 73 | 73 | // Untagged windows return before any map lookup or allocation. | |
| 74 | 74 | func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |
| 75 | 75 | 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 | |
| 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 | 86 | return | |
| 80 | 87 | } | |
| 81 | 88 | 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 | return | 86 | 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 { | |||
| 16 | 16 | // size, Width×Height logical (0 when unknown). | |
| 17 | 17 | Floating bool | |
| 18 | 18 | 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 | 25 | // SlotEnv is the environment variable neferwl sets on processes it spawns | |
| @@ -138,6 +142,14 @@ type WindowAppID struct { | |||
| 138 | 142 | ||
| 139 | 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 | 153 | // WindowResized carries wayland → core the new size of a floating window. | |
| 142 | 154 | type WindowResized struct { | |
| 143 | 155 | 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 bool | 17 | Floating bool | ||
| 18 | Width, Height int | 18 | 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 spawns | 25 | // 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 WindowID | 155 | ID WindowID | ||
M internal/ports/devices.go +7 -1
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "fmt" | |
| 5 | 6 | "image" | |
| 6 | 7 | "os" | |
| 7 | 8 | "time" | |
| @@ -12,8 +13,13 @@ import ( | |||
| 12 | 13 | // at least this capacity, so an output never blocks on a reply. | |
| 13 | 14 | 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 | 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 | 24 | // CaptureFrame is a lease on one renderer readback slot holding a GPU | |
| 19 | 25 | // 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 = 32 | 14 | 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 GPU | 24 | // 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 worker | 25 | // 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 | |||
| 17 | 17 | systemctl --user --wait start neferwl.service | |
| 18 | 18 | systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target | |
| 19 | 19 | 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.service | 17 | systemctl --user --wait start neferwl.service | ||
| 18 | systemctl --user start --job-mode=replace-irreversibly neferwl-shutdown.target | 18 | 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_STATE | 19 | 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' | |||