Black Screen When Screen Sharing on Hyprland: A Three-Bug Hunt
I couldn’t screen share. In Zen (a Firefox-family browser), picking Entire Screen in a Google Meet / browser meeting gave a perfectly black rectangle. The kicker: it failed the exact same way on two completely different machines — a desktop with an NVIDIA card and a laptop with a Radeon 680M. Same black screen.
That detail is the whole story, so I’ll spoil it up front: it was never the GPU. It turned out to be three separate bugs stacked on top of each other, each one independently capable of producing a black frame. Fixing any one of them still left it black, which is exactly why it was so maddening. Here’s the hunt.
Setup: NixOS, Hyprland (Wayland), SDDM as the display manager, PipeWire + xdg-desktop-portal-hyprland for screencast, Zen from the
zen-browserflake.
The mindset that mattered
The single most useful move was refusing to trust the obvious suspect. “Screen sharing is black” sounds like a GPU/driver problem. But the same failure on an NVIDIA box and an AMD box is a near-impossible coincidence for a driver bug — driver bugs are specific. Two different GPUs failing identically points at something they share: the compositor, the portal stack, or the app. So I started there and worked the pipeline end to end.
Bug #1 — a Hyprland screencopy regression
The portal/PipeWire stack itself checked out healthy: the ScreenCast D-Bus
interface was exported, PipeWire was running, grim (which uses the CPU/shm
capture path) produced a real screenshot. So the compositor could see the
screen. But the dmabuf screencast stream that browsers use was freezing on the
first frame.
This matched a known upstream Hyprland regression (the explicit-sync screencopy change) present in versions above 0.47.2 through 0.49.0 — the dmabuf screencast stream blacks out on the first frame. I was on 0.49.0 from nixpkgs.
Fix: move the compositor and its portal off nixpkgs and onto the Hyprland flake (0.52+), keeping them a matched pair:
programs.hyprland = {
enable = true;
package = inputs.hyprland.packages.${pkgs.system}.hyprland;
portalPackage = inputs.hyprland.packages.${pkgs.system}.xdg-desktop-portal-hyprland;
};
(package and portalPackage must come from the same source so their
protocol versions match.) Rebuilt, re-logged in… still black. On to bug two.
Bug #2 — the browser couldn’t load libpipewire
Time to stop guessing and watch the actual handshake. I tailed the portal while triggering a share:
journalctl --user -u xdg-desktop-portal-hyprland -u xdg-desktop-portal -f
Clicked Share → Entire Screen → Share and watched the log. It logged… nothing. Zero ScreenCast traffic. The browser wasn’t even asking the portal for frames — it was falling back to a legacy capture path that renders black under Wayland.
Why? Firefox-family browsers dlopen("libpipewire-0.3.so.0") at runtime to use
the portal screencast path. On NixOS there’s no global /usr/lib, so the library
has to be on the process’s library path. It wasn’t:
# Is libpipewire actually loaded in the running browser?
grep -i pipewire /proc/$(pgrep -f 'zen' | head -1)/maps # → nothing
The root cause was upstream in the browser’s Nix wrapper. nixpkgs wrapFirefox
only adds PipeWire to the wrapped browser’s LD_LIBRARY_PATH when the unwrapped
package advertises it:
# nixpkgs firefox wrapper.nix
pipewireSupport = browser.pipewireSupport or false;
# ...
libs = [ /* ... */ ] ++ lib.optional pipewireSupport pipewire;
The zen-browser flake’s unwrapped package sets ffmpegSupport and
gssSupport in its passthru but forgets pipewireSupport — so it defaulted
to false and libpipewire was silently dropped.
Fix: re-wrap the browser with the flag flipped on, mirroring the flake’s own wrap so policies/overrides still compose:
pkgs.wrapFirefox
(inputs.zen-browser.packages.${pkgs.system}.beta-unwrapped.overrideAttrs (old: {
passthru = (old.passthru or { }) // { pipewireSupport = true; };
}))
{ icon = "zen-browser"; }
Now libpipewire was on the path. Rebuilt, re-logged in… still black. One to go.
Bug #3 — XDG_SESSION_TYPE was unset
libpipewire was available, the compositor was fixed, the browser window was genuinely native Wayland — and a share still produced no portal traffic and a black frame. So the browser was still choosing the wrong capture backend.
The capture engine inside Firefox/Zen is libwebrtc, and it decides between the PipeWire backend and the old X11 backend with this check:
// libwebrtc, DesktopCapturer::IsRunningUnderWayland()
const char* xdg_session_type = getenv("XDG_SESSION_TYPE");
// must equal "wayland", plus WAYLAND_DISPLAY must be set
And on my session:
loginctl show-session $XDG_SESSION_ID -p Type # → Type=wayland (correct!)
echo "${XDG_SESSION_TYPE:-UNSET}" # → UNSET (the problem)
logind knew the session was Wayland, but the XDG_SESSION_TYPE environment
variable was never exported into the session — SDDM launches Hyprland without
setting it. So libwebrtc fell back to the X11 root-window capturer, which under
Wayland grabs an empty (black) buffer.
The trap that cost me a login
The obvious fix — set it globally in NixOS — breaks your login, and it’s worth dwelling on because it’s a great example of “correct value, wrong scope”:
# DON'T DO THIS
environment.sessionVariables.XDG_SESSION_TYPE = "wayland";
A global value also lands in the SDDM greeter’s environment. The greeter runs
on X11, but seeing XDG_SESSION_TYPE=wayland it tries to start a Wayland
greeter, which immediately segfaults:
sddm-helper-start-wayland: segfault ... in SDDM::WaylandHelper::startProcess
Greeter stopped. HelperExitStatus(11)
Black screen, no login, revert-to-previous-generation. (Thank you, NixOS boot generations.)
The right fix sets the variable only inside the session, never near the
greeter. The wrinkle: the browser is launched by the systemd --user manager,
not directly by Hyprland — so Hyprland’s own env directive doesn’t reach it.
The thing that does reach it is environment.d:
# home-manager
xdg.configFile."environment.d/10-xdg-session-type.conf".text = ''
XDG_SESSION_TYPE=wayland
'';
systemd --user reads environment.d at login and applies it to every
user-scope app it spawns — including the browser. And because it’s a per-user
file, the system-level SDDM greeter never sees it. Login stays safe; the browser
finally gets XDG_SESSION_TYPE=wayland.
The payoff
Rebuild, log out, log in, open a meeting, Share → Entire Screen — the portal source picker popped up, I selected the screen, and there it was: a live preview. Confirmed on Google Meet. After all that, the verification was anticlimactic:
grep -i pipewire /proc/$(pgrep -f 'zen' | head -1)/maps # → libpipewire LOADED
Takeaways
- Identical failure on different hardware exonerates the hardware. Two GPUs, same black screen — the cause was something they shared.
- Watch the handshake, don’t guess.
journalctl --user -u xdg-desktop-portal-* -fduring a share instantly told me the browser wasn’t even calling the portal — which redirected the whole investigation. - “No portal traffic” means the app picked the wrong backend — look at what gates that choice (here: a missing library and a missing env var).
- Right value, wrong scope still breaks things.
XDG_SESSION_TYPE=waylandis correct — set globally it crashes the X11 greeter; set per-session it just works. - Reversible systems make aggressive debugging safe. Being able to roll back to the previous NixOS generation from the boot menu is what let me try the bad global fix without fear.