From cd429142a4806ab6ebce382e7a7f710d4f378b1a Mon Sep 17 00:00:00 2001
From: outfoxxed <outfoxxed@outfoxxed.me>
Date: Tue, 14 Jan 2025 04:43:05 -0800
Subject: [PATCH] wayland/screencopy: add screencopy

---
 .github/workflows/build.yml                   |   1 +
 BUILD.md                                      |  18 +
 CMakeLists.txt                                |   6 +-
 default.nix                                   |   5 +-
 src/core/stacklist.hpp                        | 159 +++++
 src/wayland/CMakeLists.txt                    |   7 +
 src/wayland/buffer/CMakeLists.txt             |  18 +
 src/wayland/buffer/dmabuf.cpp                 | 659 ++++++++++++++++++
 src/wayland/buffer/dmabuf.hpp                 | 195 ++++++
 src/wayland/buffer/manager.cpp                | 114 +++
 src/wayland/buffer/manager.hpp                | 134 ++++
 src/wayland/buffer/manager_p.hpp              |  20 +
 src/wayland/buffer/qsg.hpp                    |  45 ++
 src/wayland/buffer/shm.cpp                    |  91 +++
 src/wayland/buffer/shm.hpp                    |  63 ++
 src/wayland/module.md                         |   1 +
 src/wayland/screencopy/CMakeLists.txt         |  42 ++
 src/wayland/screencopy/build.hpp.in           |   6 +
 .../hyprland_screencopy/CMakeLists.txt        |  16 +
 .../hyprland-toplevel-export-v1.xml           | 228 ++++++
 .../hyprland_screencopy.cpp                   | 122 ++++
 .../hyprland_screencopy.hpp                   |  26 +
 .../hyprland_screencopy_p.hpp                 |  50 ++
 .../image_copy_capture/CMakeLists.txt         |  19 +
 .../image_copy_capture/image_copy_capture.cpp | 225 ++++++
 .../image_copy_capture/image_copy_capture.hpp |  36 +
 .../image_copy_capture_p.hpp                  |  53 ++
 src/wayland/screencopy/manager.cpp            |  56 ++
 src/wayland/screencopy/manager.hpp            |  33 +
 src/wayland/screencopy/view.cpp               | 149 ++++
 src/wayland/screencopy/view.hpp               |  96 +++
 .../screencopy/wlr_screencopy/CMakeLists.txt  |  14 +
 .../wlr-screencopy-unstable-v1.xml            | 232 ++++++
 .../wlr_screencopy/wlr_screencopy.cpp         | 133 ++++
 .../wlr_screencopy/wlr_screencopy.hpp         |  25 +
 .../wlr_screencopy/wlr_screencopy_p.hpp       |  51 ++
 src/wayland/toplevel_management/qml.hpp       |   4 +-
 37 files changed, 3149 insertions(+), 3 deletions(-)
 create mode 100644 src/core/stacklist.hpp
 create mode 100644 src/wayland/buffer/CMakeLists.txt
 create mode 100644 src/wayland/buffer/dmabuf.cpp
 create mode 100644 src/wayland/buffer/dmabuf.hpp
 create mode 100644 src/wayland/buffer/manager.cpp
 create mode 100644 src/wayland/buffer/manager.hpp
 create mode 100644 src/wayland/buffer/manager_p.hpp
 create mode 100644 src/wayland/buffer/qsg.hpp
 create mode 100644 src/wayland/buffer/shm.cpp
 create mode 100644 src/wayland/buffer/shm.hpp
 create mode 100644 src/wayland/screencopy/CMakeLists.txt
 create mode 100644 src/wayland/screencopy/build.hpp.in
 create mode 100644 src/wayland/screencopy/hyprland_screencopy/CMakeLists.txt
 create mode 100644 src/wayland/screencopy/hyprland_screencopy/hyprland-toplevel-export-v1.xml
 create mode 100644 src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp
 create mode 100644 src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.hpp
 create mode 100644 src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy_p.hpp
 create mode 100644 src/wayland/screencopy/image_copy_capture/CMakeLists.txt
 create mode 100644 src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp
 create mode 100644 src/wayland/screencopy/image_copy_capture/image_copy_capture.hpp
 create mode 100644 src/wayland/screencopy/image_copy_capture/image_copy_capture_p.hpp
 create mode 100644 src/wayland/screencopy/manager.cpp
 create mode 100644 src/wayland/screencopy/manager.hpp
 create mode 100644 src/wayland/screencopy/view.cpp
 create mode 100644 src/wayland/screencopy/view.hpp
 create mode 100644 src/wayland/screencopy/wlr_screencopy/CMakeLists.txt
 create mode 100644 src/wayland/screencopy/wlr_screencopy/wlr-screencopy-unstable-v1.xml
 create mode 100644 src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp
 create mode 100644 src/wayland/screencopy/wlr_screencopy/wlr_screencopy.hpp
 create mode 100644 src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2ec6d8cf..a67e5f43 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -43,6 +43,7 @@ jobs:
             qt6-shadertools \
             wayland-protocols \
             wayland \
+            libdrm \
             libxcb \
             libpipewire \
             cli11 \
diff --git a/BUILD.md b/BUILD.md
index cf6b3a03..3172dbe3 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -130,6 +130,24 @@ which allows quickshell to be used as a session lock under compatible wayland co
 
 To disable: `-DWAYLAND_TOPLEVEL_MANAGEMENT=OFF`
 
+#### Screencopy
+Enables streaming video from monitors and toplevel windows through various protocols.
+
+To disable: `-DSCREENCOPY=OFF`
+
+Dependencies:
+- `libdrm`
+- `libgbm`
+
+Specific protocols can also be disabled:
+- `DSCREENCOPY_ICC=OFF` - Disable screencopy via [ext-image-copy-capture-v1]
+- `DSCREENCOPY_WLR=OFF` - Disable screencopy via [zwlr-screencopy-v1]
+- `DSCREENCOPY_HYPRLAND_TOPLEVEL=OFF` - Disable screencopy via [hyprland-toplevel-export-v1]
+
+[ext-image-copy-capture-v1]:https://wayland.app/protocols/ext-image-copy-capture-v1
+[zwlr-screencopy-v1]: https://wayland.app/protocols/wlr-screencopy-unstable-v1
+[hyprland-toplevel-export-v1]: https://wayland.app/protocols/hyprland-toplevel-export-v1
+
 ### X11
 This feature enables x11 support. Currently this implements panel windows for X11 similarly
 to the wlroots layershell above.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a4919952..846a280c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -56,6 +56,10 @@ boption(HYPRLAND_IPC "    Hyprland IPC" ON REQUIRES HYPRLAND)
 boption(HYPRLAND_GLOBAL_SHORTCUTS "    Hyprland Global Shortcuts" ON REQUIRES HYPRLAND)
 boption(HYPRLAND_FOCUS_GRAB "    Hyprland Focus Grabbing" ON REQUIRES HYPRLAND)
 boption(HYPRLAND_SURFACE_EXTENSIONS "    Hyprland Surface Extensions" ON REQUIRES HYPRLAND)
+boption(SCREENCOPY "  Screencopy" ON REQUIRES WAYLAND)
+boption(SCREENCOPY_ICC "    Image Copy Capture" ON REQUIRES WAYLAND)
+boption(SCREENCOPY_WLR "    Wlroots Screencopy" ON REQUIRES WAYLAND)
+boption(SCREENCOPY_HYPRLAND_TOPLEVEL "    Hyprland Toplevel Export" ON REQUIRES WAYLAND)
 boption(X11 "X11" ON)
 boption(I3 "I3/Sway" ON)
 boption(I3_IPC "  I3/Sway IPC" ON REQUIRES I3)
@@ -70,7 +74,7 @@ boption(SERVICE_NOTIFICATIONS "Notifications" ON)
 include(cmake/install-qml-module.cmake)
 include(cmake/util.cmake)
 
-add_compile_options(-Wall -Wextra)
+add_compile_options(-Wall -Wextra -Wno-vla-cxx-extension)
 
 # pipewire defines this, breaking PCH
 add_compile_definitions(_REENTRANT)
diff --git a/default.nix b/default.nix
index fab038a7..79c9b7a4 100644
--- a/default.nix
+++ b/default.nix
@@ -14,6 +14,8 @@
   jemalloc,
   wayland,
   wayland-protocols,
+  libdrm,
+  libgbm ? null,
   xorg,
   pipewire,
   pam,
@@ -64,7 +66,7 @@
   ++ lib.optional withCrashReporter breakpad
   ++ lib.optional withJemalloc jemalloc
   ++ lib.optional withQtSvg qt6.qtsvg
-  ++ lib.optionals withWayland [ qt6.qtwayland wayland ]
+  ++ lib.optionals withWayland ([ qt6.qtwayland wayland ] ++ (if libgbm != null then [ libdrm libgbm ] else []))
   ++ lib.optional withX11 xorg.libxcb
   ++ lib.optional withPam pam
   ++ lib.optional withPipewire pipewire;
@@ -79,6 +81,7 @@
     (lib.cmakeBool "CRASH_REPORTER" withCrashReporter)
     (lib.cmakeBool "USE_JEMALLOC" withJemalloc)
     (lib.cmakeBool "WAYLAND" withWayland)
+    (lib.cmakeBool "SCREENCOPY" (libgbm != null))
     (lib.cmakeBool "SERVICE_PIPEWIRE" withPipewire)
     (lib.cmakeBool "SERVICE_PAM" withPam)
     (lib.cmakeBool "HYPRLAND" withHyprland)
diff --git a/src/core/stacklist.hpp b/src/core/stacklist.hpp
new file mode 100644
index 00000000..7e9ee788
--- /dev/null
+++ b/src/core/stacklist.hpp
@@ -0,0 +1,159 @@
+#pragma once
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <iterator>
+#include <vector>
+
+#include <qtypes.h>
+
+template <class T, size_t N>
+class StackList {
+public:
+	T& operator[](size_t i) {
+		if (i < N) {
+			return this->array[i];
+		} else {
+			return this->vec[i - N];
+		}
+	}
+
+	const T& operator[](size_t i) const {
+		return const_cast<StackList<T, N>*>(this)->operator[](i); // NOLINT
+	}
+
+	void push(const T& value) {
+		if (this->size < N) {
+			this->array[this->size] = value;
+		} else {
+			this->vec.push_back(value);
+		}
+
+		++this->size;
+	}
+
+	[[nodiscard]] size_t length() const { return this->size; }
+	[[nodiscard]] bool isEmpty() const { return this->size == 0; }
+
+	[[nodiscard]] bool operator==(const StackList<T, N>& other) const {
+		if (other.size != this->size) return false;
+
+		for (size_t i = 0; i < this->size; ++i) {
+			if (this->operator[](i) != other[i]) return false;
+		}
+
+		return true;
+	}
+
+	template <typename Self, typename ListPtr, typename IT>
+	struct BaseIterator {
+		using iterator_category = std::bidirectional_iterator_tag;
+		using difference_type = int64_t;
+		using value_type = IT;
+		using pointer = IT*;
+		using reference = IT&;
+
+		BaseIterator() = default;
+		explicit BaseIterator(ListPtr list, size_t i): list(list), i(i) {}
+
+		reference operator*() const { return this->list->operator[](this->i); }
+		pointer operator->() const { return &**this; }
+
+		Self& operator++() {
+			++this->i;
+			return *static_cast<Self*>(this);
+		}
+		Self& operator--() {
+			--this->i;
+			return *static_cast<Self*>(this);
+		}
+
+		Self operator++(int) {
+			auto v = *this;
+			this->operator++();
+			return v;
+		}
+		Self operator--(int) {
+			auto v = *this;
+			this->operator--();
+			return v;
+		}
+
+		difference_type operator-(const Self& other) {
+			return static_cast<int64_t>(this->i) - static_cast<int64_t>(other.i);
+		}
+
+		Self& operator+(difference_type offset) {
+			return Self(this->list, static_cast<int64_t>(this->i) + offset);
+		}
+
+		[[nodiscard]] bool operator==(const Self& other) const {
+			return this->list == other.list && this->i == other.i;
+		}
+
+		[[nodiscard]] bool operator!=(const Self& other) const { return !(*this == other); }
+
+	private:
+		ListPtr list = nullptr;
+		size_t i = 0;
+	};
+
+	struct Iterator: public BaseIterator<Iterator, StackList<T, N>*, T> {
+		Iterator() = default;
+		Iterator(StackList<T, N>* list, size_t i)
+		    : BaseIterator<Iterator, StackList<T, N>*, T>(list, i) {}
+	};
+
+	struct ConstIterator: public BaseIterator<ConstIterator, const StackList<T, N>*, const T> {
+		ConstIterator() = default;
+		ConstIterator(const StackList<T, N>* list, size_t i)
+		    : BaseIterator<ConstIterator, const StackList<T, N>*, const T>(list, i) {}
+	};
+
+	[[nodiscard]] Iterator begin() { return Iterator(this, 0); }
+	[[nodiscard]] Iterator end() { return Iterator(this, this->size); }
+
+	[[nodiscard]] ConstIterator begin() const { return ConstIterator(this, 0); }
+	[[nodiscard]] ConstIterator end() const { return ConstIterator(this, this->size); }
+
+	[[nodiscard]] bool isContiguous() const { return this->vec.empty(); }
+	[[nodiscard]] const T* pArray() const { return this->array.data(); }
+	[[nodiscard]] size_t dataLength() const { return this->size * sizeof(T); }
+
+	const T* populateAlloc(void* alloc) const {
+		auto arraylen = std::min(this->size, N) * sizeof(T);
+		memcpy(alloc, this->array.data(), arraylen);
+
+		if (!this->vec.empty()) {
+			memcpy(
+			    static_cast<char*>(alloc) + arraylen, // NOLINT
+			    this->vec.data(),
+			    this->vec.size() * sizeof(T)
+			);
+		}
+
+		return static_cast<T*>(alloc);
+	}
+
+private:
+	std::array<T, N> array {};
+	std::vector<T> vec;
+	size_t size = 0;
+};
+
+// might be incorrectly aligned depending on type
+// #define STACKLIST_ALLOCA_VIEW(list) ((list).isContiguous() ? (list).pArray() : (list).populateAlloc(alloca((list).dataLength())))
+
+// NOLINTBEGIN
+#define STACKLIST_VLA_VIEW(type, list, var)                                                        \
+	const type* var;                                                                                 \
+	type var##Data[(list).length()];                                                                 \
+	if ((list).isContiguous()) {                                                                     \
+		(var) = (list).pArray();                                                                       \
+	} else {                                                                                         \
+		(list).populateAlloc(var##Data);                                                               \
+		(var) = var##Data;                                                                             \
+	}
+// NOLINTEND
diff --git a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt
index 54bb59bc..3b3d08ae 100644
--- a/src/wayland/CMakeLists.txt
+++ b/src/wayland/CMakeLists.txt
@@ -103,6 +103,13 @@ if (WAYLAND_TOPLEVEL_MANAGEMENT)
 	list(APPEND WAYLAND_MODULES Quickshell.Wayland._ToplevelManagement)
 endif()
 
+if (SCREENCOPY)
+	add_subdirectory(buffer)
+	add_subdirectory(screencopy)
+	list(APPEND WAYLAND_MODULES Quickshell.Wayland._Screencopy)
+endif()
+
+
 if (HYPRLAND)
 	add_subdirectory(hyprland)
 endif()
diff --git a/src/wayland/buffer/CMakeLists.txt b/src/wayland/buffer/CMakeLists.txt
new file mode 100644
index 00000000..f80c53a3
--- /dev/null
+++ b/src/wayland/buffer/CMakeLists.txt
@@ -0,0 +1,18 @@
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(dmabuf-deps REQUIRED IMPORTED_TARGET libdrm gbm egl)
+
+qt_add_library(quickshell-wayland-buffer STATIC
+	manager.cpp
+	dmabuf.cpp
+	shm.cpp
+)
+
+wl_proto(wlp-linux-dmabuf linux-dmabuf-v1 "${WAYLAND_PROTOCOLS}/stable/linux-dmabuf")
+
+target_link_libraries(quickshell-wayland-buffer PRIVATE
+	Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+	PkgConfig::dmabuf-deps
+	wlp-linux-dmabuf
+)
+
+qs_pch(quickshell-wayland-buffer SET large)
diff --git a/src/wayland/buffer/dmabuf.cpp b/src/wayland/buffer/dmabuf.cpp
new file mode 100644
index 00000000..47167020
--- /dev/null
+++ b/src/wayland/buffer/dmabuf.cpp
@@ -0,0 +1,659 @@
+#include "dmabuf.hpp"
+#include <algorithm>
+#include <array>
+#include <csignal>
+#include <cstdint>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include <EGL/egl.h>
+#include <EGL/eglext.h>
+#include <GL/gl.h>
+#include <GLES3/gl32.h>
+#include <fcntl.h>
+#include <gbm.h>
+#include <libdrm/drm_fourcc.h>
+#include <qcontainerfwd.h>
+#include <qdebug.h>
+#include <qlist.h>
+#include <qlogging.h>
+#include <qloggingcategory.h>
+#include <qopenglcontext.h>
+#include <qopenglcontext_platform.h>
+#include <qpair.h>
+#include <qquickwindow.h>
+#include <qscopedpointer.h>
+#include <qsgtexture_platform.h>
+#include <qtclasshelpermacros.h>
+#include <qwayland-linux-dmabuf-v1.h>
+#include <qwaylandclientextension.h>
+#include <sys/mman.h>
+#include <sys/types.h>
+#include <unistd.h>
+#include <wayland-client-protocol.h>
+#include <wayland-linux-dmabuf-v1-client-protocol.h>
+#include <wayland-util.h>
+#include <xf86drm.h>
+
+#include "../../core/stacklist.hpp"
+#include "manager.hpp"
+#include "manager_p.hpp"
+
+namespace qs::wayland::buffer::dmabuf {
+
+namespace {
+
+Q_LOGGING_CATEGORY(logDmabuf, "quickshell.wayland.buffer.dmabuf", QtWarningMsg);
+
+LinuxDmabufManager* MANAGER = nullptr; // NOLINT
+
+class FourCCStr {
+public:
+	explicit FourCCStr(uint32_t code)
+	    : chars(
+	          {static_cast<char>(code >> 0 & 0xff),
+	           static_cast<char>(code >> 8 & 0xff),
+	           static_cast<char>(code >> 16 & 0xff),
+	           static_cast<char>(code >> 24 & 0xff),
+	           '\0'}
+	      ) {
+		for (auto i = 3; i != 0; i--) {
+			if (chars[i] == ' ') chars[i] = '\0';
+			else break;
+		}
+	}
+
+	[[nodiscard]] const char* cStr() const { return this->chars.data(); }
+
+private:
+	std::array<char, 5> chars {};
+};
+
+class FourCCModStr {
+public:
+	explicit FourCCModStr(uint64_t code): drmStr(drmGetFormatModifierName(code)) {}
+	~FourCCModStr() {
+		if (this->drmStr) drmFree(this->drmStr);
+	}
+
+	Q_DISABLE_COPY_MOVE(FourCCModStr);
+
+	[[nodiscard]] const char* cStr() const { return this->drmStr; }
+
+private:
+	char* drmStr;
+};
+
+QDebug& operator<<(QDebug& debug, const FourCCStr& fourcc) {
+	debug << fourcc.cStr();
+	return debug;
+}
+
+QDebug& operator<<(QDebug& debug, const FourCCModStr& fourcc) {
+	debug << fourcc.cStr();
+	return debug;
+}
+
+} // namespace
+
+QDebug& operator<<(QDebug& debug, const WlDmaBuffer* buffer) {
+	auto saver = QDebugStateSaver(debug);
+	debug.nospace();
+
+	if (buffer) {
+		debug << "WlDmaBuffer(" << static_cast<const void*>(buffer) << ", size=" << buffer->width << 'x'
+		      << buffer->height << ", format=" << FourCCStr(buffer->format) << ", modifier=`"
+		      << FourCCModStr(buffer->modifier) << "`)";
+	} else {
+		debug << "WlDmaBuffer(0x0)";
+	}
+
+	return debug;
+}
+
+GbmDeviceHandle::~GbmDeviceHandle() {
+	if (device) {
+		MANAGER->unrefGbmDevice(this->device);
+	}
+}
+
+// This will definitely backfire later
+void LinuxDmabufFormatSelection::ensureSorted() {
+	if (this->sorted) return;
+	auto beginIter = this->formats.begin();
+
+	auto xrgbIter = std::ranges::find_if(this->formats, [](const auto& format) {
+		return format.first == DRM_FORMAT_XRGB8888;
+	});
+
+	if (xrgbIter != this->formats.end()) {
+		std::swap(*beginIter, *xrgbIter);
+		++beginIter;
+	}
+
+	auto argbIter = std::ranges::find_if(this->formats, [](const auto& format) {
+		return format.first == DRM_FORMAT_ARGB8888;
+	});
+
+	if (argbIter != this->formats.end()) std::swap(*beginIter, *argbIter);
+
+	this->sorted = true;
+}
+
+LinuxDmabufFeedback::LinuxDmabufFeedback(::zwp_linux_dmabuf_feedback_v1* feedback)
+    : zwp_linux_dmabuf_feedback_v1(feedback) {}
+
+LinuxDmabufFeedback::~LinuxDmabufFeedback() { this->destroy(); }
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_format_table(int32_t fd, uint32_t size) {
+	this->formatTableSize = size;
+
+	this->formatTable = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
+
+	if (this->formatTable == MAP_FAILED) {
+		this->formatTable = nullptr;
+		qCFatal(logDmabuf) << "Failed to mmap format table.";
+	}
+
+	qCDebug(logDmabuf) << "Got format table";
+}
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_main_device(wl_array* device) {
+	if (device->size != sizeof(dev_t)) {
+		qCFatal(logDmabuf) << "The size of dev_t used by the compositor and quickshell is mismatched. "
+		                      "Try recompiling both.";
+	}
+
+	this->mainDevice = *reinterpret_cast<dev_t*>(device->data);
+	qCDebug(logDmabuf) << "Got main device id" << this->mainDevice;
+}
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_tranche_target_device(wl_array* device) {
+	if (device->size != sizeof(dev_t)) {
+		qCFatal(logDmabuf) << "The size of dev_t used by the compositor and quickshell is mismatched. "
+		                      "Try recompiling both.";
+	}
+
+	auto& tranche = this->tranches.emplaceBack();
+	tranche.device = *reinterpret_cast<dev_t*>(device->data);
+	qCDebug(logDmabuf) << "Got target device id" << this->mainDevice;
+}
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_tranche_flags(uint32_t flags) {
+	this->tranches.back().flags = flags;
+	qCDebug(logDmabuf) << "Got target device flags" << flags;
+}
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_tranche_formats(wl_array* indices) {
+	struct FormatTableEntry {
+		uint32_t format;
+		uint32_t padding;
+		uint64_t modifier;
+	};
+
+	static_assert(sizeof(FormatTableEntry) == 16, "Format table entry was not packed to 16 bytes.");
+
+	if (this->formatTable == nullptr) {
+		qCFatal(logDmabuf) << "Received tranche formats before format table.";
+	}
+
+	auto& tranche = this->tranches.back();
+
+	auto* table = reinterpret_cast<FormatTableEntry*>(this->formatTable);
+	auto* indexTable = reinterpret_cast<uint16_t*>(indices->data);
+	auto indexTableLength = indices->size / sizeof(uint16_t);
+
+	uint32_t lastFormat = 0;
+	LinuxDmabufModifiers* lastModifiers = nullptr;
+	LinuxDmabufModifiers* modifiers = nullptr;
+
+	for (uint16_t ti = 0; ti != indexTableLength; ++ti) {
+		auto i = indexTable[ti];      // NOLINT
+		const auto& entry = table[i]; // NOLINT
+
+		// Compositors usually send a single format's modifiers as a block.
+		if (!modifiers || entry.format != lastFormat) {
+			// We can often share modifier lists between formats
+			if (lastModifiers && modifiers->modifiers == lastModifiers->modifiers) {
+				// avoids storing a second list
+				modifiers->modifiers = lastModifiers->modifiers;
+			}
+
+			lastFormat = entry.format;
+			lastModifiers = modifiers;
+
+			auto modifiersIter = std::ranges::find_if(tranche.formats.formats, [&](const auto& pair) {
+				return pair.first == entry.format;
+			});
+
+			if (modifiersIter == tranche.formats.formats.end()) {
+				tranche.formats.formats.push(qMakePair(entry.format, LinuxDmabufModifiers()));
+				modifiers = &(--tranche.formats.formats.end())->second;
+			} else {
+				modifiers = &modifiersIter->second;
+			}
+		}
+
+		if (entry.modifier == DRM_FORMAT_MOD_INVALID) {
+			modifiers->implicit = true;
+		} else {
+			modifiers->modifiers.push(entry.modifier);
+		}
+	}
+
+	if (lastModifiers && modifiers && modifiers->modifiers == lastModifiers->modifiers) {
+		modifiers->modifiers = lastModifiers->modifiers;
+	}
+}
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_tranche_done() {
+	qCDebug(logDmabuf) << "Got tranche end.";
+}
+
+void LinuxDmabufFeedback::zwp_linux_dmabuf_feedback_v1_done() {
+	qCDebug(logDmabuf) << "Got feedback done.";
+
+	if (this->formatTable) {
+		munmap(this->formatTable, this->formatTableSize);
+		this->formatTable = nullptr;
+	}
+
+	if (logDmabuf().isDebugEnabled()) {
+		qCDebug(logDmabuf) << "Dmabuf tranches:";
+
+		for (auto& tranche: this->tranches) {
+			qCDebug(logDmabuf) << "  Tranche on device" << tranche.device;
+
+			// will be sorted on first use otherwise
+			tranche.formats.ensureSorted();
+
+			for (auto& [format, modifiers]: tranche.formats.formats) {
+				qCDebug(logDmabuf) << "    Format" << FourCCStr(format);
+
+				if (modifiers.implicit) {
+					qCDebug(logDmabuf) << "      Implicit Modifier";
+				}
+
+				for (const auto& modifier: modifiers.modifiers) {
+					qCDebug(logDmabuf) << "      Explicit Modifier" << FourCCModStr(modifier);
+				}
+			}
+		}
+	}
+
+	// Copy tranches to the manager. If the compositor ever updates
+	// our tranches, we'll start from a clean slate.
+	MANAGER->tranches = this->tranches;
+	this->tranches.clear();
+
+	MANAGER->feedbackDone();
+}
+
+LinuxDmabufManager::LinuxDmabufManager(WlBufferManagerPrivate* manager)
+    : QWaylandClientExtensionTemplate(5)
+    , manager(manager) {
+	MANAGER = this;
+	this->initialize();
+
+	if (this->isActive()) {
+		qCDebug(logDmabuf) << "Requesting default dmabuf feedback...";
+		new LinuxDmabufFeedback(this->get_default_feedback());
+	}
+}
+
+void LinuxDmabufManager::feedbackDone() { this->manager->dmabufReady(); }
+
+GbmDeviceHandle LinuxDmabufManager::getGbmDevice(dev_t handle) {
+	struct DrmFree {
+		static void cleanup(drmDevice* d) { drmFreeDevice(&d); }
+	};
+
+	std::string renderNodeStorage;
+	std::string* renderNode = nullptr;
+
+	auto sharedDevice = std::ranges::find_if(this->gbmDevices, [&](const SharedGbmDevice& d) {
+		return d.handle == handle;
+	});
+
+	if (sharedDevice != this->gbmDevices.end()) {
+		renderNode = &sharedDevice->renderNode;
+	} else {
+		drmDevice* drmDevPtr = nullptr;
+		if (auto error = drmGetDeviceFromDevId(handle, 0, &drmDevPtr); error != 0) {
+			qCWarning(logDmabuf) << "Failed to get drm device information from handle:"
+			                     << qt_error_string(error);
+			return nullptr;
+		}
+
+		auto drmDev = QScopedPointer<drmDevice, DrmFree>(drmDevPtr);
+
+		if (!(drmDev->available_nodes & (1 << DRM_NODE_RENDER))) {
+			qCDebug(logDmabuf) << "Cannot create GBM device: DRM device does not have render node.";
+			return nullptr;
+		}
+
+		renderNodeStorage = drmDev->nodes[DRM_NODE_RENDER]; // NOLINT
+		renderNode = &renderNodeStorage;
+		sharedDevice = std::ranges::find_if(this->gbmDevices, [&](const SharedGbmDevice& d) {
+			return d.renderNode == renderNodeStorage;
+		});
+	}
+
+	if (sharedDevice != this->gbmDevices.end()) {
+		qCDebug(logDmabuf) << "Used existing GBM device on render node" << *renderNode;
+		++sharedDevice->refcount;
+		return sharedDevice->device;
+	} else {
+		auto fd = open(renderNode->c_str(), O_RDWR | O_CLOEXEC);
+		if (fd < 0) {
+			qCDebug(logDmabuf) << "Could not open render node" << *renderNode << ":"
+			                   << qt_error_string(fd);
+			return nullptr;
+		}
+
+		auto* device = gbm_create_device(fd);
+
+		if (!device) {
+			qCDebug(logDmabuf) << "Failed to create GBM device from render node" << *renderNode;
+			close(fd);
+			return nullptr;
+		}
+
+		qCDebug(logDmabuf) << "Created GBM device on render node" << *renderNode;
+
+		this->gbmDevices.push_back({
+		    .handle = handle,
+		    .renderNode = std::move(renderNodeStorage),
+		    .device = device,
+		    .refcount = 1,
+		});
+
+		return device;
+	}
+}
+
+void LinuxDmabufManager::unrefGbmDevice(gbm_device* device) {
+	auto iter = std::ranges::find_if(this->gbmDevices, [device](const SharedGbmDevice& d) {
+		return d.device == device;
+	});
+	if (iter == this->gbmDevices.end()) return;
+
+	qCDebug(logDmabuf) << "Lost reference to GBM device" << device;
+
+	if (--iter->refcount == 0) {
+		auto fd = gbm_device_get_fd(iter->device);
+		gbm_device_destroy(iter->device);
+		close(fd);
+
+		this->gbmDevices.erase(iter);
+		qCDebug(logDmabuf) << "Destroyed GBM device" << device;
+	}
+}
+
+GbmDeviceHandle LinuxDmabufManager::dupHandle(const GbmDeviceHandle& handle) {
+	if (!handle) return GbmDeviceHandle();
+
+	auto iter = std::ranges::find_if(this->gbmDevices, [&handle](const SharedGbmDevice& d) {
+		return d.device == *handle;
+	});
+	if (iter == this->gbmDevices.end()) return GbmDeviceHandle();
+
+	qCDebug(logDmabuf) << "Duplicated GBM device handle" << *handle;
+	++iter->refcount;
+	return GbmDeviceHandle(*handle);
+}
+
+WlBuffer* LinuxDmabufManager::createDmabuf(const WlBufferRequest& request) {
+	for (auto& tranche: this->tranches) {
+		if (request.dmabuf.device != 0 && tranche.device != request.dmabuf.device) {
+			continue;
+		}
+
+		LinuxDmabufFormatSelection formats;
+		for (const auto& format: request.dmabuf.formats) {
+			if (!format.modifiers.isEmpty()) {
+				formats.formats.push(
+				    qMakePair(format.format, LinuxDmabufModifiers {.modifiers = format.modifiers})
+				);
+			} else {
+				for (const auto& trancheFormat: tranche.formats.formats) {
+					if (trancheFormat.first == format.format) {
+						formats.formats.push(trancheFormat);
+					}
+				}
+			}
+		}
+
+		if (formats.formats.isEmpty()) continue;
+		formats.ensureSorted();
+
+		auto gbmDevice = this->getGbmDevice(tranche.device);
+
+		if (!gbmDevice) {
+			qCWarning(logDmabuf) << "Hit unusable tranche device while trying to create dmabuf.";
+			continue;
+		}
+
+		for (const auto& [format, modifiers]: formats.formats) {
+			if (auto* buf =
+			        this->createDmabuf(gbmDevice, format, modifiers, request.width, request.height))
+			{
+				return buf;
+			}
+		}
+	}
+
+	qCWarning(logDmabuf) << "Unable to create dmabuf for request: No matching formats.";
+	return nullptr;
+}
+
+WlBuffer* LinuxDmabufManager::createDmabuf(
+    GbmDeviceHandle& device,
+    uint32_t format,
+    const LinuxDmabufModifiers& modifiers,
+    uint32_t width,
+    uint32_t height
+) {
+	auto buffer = std::unique_ptr<WlDmaBuffer>(new WlDmaBuffer());
+	auto& bo = buffer->bo;
+
+	const uint32_t flags = GBM_BO_USE_RENDERING;
+
+	if (modifiers.modifiers.isEmpty()) {
+		if (!modifiers.implicit) {
+			qCritical(logDmabuf
+			) << "Failed to create gbm_bo: format supports no implicit OR explicit modifiers.";
+			return nullptr;
+		}
+
+		qCDebug(logDmabuf) << "Creating gbm_bo without modifiers...";
+		bo = gbm_bo_create(*device, width, height, format, flags);
+	} else {
+		qCDebug(logDmabuf) << "Creating gbm_bo with modifiers...";
+
+		STACKLIST_VLA_VIEW(uint64_t, modifiers.modifiers, modifiersData);
+
+		bo = gbm_bo_create_with_modifiers2(
+		    *device,
+		    width,
+		    height,
+		    format,
+		    modifiersData,
+		    modifiers.modifiers.length(),
+		    flags
+		);
+	}
+
+	if (!bo) {
+		qCritical(logDmabuf) << "Failed to create gbm_bo.";
+		return nullptr;
+	}
+
+	buffer->planeCount = gbm_bo_get_plane_count(bo);
+	buffer->planes = new WlDmaBuffer::Plane[buffer->planeCount]();
+	buffer->modifier = gbm_bo_get_modifier(bo);
+
+	auto params = QtWayland::zwp_linux_buffer_params_v1(this->create_params());
+
+	for (auto i = 0; i < buffer->planeCount; ++i) {
+		auto& plane = buffer->planes[i]; // NOLINT
+		plane.fd = gbm_bo_get_fd_for_plane(bo, i);
+
+		if (plane.fd < 0) {
+			qCritical(logDmabuf) << "Failed to get gbm_bo fd for plane" << i << qt_error_string(plane.fd);
+			params.destroy();
+			gbm_bo_destroy(bo);
+			return nullptr;
+		}
+
+		plane.stride = gbm_bo_get_stride_for_plane(bo, i);
+		plane.offset = gbm_bo_get_offset(bo, i);
+
+		params.add(
+		    plane.fd,
+		    i,
+		    plane.offset,
+		    plane.stride,
+		    buffer->modifier >> 32,
+		    buffer->modifier & 0xffffffff
+		);
+	}
+
+	buffer->mBuffer =
+	    params.create_immed(static_cast<int32_t>(width), static_cast<int32_t>(height), format, 0);
+	params.destroy();
+
+	buffer->device = this->dupHandle(device);
+	buffer->width = width;
+	buffer->height = height;
+	buffer->format = format;
+
+	qCDebug(logDmabuf) << "Created dmabuf" << buffer.get();
+	return buffer.release();
+}
+
+WlDmaBuffer::WlDmaBuffer(WlDmaBuffer&& other) noexcept
+    : device(std::move(other.device))
+    , bo(other.bo)
+    , mBuffer(other.mBuffer)
+    , planes(other.planes) {
+	other.mBuffer = nullptr;
+	other.bo = nullptr;
+	other.planeCount = 0;
+}
+
+WlDmaBuffer& WlDmaBuffer::operator=(WlDmaBuffer&& other) noexcept {
+	this->~WlDmaBuffer();
+	new (this) WlDmaBuffer(std::move(other));
+	return *this;
+}
+
+WlDmaBuffer::~WlDmaBuffer() {
+	if (this->mBuffer) {
+		wl_buffer_destroy(this->mBuffer);
+	}
+
+	if (this->bo) {
+		gbm_bo_destroy(this->bo);
+		qCDebug(logDmabuf) << "Destroyed" << this << "freeing bo" << this->bo;
+	}
+
+	for (auto i = 0; i < this->planeCount; ++i) {
+		const auto& plane = this->planes[i]; // NOLINT
+		if (plane.fd) close(plane.fd);
+	}
+
+	delete[] this->planes;
+}
+
+bool WlDmaBuffer::isCompatible(const WlBufferRequest& request) const {
+	if (request.width != this->width || request.height != this->height) return false;
+
+	auto matchingFormat = std::ranges::find_if(request.dmabuf.formats, [&](const auto& format) {
+		return format.format == this->format
+		    && (format.modifiers.isEmpty()
+		        || std::ranges::find(format.modifiers, this->modifier) != format.modifiers.end());
+	});
+
+	return matchingFormat != request.dmabuf.formats.end();
+}
+
+WlBufferQSGTexture* WlDmaBuffer::createQsgTexture(QQuickWindow* window) const {
+	static auto* glEGLImageTargetTexture2DOES = []() {
+		auto* fn = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(
+		    eglGetProcAddress("glEGLImageTargetTexture2DOES")
+		);
+
+		if (!fn) {
+			qCFatal(logDmabuf) << "Failed to create QSG texture from WlDmaBuffer: "
+			                      "glEGLImageTargetTexture2DOES is missing.";
+		}
+
+		return fn;
+	}();
+
+	auto* context = QOpenGLContext::currentContext();
+	if (!context) {
+		qCFatal(logDmabuf) << "Failed to create QSG texture from WlDmaBuffer: No GL context.";
+	}
+
+	auto* qEglContext = context->nativeInterface<QNativeInterface::QEGLContext>();
+	if (!qEglContext) {
+		qCFatal(logDmabuf) << "Failed to create QSG texture from WlDmaBuffer: No EGL context.";
+	}
+
+	auto* display = qEglContext->display();
+
+	// clang-format off
+	auto attribs = std::array<EGLAttrib, 6 * 2 + 1> {
+		EGL_WIDTH, this->width,
+		EGL_HEIGHT, this->height,
+		EGL_LINUX_DRM_FOURCC_EXT, this->format,
+		EGL_DMA_BUF_PLANE0_FD_EXT, this->planes[0].fd, // NOLINT
+		EGL_DMA_BUF_PLANE0_OFFSET_EXT, this->planes[0].offset, // NOLINT
+		EGL_DMA_BUF_PLANE0_PITCH_EXT, this->planes[0].stride, // NOLINT
+		EGL_NONE
+	};
+	// clang-format on
+
+	auto* eglImage =
+	    eglCreateImage(display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attribs.data());
+
+	if (eglImage == EGL_NO_IMAGE) {
+		qFatal() << "failed to make egl image" << eglGetError();
+		return nullptr;
+	}
+
+	window->beginExternalCommands();
+	GLuint glTexture = 0;
+	glGenTextures(1, &glTexture);
+
+	glBindTexture(GL_TEXTURE_2D, glTexture);
+	glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, eglImage);
+	glBindTexture(GL_TEXTURE_2D, 0);
+	window->endExternalCommands();
+
+	auto* qsgTexture = QNativeInterface::QSGOpenGLTexture::fromNative(
+	    glTexture,
+	    window,
+	    QSize(static_cast<int>(this->width), static_cast<int>(this->height))
+	);
+
+	auto* tex = new WlDmaBufferQSGTexture(eglImage, glTexture, qsgTexture);
+	qCDebug(logDmabuf) << "Created WlDmaBufferQSGTexture" << tex << "from" << this;
+	return tex;
+}
+
+WlDmaBufferQSGTexture::~WlDmaBufferQSGTexture() {
+	auto* context = QOpenGLContext::currentContext();
+	auto* display = context->nativeInterface<QNativeInterface::QEGLContext>()->display();
+
+	if (this->glTexture) glDeleteTextures(1, &this->glTexture);
+	if (this->eglImage) eglDestroyImage(display, this->eglImage);
+	delete this->qsgTexture;
+
+	qCDebug(logDmabuf) << "WlDmaBufferQSGTexture" << this << "destroyed.";
+}
+
+} // namespace qs::wayland::buffer::dmabuf
diff --git a/src/wayland/buffer/dmabuf.hpp b/src/wayland/buffer/dmabuf.hpp
new file mode 100644
index 00000000..97b5576f
--- /dev/null
+++ b/src/wayland/buffer/dmabuf.hpp
@@ -0,0 +1,195 @@
+#pragma once
+
+#include <cstdint>
+
+#include <EGL/egl.h>
+#include <gbm.h>
+#include <qcontainerfwd.h>
+#include <qhash.h>
+#include <qlist.h>
+#include <qquickwindow.h>
+#include <qsgtexture.h>
+#include <qsize.h>
+#include <qtclasshelpermacros.h>
+#include <qtypes.h>
+#include <qwayland-linux-dmabuf-v1.h>
+#include <qwaylandclientextension.h>
+#include <sys/types.h>
+#include <wayland-linux-dmabuf-v1-client-protocol.h>
+#include <wayland-util.h>
+
+#include "manager.hpp"
+#include "qsg.hpp"
+
+namespace qs::wayland::buffer {
+class WlBufferManagerPrivate;
+}
+
+namespace qs::wayland::buffer::dmabuf {
+
+class LinuxDmabufManager;
+
+class GbmDeviceHandle {
+public:
+	GbmDeviceHandle() = default;
+	GbmDeviceHandle(gbm_device* device): device(device) {}
+
+	GbmDeviceHandle(GbmDeviceHandle&& other) noexcept: device(other.device) {
+		other.device = nullptr;
+	}
+
+	~GbmDeviceHandle();
+	Q_DISABLE_COPY(GbmDeviceHandle);
+
+	GbmDeviceHandle& operator=(GbmDeviceHandle&& other) noexcept {
+		this->device = other.device;
+		other.device = nullptr;
+		return *this;
+	}
+
+	[[nodiscard]] gbm_device* operator*() const { return this->device; }
+	[[nodiscard]] operator bool() const { return this->device; }
+
+private:
+	gbm_device* device = nullptr;
+};
+
+class WlDmaBufferQSGTexture: public WlBufferQSGTexture {
+public:
+	~WlDmaBufferQSGTexture() override;
+	Q_DISABLE_COPY_MOVE(WlDmaBufferQSGTexture);
+
+	[[nodiscard]] QSGTexture* texture() const override { return this->qsgTexture; }
+
+private:
+	WlDmaBufferQSGTexture(EGLImage eglImage, GLuint glTexture, QSGTexture* qsgTexture)
+	    : eglImage(eglImage)
+	    , glTexture(glTexture)
+	    , qsgTexture(qsgTexture) {}
+
+	EGLImage eglImage;
+	GLuint glTexture;
+	QSGTexture* qsgTexture;
+
+	friend class WlDmaBuffer;
+};
+
+class WlDmaBuffer: public WlBuffer {
+public:
+	~WlDmaBuffer() override;
+	Q_DISABLE_COPY(WlDmaBuffer);
+	WlDmaBuffer(WlDmaBuffer&& other) noexcept;
+	WlDmaBuffer& operator=(WlDmaBuffer&& other) noexcept;
+
+	[[nodiscard]] wl_buffer* buffer() const override { return this->mBuffer; }
+
+	[[nodiscard]] QSize size() const override {
+		return QSize(static_cast<int>(this->width), static_cast<int>(this->height));
+	}
+
+	[[nodiscard]] bool isCompatible(const WlBufferRequest& request) const override;
+	[[nodiscard]] WlBufferQSGTexture* createQsgTexture(QQuickWindow* window) const override;
+
+private:
+	WlDmaBuffer() noexcept = default;
+
+	struct Plane {
+		int fd = 0;
+		uint32_t offset = 0;
+		uint32_t stride = 0;
+	};
+
+	GbmDeviceHandle device;
+	gbm_bo* bo = nullptr;
+	wl_buffer* mBuffer = nullptr;
+	int planeCount = 0;
+	Plane* planes = nullptr;
+	uint32_t format = 0;
+	uint64_t modifier = 0;
+	uint32_t width = 0;
+	uint32_t height = 0;
+
+	friend class LinuxDmabufManager;
+	friend QDebug& operator<<(QDebug& debug, const WlDmaBuffer* buffer);
+};
+
+QDebug& operator<<(QDebug& debug, const WlDmaBuffer* buffer);
+
+struct LinuxDmabufModifiers {
+	StackList<uint64_t, 10> modifiers;
+	bool implicit = false;
+};
+
+struct LinuxDmabufFormatSelection {
+	bool sorted = false;
+	StackList<QPair<uint32_t, LinuxDmabufModifiers>, 2> formats;
+	void ensureSorted();
+};
+
+struct LinuxDmabufTranche {
+	dev_t device = 0;
+	uint32_t flags = 0;
+	LinuxDmabufFormatSelection formats;
+};
+
+class LinuxDmabufFeedback: public QtWayland::zwp_linux_dmabuf_feedback_v1 {
+public:
+	explicit LinuxDmabufFeedback(::zwp_linux_dmabuf_feedback_v1* feedback);
+	~LinuxDmabufFeedback() override;
+	Q_DISABLE_COPY_MOVE(LinuxDmabufFeedback);
+
+protected:
+	void zwp_linux_dmabuf_feedback_v1_main_device(wl_array* device) override;
+	void zwp_linux_dmabuf_feedback_v1_format_table(int32_t fd, uint32_t size) override;
+	void zwp_linux_dmabuf_feedback_v1_tranche_target_device(wl_array* device) override;
+	void zwp_linux_dmabuf_feedback_v1_tranche_flags(uint32_t flags) override;
+	void zwp_linux_dmabuf_feedback_v1_tranche_formats(wl_array* indices) override;
+	void zwp_linux_dmabuf_feedback_v1_tranche_done() override;
+	void zwp_linux_dmabuf_feedback_v1_done() override;
+
+private:
+	dev_t mainDevice = 0;
+	QList<LinuxDmabufTranche> tranches;
+	void* formatTable = nullptr;
+	uint32_t formatTableSize = 0;
+};
+
+class LinuxDmabufManager
+    : public QWaylandClientExtensionTemplate<LinuxDmabufManager>
+    , public QtWayland::zwp_linux_dmabuf_v1 {
+public:
+	explicit LinuxDmabufManager(WlBufferManagerPrivate* manager);
+
+	[[nodiscard]] WlBuffer* createDmabuf(const WlBufferRequest& request);
+
+	[[nodiscard]] WlBuffer* createDmabuf(
+	    GbmDeviceHandle& device,
+	    uint32_t format,
+	    const LinuxDmabufModifiers& modifiers,
+	    uint32_t width,
+	    uint32_t height
+	);
+
+private:
+	struct SharedGbmDevice {
+		dev_t handle = 0;
+		std::string renderNode;
+		gbm_device* device = nullptr;
+		qsizetype refcount = 0;
+	};
+
+	void feedbackDone();
+
+	GbmDeviceHandle getGbmDevice(dev_t handle);
+	void unrefGbmDevice(gbm_device* device);
+	GbmDeviceHandle dupHandle(const GbmDeviceHandle& handle);
+
+	QList<LinuxDmabufTranche> tranches;
+	QList<SharedGbmDevice> gbmDevices;
+	WlBufferManagerPrivate* manager;
+
+	friend class LinuxDmabufFeedback;
+	friend class GbmDeviceHandle;
+};
+
+} // namespace qs::wayland::buffer::dmabuf
diff --git a/src/wayland/buffer/manager.cpp b/src/wayland/buffer/manager.cpp
new file mode 100644
index 00000000..dde71a88
--- /dev/null
+++ b/src/wayland/buffer/manager.cpp
@@ -0,0 +1,114 @@
+#include "manager.hpp"
+
+#include <qdebug.h>
+#include <qlogging.h>
+#include <qloggingcategory.h>
+#include <qmatrix4x4.h>
+#include <qnamespace.h>
+#include <qquickwindow.h>
+#include <qtenvironmentvariables.h>
+#include <qtmetamacros.h>
+#include <qvectornd.h>
+
+#include "dmabuf.hpp"
+#include "manager_p.hpp"
+#include "qsg.hpp"
+#include "shm.hpp"
+
+namespace qs::wayland::buffer {
+
+WlBuffer* WlBufferSwapchain::createBackbuffer(const WlBufferRequest& request, bool* newBuffer) {
+	auto& buffer = this->presentSecondBuffer ? this->buffer1 : this->buffer2;
+
+	if (!buffer || !buffer->isCompatible(request)) {
+		buffer.reset(WlBufferManager::instance()->createBuffer(request));
+		if (newBuffer) *newBuffer = true;
+	}
+
+	return buffer.get();
+}
+
+WlBufferManager::WlBufferManager(): p(new WlBufferManagerPrivate(this)) {}
+
+WlBufferManager::~WlBufferManager() { delete this->p; }
+
+WlBufferManager* WlBufferManager::instance() {
+	static auto* instance = new WlBufferManager();
+	return instance;
+}
+
+bool WlBufferManager::isReady() const { return this->p->mReady; }
+
+[[nodiscard]] WlBuffer* WlBufferManager::createBuffer(const WlBufferRequest& request) {
+	static const bool dmabufDisabled = qEnvironmentVariableIsSet("QS_DISABLE_DMABUF");
+
+	if (!dmabufDisabled) {
+		if (auto* buf = this->p->dmabuf.createDmabuf(request)) return buf;
+		qCWarning(shm::logShm) << "DMA buffer creation failed, falling back to SHM.";
+	}
+
+	return shm::ShmbufManager::createShmbuf(request);
+}
+
+WlBufferManagerPrivate::WlBufferManagerPrivate(WlBufferManager* manager)
+    : manager(manager)
+    , dmabuf(this) {}
+
+void WlBufferManagerPrivate::dmabufReady() {
+	this->mReady = true;
+	emit this->manager->ready();
+}
+
+WlBufferQSGDisplayNode::WlBufferQSGDisplayNode(QQuickWindow* window)
+    : window(window)
+    , imageNode(window->createImageNode()) {
+	this->appendChildNode(this->imageNode);
+}
+
+void WlBufferQSGDisplayNode::setRect(const QRectF& rect) {
+	const auto* buffer = (this->presentSecondBuffer ? this->buffer2 : this->buffer1).first;
+	if (!buffer) return;
+
+	auto matrix = QMatrix4x4();
+	auto center = rect.center();
+	auto centerX = static_cast<float>(center.x());
+	auto centerY = static_cast<float>(center.y());
+	matrix.translate(centerX, centerY);
+	buffer->transform.apply(matrix);
+	matrix.translate(-centerX, -centerY);
+
+	auto viewRect = matrix.mapRect(rect);
+	auto bufferSize = buffer->size().toSizeF();
+
+	bufferSize.scale(viewRect.width(), viewRect.height(), Qt::KeepAspectRatio);
+	this->imageNode->setRect(
+	    viewRect.x() + viewRect.width() / 2 - bufferSize.width() / 2,
+	    viewRect.y() + viewRect.height() / 2 - bufferSize.height() / 2,
+	    bufferSize.width(),
+	    bufferSize.height()
+	);
+
+	this->setMatrix(matrix);
+}
+
+void WlBufferQSGDisplayNode::syncSwapchain(const WlBufferSwapchain& swapchain) {
+	auto* buffer = swapchain.frontbuffer();
+	auto& texture = swapchain.presentSecondBuffer ? this->buffer2 : this->buffer1;
+
+	if (swapchain.presentSecondBuffer == this->presentSecondBuffer && texture.first == buffer) {
+		return;
+	}
+
+	this->presentSecondBuffer = swapchain.presentSecondBuffer;
+
+	if (texture.first == buffer) {
+		texture.second->sync(texture.first, this->window);
+	} else {
+		texture.first = buffer;
+		texture.second.reset(buffer->createQsgTexture(this->window));
+	}
+
+	this->imageNode->setTexture(texture.second->texture());
+}
+
+} // namespace qs::wayland::buffer
diff --git a/src/wayland/buffer/manager.hpp b/src/wayland/buffer/manager.hpp
new file mode 100644
index 00000000..c3f62a0d
--- /dev/null
+++ b/src/wayland/buffer/manager.hpp
@@ -0,0 +1,134 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+
+#include <qhash.h>
+#include <qlist.h>
+#include <qmatrix4x4.h>
+#include <qobject.h>
+#include <qtclasshelpermacros.h>
+#include <qtmetamacros.h>
+#include <qvariant.h>
+#include <wayland-client-protocol.h>
+#include <wayland-client.h>
+
+#include "../../core/stacklist.hpp"
+
+class QQuickWindow;
+
+namespace qs::wayland::buffer {
+
+class WlBufferManagerPrivate;
+class WlBufferQSGTexture;
+
+struct WlBufferTransform {
+	enum Transform : uint8_t {
+		Normal0 = 0,
+		Normal90 = 1,
+		Normal180 = 2,
+		Normal270 = 3,
+		Flipped0 = 4,
+		Flipped90 = 5,
+		Flipped180 = 6,
+		Flipped270 = 7,
+	} transform = Normal0;
+
+	WlBufferTransform() = default;
+	WlBufferTransform(uint8_t transform): transform(static_cast<Transform>(transform)) {}
+
+	[[nodiscard]] int degrees() const { return 90 * (this->transform & 0b11111011); }
+	[[nodiscard]] bool flip() const { return this->transform & 0b00000100; }
+
+	void apply(QMatrix4x4& matrix) const {
+		matrix.rotate(this->flip() ? 180 : 0, 0, 1, 0);
+		matrix.rotate(static_cast<float>(this->degrees()), 0, 0, 1);
+	}
+};
+
+struct WlBufferRequest {
+	uint32_t width = 0;
+	uint32_t height = 0;
+
+	struct DmaFormat {
+		DmaFormat() = default;
+		DmaFormat(uint32_t format): format(format) {}
+
+		uint32_t format = 0;
+		StackList<uint64_t, 10> modifiers;
+	};
+
+	struct {
+		StackList<uint32_t, 1> formats;
+	} shm;
+
+	struct {
+		dev_t device = 0;
+		StackList<DmaFormat, 1> formats;
+	} dmabuf;
+};
+
+class WlBuffer {
+public:
+	virtual ~WlBuffer() = default;
+	Q_DISABLE_COPY_MOVE(WlBuffer);
+
+	[[nodiscard]] virtual wl_buffer* buffer() const = 0;
+	[[nodiscard]] virtual QSize size() const = 0;
+	[[nodiscard]] virtual bool isCompatible(const WlBufferRequest& request) const = 0;
+	[[nodiscard]] operator bool() const { return this->buffer(); }
+
+	// Must be called from render thread.
+	[[nodiscard]] virtual WlBufferQSGTexture* createQsgTexture(QQuickWindow* window) const = 0;
+
+	WlBufferTransform transform;
+
+protected:
+	explicit WlBuffer() = default;
+};
+
+class WlBufferSwapchain {
+public:
+	[[nodiscard]] WlBuffer*
+	createBackbuffer(const WlBufferRequest& request, bool* newBuffer = nullptr);
+
+	void swapBuffers() { this->presentSecondBuffer = !this->presentSecondBuffer; }
+
+	[[nodiscard]] WlBuffer* backbuffer() const {
+		return this->presentSecondBuffer ? this->buffer1.get() : this->buffer2.get();
+	}
+
+	[[nodiscard]] WlBuffer* frontbuffer() const {
+		return this->presentSecondBuffer ? this->buffer2.get() : this->buffer1.get();
+	}
+
+private:
+	std::unique_ptr<WlBuffer> buffer1;
+	std::unique_ptr<WlBuffer> buffer2;
+	bool presentSecondBuffer = false;
+
+	friend class WlBufferQSGDisplayNode;
+};
+
+class WlBufferManager: public QObject {
+	Q_OBJECT;
+
+public:
+	~WlBufferManager() override;
+	Q_DISABLE_COPY_MOVE(WlBufferManager);
+
+	static WlBufferManager* instance();
+
+	[[nodiscard]] bool isReady() const;
+	[[nodiscard]] WlBuffer* createBuffer(const WlBufferRequest& request);
+
+signals:
+	void ready();
+
+private:
+	explicit WlBufferManager();
+
+	WlBufferManagerPrivate* p;
+};
+
+} // namespace qs::wayland::buffer
diff --git a/src/wayland/buffer/manager_p.hpp b/src/wayland/buffer/manager_p.hpp
new file mode 100644
index 00000000..55f5e667
--- /dev/null
+++ b/src/wayland/buffer/manager_p.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include "dmabuf.hpp"
+#include "manager.hpp"
+
+namespace qs::wayland::buffer {
+
+class WlBufferManagerPrivate {
+public:
+	explicit WlBufferManagerPrivate(WlBufferManager* manager);
+
+	void dmabufReady();
+
+	WlBufferManager* manager;
+	dmabuf::LinuxDmabufManager dmabuf;
+
+	bool mReady = false;
+};
+
+} // namespace qs::wayland::buffer
diff --git a/src/wayland/buffer/qsg.hpp b/src/wayland/buffer/qsg.hpp
new file mode 100644
index 00000000..c230cfee
--- /dev/null
+++ b/src/wayland/buffer/qsg.hpp
@@ -0,0 +1,45 @@
+#pragma once
+
+#include <memory>
+
+#include <qcontainerfwd.h>
+#include <qquickwindow.h>
+#include <qsgimagenode.h>
+#include <qsgnode.h>
+#include <qsgtexture.h>
+#include <qvectornd.h>
+
+#include "manager.hpp"
+
+namespace qs::wayland::buffer {
+
+// Interact only from QSG thread.
+class WlBufferQSGTexture {
+public:
+	virtual ~WlBufferQSGTexture() = default;
+	Q_DISABLE_COPY_MOVE(WlBufferQSGTexture);
+
+	[[nodiscard]] virtual QSGTexture* texture() const = 0;
+	virtual void sync(const WlBuffer* /*buffer*/, QQuickWindow* /*window*/) {}
+
+protected:
+	WlBufferQSGTexture() = default;
+};
+
+// Interact only from QSG thread.
+class WlBufferQSGDisplayNode: public QSGTransformNode {
+public:
+	explicit WlBufferQSGDisplayNode(QQuickWindow* window);
+
+	void syncSwapchain(const WlBufferSwapchain& swapchain);
+	void setRect(const QRectF& rect);
+
+private:
+	QQuickWindow* window;
+	QSGImageNode* imageNode;
+	QPair<WlBuffer*, std::unique_ptr<WlBufferQSGTexture>> buffer1;
+	QPair<WlBuffer*, std::unique_ptr<WlBufferQSGTexture>> buffer2;
+	bool presentSecondBuffer = false;
+};
+
+} // namespace qs::wayland::buffer
diff --git a/src/wayland/buffer/shm.cpp b/src/wayland/buffer/shm.cpp
new file mode 100644
index 00000000..8973cdfb
--- /dev/null
+++ b/src/wayland/buffer/shm.cpp
@@ -0,0 +1,91 @@
+#include "shm.hpp"
+#include <algorithm>
+#include <memory>
+
+#include <private/qwaylanddisplay_p.h>
+#include <private/qwaylandintegration_p.h>
+#include <private/qwaylandshm_p.h>
+#include <private/qwaylandshmbackingstore_p.h>
+#include <qdebug.h>
+#include <qlogging.h>
+#include <qloggingcategory.h>
+#include <qquickwindow.h>
+#include <qsize.h>
+#include <wayland-client-protocol.h>
+
+#include "manager.hpp"
+
+namespace qs::wayland::buffer::shm {
+
+Q_LOGGING_CATEGORY(logShm, "quickshell.wayland.buffer.shm", QtWarningMsg);
+
+bool WlShmBuffer::isCompatible(const WlBufferRequest& request) const {
+	if (QSize(static_cast<int>(request.width), static_cast<int>(request.height)) != this->size()) {
+		return false;
+	}
+
+	auto matchingFormat = std::ranges::find(request.shm.formats, this->format);
+	return matchingFormat != request.shm.formats.end();
+}
+
+QDebug& operator<<(QDebug& debug, const WlShmBuffer* buffer) {
+	auto saver = QDebugStateSaver(debug);
+	debug.nospace();
+
+	if (buffer) {
+		auto fmt = QtWaylandClient::QWaylandShm::formatFrom(
+		    static_cast<::wl_shm_format>(buffer->format) // NOLINT
+		);
+
+		debug << "WlShmBuffer(" << static_cast<const void*>(buffer) << ", size=" << buffer->size()
+		      << ", format=" << fmt << ')';
+	} else {
+		debug << "WlShmBuffer(0x0)";
+	}
+
+	return debug;
+}
+
+WlShmBuffer::~WlShmBuffer() { qCDebug(logShm) << "Destroyed" << this; }
+
+WlBufferQSGTexture* WlShmBuffer::createQsgTexture(QQuickWindow* window) const {
+	auto* texture = new WlShmBufferQSGTexture();
+
+	// If the QWaylandShmBuffer is destroyed before the QSGTexture, we'll hit a UAF
+	// in the render thread.
+	texture->shmBuffer = this->shmBuffer;
+
+	texture->qsgTexture.reset(window->createTextureFromImage(*this->shmBuffer->image()));
+	texture->sync(this, window);
+	return texture;
+}
+
+void WlShmBufferQSGTexture::sync(const WlBuffer* /*unused*/, QQuickWindow* window) {
+	// This is both dumb and expensive. We should use an RHI texture and render images into
+	// it more intelligently, but shm buffers are already a horribly slow fallback path,
+	// to the point where it barely matters.
+	this->qsgTexture.reset(window->createTextureFromImage(*this->shmBuffer->image()));
+}
+
+WlBuffer* ShmbufManager::createShmbuf(const WlBufferRequest& request) {
+	if (request.shm.formats.isEmpty()) return nullptr;
+
+	static const auto* waylandIntegration = QtWaylandClient::QWaylandIntegration::instance();
+	auto* display = waylandIntegration->display();
+
+	// Its probably fine...
+	auto format = request.shm.formats[0];
+
+	auto* buffer = new WlShmBuffer(
+	    new QtWaylandClient::QWaylandShmBuffer(
+	        display,
+	        QSize(static_cast<int>(request.width), static_cast<int>(request.height)),
+	        QtWaylandClient::QWaylandShm::formatFrom(static_cast<::wl_shm_format>(format)) // NOLINT
+	    ),
+	    format
+	);
+
+	qCDebug(logShm) << "Created shmbuf" << buffer;
+	return buffer;
+}
+} // namespace qs::wayland::buffer::shm
diff --git a/src/wayland/buffer/shm.hpp b/src/wayland/buffer/shm.hpp
new file mode 100644
index 00000000..12af26e3
--- /dev/null
+++ b/src/wayland/buffer/shm.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include <memory>
+
+#include <private/qwaylandshmbackingstore_p.h>
+#include <qquickwindow.h>
+#include <qsgtexture.h>
+#include <qsize.h>
+#include <qtclasshelpermacros.h>
+#include <wayland-client-protocol.h>
+
+#include "manager.hpp"
+#include "qsg.hpp"
+
+namespace qs::wayland::buffer::shm {
+
+Q_DECLARE_LOGGING_CATEGORY(logShm);
+
+class WlShmBuffer: public WlBuffer {
+public:
+	~WlShmBuffer() override;
+	Q_DISABLE_COPY_MOVE(WlShmBuffer);
+
+	[[nodiscard]] wl_buffer* buffer() const override { return this->shmBuffer->buffer(); }
+	[[nodiscard]] QSize size() const override { return this->shmBuffer->size(); }
+	[[nodiscard]] bool isCompatible(const WlBufferRequest& request) const override;
+	[[nodiscard]] WlBufferQSGTexture* createQsgTexture(QQuickWindow* window) const override;
+
+private:
+	WlShmBuffer(QtWaylandClient::QWaylandShmBuffer* shmBuffer, uint32_t format)
+	    : shmBuffer(shmBuffer)
+	    , format(format) {}
+
+	std::shared_ptr<QtWaylandClient::QWaylandShmBuffer> shmBuffer;
+	uint32_t format;
+
+	friend class WlShmBufferQSGTexture;
+	friend class ShmbufManager;
+	friend QDebug& operator<<(QDebug& debug, const WlShmBuffer* buffer);
+};
+
+QDebug& operator<<(QDebug& debug, const WlShmBuffer* buffer);
+
+class WlShmBufferQSGTexture: public WlBufferQSGTexture {
+public:
+	[[nodiscard]] QSGTexture* texture() const override { return this->qsgTexture.get(); }
+	void sync(const WlBuffer* buffer, QQuickWindow* window) override;
+
+private:
+	WlShmBufferQSGTexture() = default;
+
+	std::shared_ptr<QtWaylandClient::QWaylandShmBuffer> shmBuffer;
+	std::unique_ptr<QSGTexture> qsgTexture;
+
+	friend class WlShmBuffer;
+};
+
+class ShmbufManager {
+public:
+	[[nodiscard]] static WlBuffer* createShmbuf(const WlBufferRequest& request);
+};
+
+} // namespace qs::wayland::buffer::shm
diff --git a/src/wayland/module.md b/src/wayland/module.md
index d6376e39..db9bfb5a 100644
--- a/src/wayland/module.md
+++ b/src/wayland/module.md
@@ -5,5 +5,6 @@ headers = [
 	"wlr_layershell.hpp",
 	"session_lock.hpp",
 	"toplevel_management/qml.hpp",
+	"screencopy/view.hpp",
 ]
 -----
diff --git a/src/wayland/screencopy/CMakeLists.txt b/src/wayland/screencopy/CMakeLists.txt
new file mode 100644
index 00000000..97c4209e
--- /dev/null
+++ b/src/wayland/screencopy/CMakeLists.txt
@@ -0,0 +1,42 @@
+qt_add_library(quickshell-wayland-screencopy STATIC
+	manager.cpp
+	view.cpp
+)
+
+qt_add_qml_module(quickshell-wayland-screencopy
+	URI Quickshell.Wayland._Screencopy
+	VERSION 0.1
+	DEPENDENCIES QtQuick
+)
+
+install_qml_module(quickshell-wayland-screencopy)
+
+set(SCREENCOPY_MODULES)
+
+if (SCREENCOPY_ICC)
+	add_subdirectory(image_copy_capture)
+	list(APPEND SCREENCOPY_MODULES quickshell-wayland-screencopy-icc)
+endif()
+
+if (SCREENCOPY_WLR)
+	add_subdirectory(wlr_screencopy)
+	list(APPEND SCREENCOPY_MODULES quickshell-wayland-screencopy-wlr)
+endif()
+
+if (SCREENCOPY_HYPRLAND_TOPLEVEL)
+	add_subdirectory(hyprland_screencopy)
+	list(APPEND SCREENCOPY_MODULES quickshell-wayland-screencopy-hyprland)
+endif()
+
+configure_file(build.hpp.in build.hpp @ONLY)
+target_include_directories(quickshell-wayland-screencopy PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
+
+target_link_libraries(quickshell-wayland-screencopy PRIVATE
+	Qt::Quick Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+	quickshell-wayland-buffer
+	${SCREENCOPY_MODULES}
+)
+
+qs_module_pch(quickshell-wayland-screencopy SET large)
+
+target_link_libraries(quickshell PRIVATE quickshell-wayland-screencopyplugin)
diff --git a/src/wayland/screencopy/build.hpp.in b/src/wayland/screencopy/build.hpp.in
new file mode 100644
index 00000000..9276daaa
--- /dev/null
+++ b/src/wayland/screencopy/build.hpp.in
@@ -0,0 +1,6 @@
+#pragma once
+// NOLINTBEGIN
+#cmakedefine01 SCREENCOPY_ICC
+#cmakedefine01 SCREENCOPY_WLR
+#cmakedefine01 SCREENCOPY_HYPRLAND_TOPLEVEL
+// NOLINTEND
diff --git a/src/wayland/screencopy/hyprland_screencopy/CMakeLists.txt b/src/wayland/screencopy/hyprland_screencopy/CMakeLists.txt
new file mode 100644
index 00000000..d06a91e5
--- /dev/null
+++ b/src/wayland/screencopy/hyprland_screencopy/CMakeLists.txt
@@ -0,0 +1,16 @@
+qt_add_library(quickshell-wayland-screencopy-hyprland STATIC
+	hyprland_screencopy.cpp
+)
+
+wl_proto(wlp-hyprland-screencopy hyprland-toplevel-export-v1 "${CMAKE_CURRENT_SOURCE_DIR}")
+
+target_link_libraries(quickshell-wayland-screencopy-hyprland PRIVATE
+	Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+	Qt::Quick # for pch
+)
+
+target_link_libraries(quickshell-wayland-screencopy-hyprland PUBLIC
+	wlp-hyprland-screencopy wlp-foreign-toplevel
+)
+
+qs_pch(quickshell-wayland-screencopy-hyprland SET large)
diff --git a/src/wayland/screencopy/hyprland_screencopy/hyprland-toplevel-export-v1.xml b/src/wayland/screencopy/hyprland_screencopy/hyprland-toplevel-export-v1.xml
new file mode 100644
index 00000000..b1185aa5
--- /dev/null
+++ b/src/wayland/screencopy/hyprland_screencopy/hyprland-toplevel-export-v1.xml
@@ -0,0 +1,228 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="hyprland_toplevel_export_v1">
+  <copyright>
+    Copyright © 2022 Vaxry
+    All rights reserved.
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+
+    1. Redistributions of source code must retain the above copyright notice, this
+       list of conditions and the following disclaimer.
+
+    2. Redistributions in binary form must reproduce the above copyright notice,
+       this list of conditions and the following disclaimer in the documentation
+       and/or other materials provided with the distribution.
+
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+    DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+    FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+    DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+    SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+    OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+  </copyright>
+
+  <description summary="capturing the contents of toplevel windows">
+    This protocol allows clients to ask for exporting another toplevel's
+    surface(s) to a buffer.
+
+    Particularly useful for sharing a single window.
+  </description>
+
+  <interface name="hyprland_toplevel_export_manager_v1" version="2">
+    <description summary="manager to inform clients and begin capturing">
+      This object is a manager which offers requests to start capturing from a
+      source.
+    </description>
+
+    <request name="capture_toplevel">
+      <description summary="capture a toplevel">
+        Capture the next frame of a toplevel. (window)
+
+        The captured frame will not contain any server-side decorations and will
+        ignore the compositor-set geometry, like e.g. rounded corners.
+
+        It will contain all the subsurfaces and popups, however the latter will be clipped
+        to the geometry of the base surface.
+
+        The handle parameter refers to the address of the window as seen in `hyprctl clients`.
+        For example, for d161e7b0 it would be 3512854448.
+      </description>
+      <arg name="frame" type="new_id" interface="hyprland_toplevel_export_frame_v1"/>
+      <arg name="overlay_cursor" type="int"
+        summary="composite cursor onto the frame"/>
+      <arg name="handle" type="uint" summary="the handle of the toplevel (window) to be captured"/>
+    </request>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the manager">
+        All objects created by the manager will still remain valid, until their
+        appropriate destroy request has been called.
+      </description>
+    </request>
+
+    <!-- Version 2 -->
+    <request name="capture_toplevel_with_wlr_toplevel_handle" since="2">
+      <description summary="capture a toplevel">
+        Same as capture_toplevel, but with a zwlr_foreign_toplevel_handle_v1 handle.
+      </description>
+      <arg name="frame" type="new_id" interface="hyprland_toplevel_export_frame_v1"/>
+      <arg name="overlay_cursor" type="int"
+        summary="composite cursor onto the frame"/>
+      <arg name="handle" type="object" interface="zwlr_foreign_toplevel_handle_v1" allow-null="false" summary="the zwlr_foreign_toplevel_handle_v1 handle of the toplevel to be captured"/>
+    </request>
+    <!-- End Version 2 -->
+  </interface>
+
+  <interface name="hyprland_toplevel_export_frame_v1" version="2">
+    <description summary="a frame ready for copy">
+      This object represents a single frame.
+
+      When created, a series of buffer events will be sent, each representing a
+      supported buffer type. The "buffer_done" event is sent afterwards to
+      indicate that all supported buffer types have been enumerated. The client
+      will then be able to send a "copy" request. If the capture is successful,
+      the compositor will send a "flags" followed by a "ready" event.
+
+      wl_shm buffers are always supported, ie. the "buffer" event is guaranteed to be sent.
+
+      If the capture failed, the "failed" event is sent. This can happen anytime
+      before the "ready" event.
+
+      Once either a "ready" or a "failed" event is received, the client should
+      destroy the frame.
+    </description>
+
+    <event name="buffer">
+      <description summary="wl_shm buffer information">
+        Provides information about wl_shm buffer parameters that need to be
+        used for this frame. This event is sent once after the frame is created
+        if wl_shm buffers are supported.
+      </description>
+      <arg name="format" type="uint" enum="wl_shm.format" summary="buffer format"/>
+      <arg name="width" type="uint" summary="buffer width"/>
+      <arg name="height" type="uint" summary="buffer height"/>
+      <arg name="stride" type="uint" summary="buffer stride"/>
+    </event>
+
+    <request name="copy">
+      <description summary="copy the frame">
+        Copy the frame to the supplied buffer. The buffer must have the
+        correct size, see hyprland_toplevel_export_frame_v1.buffer and
+        hyprland_toplevel_export_frame_v1.linux_dmabuf. The buffer needs to have a
+        supported format.
+
+        If the frame is successfully copied, a "flags" and a "ready" event is
+        sent. Otherwise, a "failed" event is sent.
+
+        This event will wait for appropriate damage to be copied, unless the ignore_damage
+        arg is set to a non-zero value.
+      </description>
+      <arg name="buffer" type="object" interface="wl_buffer"/>
+      <arg name="ignore_damage" type="int"/>
+    </request>
+
+    <event name="damage">
+      <description summary="carries the coordinates of the damaged region">
+        This event is sent right before the ready event when ignore_damage was
+        not set. It may be generated multiple times for each copy
+        request.
+
+        The arguments describe a box around an area that has changed since the
+        last copy request that was derived from the current screencopy manager
+        instance.
+
+        The union of all regions received between the call to copy
+        and a ready event is the total damage since the prior ready event.
+      </description>
+      <arg name="x" type="uint" summary="damaged x coordinates"/>
+      <arg name="y" type="uint" summary="damaged y coordinates"/>
+      <arg name="width" type="uint" summary="current width"/>
+      <arg name="height" type="uint" summary="current height"/>
+    </event>
+
+    <enum name="error">
+      <entry name="already_used" value="0"
+        summary="the object has already been used to copy a wl_buffer"/>
+      <entry name="invalid_buffer" value="1"
+        summary="buffer attributes are invalid"/>
+    </enum>
+
+    <enum name="flags" bitfield="true">
+      <entry name="y_invert" value="1" summary="contents are y-inverted"/>
+    </enum>
+
+    <event name="flags">
+      <description summary="frame flags">
+        Provides flags about the frame. This event is sent once before the
+        "ready" event.
+      </description>
+      <arg name="flags" type="uint" enum="flags" summary="frame flags"/>
+    </event>
+
+    <event name="ready">
+      <description summary="indicates frame is available for reading">
+        Called as soon as the frame is copied, indicating it is available
+        for reading. This event includes the time at which presentation happened
+        at.
+
+        The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples,
+        each component being an unsigned 32-bit value. Whole seconds are in
+        tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo,
+        and the additional fractional part in tv_nsec as nanoseconds. Hence,
+        for valid timestamps tv_nsec must be in [0, 999999999]. The seconds part
+        may have an arbitrary offset at start.
+
+        After receiving this event, the client should destroy the object.
+      </description>
+      <arg name="tv_sec_hi" type="uint"
+           summary="high 32 bits of the seconds part of the timestamp"/>
+      <arg name="tv_sec_lo" type="uint"
+           summary="low 32 bits of the seconds part of the timestamp"/>
+      <arg name="tv_nsec" type="uint"
+           summary="nanoseconds part of the timestamp"/>
+    </event>
+
+    <event name="failed">
+      <description summary="frame copy failed">
+        This event indicates that the attempted frame copy has failed.
+
+        After receiving this event, the client should destroy the object.
+      </description>
+    </event>
+
+    <request name="destroy" type="destructor">
+      <description summary="delete this object, used or not">
+        Destroys the frame. This request can be sent at any time by the client.
+      </description>
+    </request>
+
+    <event name="linux_dmabuf">
+      <description summary="linux-dmabuf buffer information">
+        Provides information about linux-dmabuf buffer parameters that need to
+        be used for this frame. This event is sent once after the frame is
+        created if linux-dmabuf buffers are supported.
+      </description>
+      <arg name="format" type="uint" summary="fourcc pixel format"/>
+      <arg name="width" type="uint" summary="buffer width"/>
+      <arg name="height" type="uint" summary="buffer height"/>
+    </event>
+
+    <event name="buffer_done">
+      <description summary="all buffer types reported">
+        This event is sent once after all buffer events have been sent.
+
+        The client should proceed to create a buffer of one of the supported
+        types, and send a "copy" request.
+      </description>
+    </event>
+  </interface>
+</protocol>
diff --git a/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp
new file mode 100644
index 00000000..457f1055
--- /dev/null
+++ b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.cpp
@@ -0,0 +1,122 @@
+#include "hyprland_screencopy.hpp"
+#include <cstdint>
+
+#include <qlogging.h>
+#include <qloggingcategory.h>
+#include <qobject.h>
+#include <qtmetamacros.h>
+#include <qwaylandclientextension.h>
+#include <wayland-hyprland-toplevel-export-v1-client-protocol.h>
+
+#include "../../toplevel_management/handle.hpp"
+#include "../manager.hpp"
+#include "hyprland_screencopy_p.hpp"
+
+namespace qs::wayland::screencopy::hyprland {
+
+namespace {
+Q_LOGGING_CATEGORY(logScreencopy, "quickshell.wayland.screencopy.hyprland", QtWarningMsg);
+}
+
+HyprlandScreencopyManager::HyprlandScreencopyManager(): QWaylandClientExtensionTemplate(2) {
+	this->initialize();
+}
+
+HyprlandScreencopyManager* HyprlandScreencopyManager::instance() {
+	static auto* instance = new HyprlandScreencopyManager();
+	return instance;
+}
+
+ScreencopyContext* HyprlandScreencopyManager::captureToplevel(
+    toplevel_management::impl::ToplevelHandle* handle,
+    bool paintCursors
+) {
+	return new HyprlandScreencopyContext(this, handle, paintCursors);
+}
+
+HyprlandScreencopyContext::HyprlandScreencopyContext(
+    HyprlandScreencopyManager* manager,
+    toplevel_management::impl::ToplevelHandle* handle,
+    bool paintCursors
+)
+    : manager(manager)
+    , handle(handle)
+    , paintCursors(paintCursors) {
+	QObject::connect(
+	    handle,
+	    &QObject::destroyed,
+	    this,
+	    &HyprlandScreencopyContext::onToplevelDestroyed
+	);
+}
+
+HyprlandScreencopyContext::~HyprlandScreencopyContext() {
+	if (this->object()) this->destroy();
+}
+
+void HyprlandScreencopyContext::onToplevelDestroyed() {
+	qCWarning(logScreencopy) << "Toplevel destroyed while recording. Stopping" << this;
+	if (this->object()) this->destroy();
+	emit this->stopped();
+}
+
+void HyprlandScreencopyContext::captureFrame() {
+	if (this->object()) return;
+
+	this->init(this->manager->capture_toplevel_with_wlr_toplevel_handle(
+	    this->paintCursors ? 1 : 0,
+	    this->handle->object()
+	));
+}
+
+void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_buffer(
+    uint32_t format,
+    uint32_t width,
+    uint32_t height,
+    uint32_t /*stride*/
+) {
+	// While different sizes can technically be requested, that would be insane.
+	this->request.width = width;
+	this->request.height = height;
+	this->request.shm.formats.push(format);
+}
+
+void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_linux_dmabuf(
+    uint32_t format,
+    uint32_t width,
+    uint32_t height
+) {
+	// While different sizes can technically be requested, that would be insane.
+	this->request.width = width;
+	this->request.height = height;
+	this->request.dmabuf.formats.push(format);
+}
+
+void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_flags(uint32_t flags) {
+	if (flags & HYPRLAND_TOPLEVEL_EXPORT_FRAME_V1_FLAGS_Y_INVERT) {
+		this->mSwapchain.backbuffer()->transform = buffer::WlBufferTransform::Flipped180;
+	}
+}
+
+void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_buffer_done() {
+	auto* backbuffer = this->mSwapchain.createBackbuffer(this->request);
+	this->copy(backbuffer->buffer(), this->copiedFirstFrame ? 0 : 1);
+}
+
+void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_ready(
+    uint32_t /*tvSecHi*/,
+    uint32_t /*tvSecLo*/,
+    uint32_t /*tvNsec*/
+) {
+	this->destroy();
+	this->copiedFirstFrame = true;
+	this->mSwapchain.swapBuffers();
+	emit this->frameCaptured();
+}
+
+void HyprlandScreencopyContext::hyprland_toplevel_export_frame_v1_failed() {
+	qCWarning(logScreencopy) << "Ending recording due to screencopy failure for" << this;
+	emit this->stopped();
+}
+
+} // namespace qs::wayland::screencopy::hyprland
diff --git a/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.hpp b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.hpp
new file mode 100644
index 00000000..fbd08c54
--- /dev/null
+++ b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy.hpp
@@ -0,0 +1,26 @@
+#pragma once
+
+#include <qwayland-hyprland-toplevel-export-v1.h>
+#include <qwaylandclientextension.h>
+
+#include "../../toplevel_management/handle.hpp"
+#include "../manager.hpp"
+
+namespace qs::wayland::screencopy::hyprland {
+
+class HyprlandScreencopyManager
+    : public QWaylandClientExtensionTemplate<HyprlandScreencopyManager>
+    , public QtWayland::hyprland_toplevel_export_manager_v1 {
+public:
+	ScreencopyContext*
+	captureToplevel(toplevel_management::impl::ToplevelHandle* handle, bool paintCursors);
+
+	static HyprlandScreencopyManager* instance();
+
+private:
+	explicit HyprlandScreencopyManager();
+
+	friend class HyprlandScreencopyContext;
+};
+
+} // namespace qs::wayland::screencopy::hyprland
diff --git a/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy_p.hpp b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy_p.hpp
new file mode 100644
index 00000000..199390ec
--- /dev/null
+++ b/src/wayland/screencopy/hyprland_screencopy/hyprland_screencopy_p.hpp
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <qtclasshelpermacros.h>
+#include <qwayland-hyprland-toplevel-export-v1.h>
+
+#include "../../toplevel_management/handle.hpp"
+#include "../manager.hpp"
+
+namespace qs::wayland::screencopy::hyprland {
+
+class HyprlandScreencopyManager;
+
+class HyprlandScreencopyContext
+    : public ScreencopyContext
+    , public QtWayland::hyprland_toplevel_export_frame_v1 {
+public:
+	explicit HyprlandScreencopyContext(
+	    HyprlandScreencopyManager* manager,
+	    toplevel_management::impl::ToplevelHandle* handle,
+	    bool paintCursors
+	);
+
+	~HyprlandScreencopyContext() override;
+	Q_DISABLE_COPY_MOVE(HyprlandScreencopyContext);
+
+	void captureFrame() override;
+
+protected:
+	// clang-format off
+	void hyprland_toplevel_export_frame_v1_buffer(uint32_t format, uint32_t width, uint32_t height, uint32_t stride) override;
+	void hyprland_toplevel_export_frame_v1_linux_dmabuf(uint32_t format, uint32_t width, uint32_t height) override;
+	void hyprland_toplevel_export_frame_v1_flags(uint32_t flags) override;
+	void hyprland_toplevel_export_frame_v1_buffer_done() override;
+	void hyprland_toplevel_export_frame_v1_ready(uint32_t tvSecHi, uint32_t tvSecLo, uint32_t tvNsec) override;
+	void hyprland_toplevel_export_frame_v1_failed() override;
+	// clang-format on
+
+private slots:
+	void onToplevelDestroyed();
+
+private:
+	HyprlandScreencopyManager* manager;
+	buffer::WlBufferRequest request;
+	bool copiedFirstFrame = false;
+
+	toplevel_management::impl::ToplevelHandle* handle;
+	bool paintCursors;
+};
+
+} // namespace qs::wayland::screencopy::hyprland
diff --git a/src/wayland/screencopy/image_copy_capture/CMakeLists.txt b/src/wayland/screencopy/image_copy_capture/CMakeLists.txt
new file mode 100644
index 00000000..954fdda3
--- /dev/null
+++ b/src/wayland/screencopy/image_copy_capture/CMakeLists.txt
@@ -0,0 +1,19 @@
+qt_add_library(quickshell-wayland-screencopy-icc STATIC
+	image_copy_capture.cpp
+)
+
+wl_proto(wlp-ext-foreign-toplevel ext-foreign-toplevel-list-v1 "${WAYLAND_PROTOCOLS}/staging/ext-foreign-toplevel-list")
+wl_proto(wlp-image-copy-capture ext-image-copy-capture-v1 "${WAYLAND_PROTOCOLS}/staging/ext-image-copy-capture")
+wl_proto(wlp-image-capture-source ext-image-capture-source-v1 "${WAYLAND_PROTOCOLS}/staging/ext-image-capture-source")
+
+target_link_libraries(quickshell-wayland-screencopy-icc PRIVATE
+	Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+	Qt::Quick # for pch
+)
+
+target_link_libraries(quickshell-wayland-screencopy-icc PUBLIC
+	wlp-image-copy-capture wlp-image-capture-source
+	wlp-ext-foreign-toplevel # required for capture source to build
+)
+
+qs_pch(quickshell-wayland-screencopy-icc SET large)
diff --git a/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp b/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp
new file mode 100644
index 00000000..649b111b
--- /dev/null
+++ b/src/wayland/screencopy/image_copy_capture/image_copy_capture.cpp
@@ -0,0 +1,225 @@
+#include "image_copy_capture.hpp"
+#include <cstdint>
+
+#include <private/qwaylandscreen_p.h>
+#include <qlogging.h>
+#include <qloggingcategory.h>
+#include <qrect.h>
+#include <qscreen.h>
+#include <qtmetamacros.h>
+#include <qwayland-ext-image-copy-capture-v1.h>
+#include <qwaylandclientextension.h>
+#include <sys/types.h>
+#include <wayland-ext-image-copy-capture-v1-client-protocol.h>
+#include <wayland-util.h>
+
+#include "../manager.hpp"
+#include "image_copy_capture_p.hpp"
+
+namespace qs::wayland::screencopy::icc {
+
+namespace {
+Q_LOGGING_CATEGORY(logIcc, "quickshell.wayland.screencopy.icc", QtWarningMsg);
+}
+
+using IccCaptureSession = QtWayland::ext_image_copy_capture_session_v1;
+using IccCaptureFrame = QtWayland::ext_image_copy_capture_frame_v1;
+
+IccScreencopyContext::IccScreencopyContext(::ext_image_copy_capture_session_v1* session)
+    : IccCaptureSession(session) {}
+
+IccScreencopyContext::~IccScreencopyContext() {
+	if (this->IccCaptureSession::object()) {
+		this->IccCaptureSession::destroy();
+	}
+
+	if (this->IccCaptureFrame::object()) {
+		this->IccCaptureFrame::destroy();
+	}
+}
+
+void IccScreencopyContext::captureFrame() {
+	if (this->IccCaptureFrame::object() || this->capturePending) return;
+
+	if (this->statePending) this->capturePending = true;
+	else this->doCapture();
+}
+
+void IccScreencopyContext::ext_image_copy_capture_session_v1_buffer_size(
+    uint32_t width,
+    uint32_t height
+) {
+	this->clearOldState();
+
+	this->request.width = width;
+	this->request.height = height;
+}
+
+void IccScreencopyContext::ext_image_copy_capture_session_v1_shm_format(uint32_t format) {
+	this->clearOldState();
+
+	this->request.shm.formats.push(format);
+}
+
+void IccScreencopyContext::ext_image_copy_capture_session_v1_dmabuf_device(wl_array* device) {
+	this->clearOldState();
+
+	if (device->size != sizeof(dev_t)) {
+		qCFatal(logIcc) << "The size of dev_t used by the compositor and quickshell is mismatched. Try "
+		                   "recompiling both.";
+	}
+
+	this->request.dmabuf.device = *reinterpret_cast<dev_t*>(device->data);
+}
+
+void IccScreencopyContext::ext_image_copy_capture_session_v1_dmabuf_format(
+    uint32_t format,
+    wl_array* modifiers
+) {
+	this->clearOldState();
+
+	auto* modifierArray = reinterpret_cast<uint64_t*>(modifiers->data);
+	auto modifierCount = modifiers->size / sizeof(uint64_t);
+
+	auto reqFormat = buffer::WlBufferRequest::DmaFormat(format);
+
+	for (uint16_t i = 0; i != modifierCount; i++) {
+		reqFormat.modifiers.push(modifierArray[i]); // NOLINT
+	}
+
+	this->request.dmabuf.formats.push(reqFormat);
+}
+
+void IccScreencopyContext::ext_image_copy_capture_session_v1_done() {
+	this->statePending = false;
+
+	if (this->capturePending) {
+		this->doCapture();
+	}
+}
+
+void IccScreencopyContext::ext_image_copy_capture_session_v1_stopped() {
+	qCInfo(logIcc) << "Ending recording due to screencopy stop for" << this;
+	emit this->stopped();
+}
+
+void IccScreencopyContext::clearOldState() {
+	if (!this->statePending) {
+		this->request = buffer::WlBufferRequest();
+		this->statePending = true;
+	}
+}
+
+void IccScreencopyContext::doCapture() {
+	this->capturePending = false;
+
+	auto newBuffer = false;
+	auto* backbuffer = this->mSwapchain.createBackbuffer(this->request, &newBuffer);
+
+	this->IccCaptureFrame::init(this->IccCaptureSession::create_frame());
+	this->IccCaptureFrame::attach_buffer(backbuffer->buffer());
+
+	if (newBuffer) {
+		// If the buffer was replaced, it will be blank and the compositor needs
+		// to repaint the whole thing.
+		this->IccCaptureFrame::damage_buffer(
+		    0,
+		    0,
+		    static_cast<int>(this->request.width),
+		    static_cast<int>(this->request.height)
+		);
+
+		// We don't care about partial damage if the whole buffer was replaced.
+		this->lastDamage = QRect();
+	} else if (!this->lastDamage.isEmpty()) {
+		// If buffers were swapped between the last frame and the current one, request a repaint
+		// of the backbuffer in the same places that changes to the frontbuffer were recorded.
+		this->IccCaptureFrame::damage_buffer(
+		    this->lastDamage.x(),
+		    this->lastDamage.y(),
+		    this->lastDamage.width(),
+		    this->lastDamage.height()
+		);
+
+		// We don't need to do this more than once per buffer swap.
+		this->lastDamage = QRect();
+	}
+
+	this->IccCaptureFrame::capture();
+}
+
+void IccScreencopyContext::ext_image_copy_capture_frame_v1_transform(uint32_t transform) {
+	this->mSwapchain.backbuffer()->transform = transform;
+}
+
+void IccScreencopyContext::ext_image_copy_capture_frame_v1_damage(
+    int32_t x,
+    int32_t y,
+    int32_t width,
+    int32_t height
+) {
+	this->damage = this->damage.united(QRect(x, y, width, height));
+}
+
+void IccScreencopyContext::ext_image_copy_capture_frame_v1_ready() {
+	this->IccCaptureFrame::destroy();
+
+	this->mSwapchain.swapBuffers();
+	this->lastDamage = this->damage;
+	this->damage = QRect();
+
+	emit this->frameCaptured();
+}
+
+void IccScreencopyContext::ext_image_copy_capture_frame_v1_failed(uint32_t reason) {
+	switch (static_cast<IccCaptureFrame::failure_reason>(reason)) {
+	case IccCaptureFrame::failure_reason_buffer_constraints:
+		qFatal(logIcc) << "Got a buffer_constraints failure, however the buffer matches the last sent "
+		                  "size. There is a bug in quickshell or your compositor.";
+		break;
+	case IccCaptureFrame::failure_reason_stopped:
+		// Handled in the ExtCaptureSession handler.
+		break;
+	case IccCaptureFrame::failure_reason_unknown:
+		qCWarning(logIcc) << "Ending recording due to screencopy failure for" << this;
+		emit this->stopped();
+		break;
+	}
+}
+
+IccManager::IccManager(): QWaylandClientExtensionTemplate(1) { this->initialize(); }
+
+IccManager* IccManager::instance() {
+	static auto* instance = new IccManager();
+	return instance;
+}
+
+ScreencopyContext*
+IccManager::createSession(::ext_image_capture_source_v1* source, bool paintCursors) {
+	auto* session = this->create_session(
+	    source,
+	    paintCursors ? QtWayland::ext_image_copy_capture_manager_v1::options_paint_cursors : 0
+	);
+	return new IccScreencopyContext(session);
+}
+
+IccOutputSourceManager::IccOutputSourceManager(): QWaylandClientExtensionTemplate(1) {
+	this->initialize();
+}
+
+IccOutputSourceManager* IccOutputSourceManager::instance() {
+	static auto* instance = new IccOutputSourceManager();
+	return instance;
+}
+
+ScreencopyContext* IccOutputSourceManager::captureOutput(QScreen* screen, bool paintCursors) {
+	auto* waylandScreen = dynamic_cast<QtWaylandClient::QWaylandScreen*>(screen->handle());
+	if (!waylandScreen) return nullptr;
+
+	return IccManager::instance()->createSession(
+	    this->create_source(waylandScreen->output()),
+	    paintCursors
+	);
+}
+
+} // namespace qs::wayland::screencopy::icc
diff --git a/src/wayland/screencopy/image_copy_capture/image_copy_capture.hpp b/src/wayland/screencopy/image_copy_capture/image_copy_capture.hpp
new file mode 100644
index 00000000..93ba36c3
--- /dev/null
+++ b/src/wayland/screencopy/image_copy_capture/image_copy_capture.hpp
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <qscreen.h>
+#include <qwayland-ext-image-capture-source-v1.h>
+#include <qwayland-ext-image-copy-capture-v1.h>
+#include <qwaylandclientextension.h>
+
+#include "../manager.hpp"
+
+namespace qs::wayland::screencopy::icc {
+
+class IccManager
+    : public QWaylandClientExtensionTemplate<IccManager>
+    , public QtWayland::ext_image_copy_capture_manager_v1 {
+public:
+	ScreencopyContext* createSession(::ext_image_capture_source_v1* source, bool paintCursors);
+
+	static IccManager* instance();
+
+private:
+	explicit IccManager();
+};
+
+class IccOutputSourceManager
+    : public QWaylandClientExtensionTemplate<IccOutputSourceManager>
+    , public QtWayland::ext_output_image_capture_source_manager_v1 {
+public:
+	ScreencopyContext* captureOutput(QScreen* screen, bool paintCursors);
+
+	static IccOutputSourceManager* instance();
+
+private:
+	explicit IccOutputSourceManager();
+};
+
+} // namespace qs::wayland::screencopy::icc
diff --git a/src/wayland/screencopy/image_copy_capture/image_copy_capture_p.hpp b/src/wayland/screencopy/image_copy_capture/image_copy_capture_p.hpp
new file mode 100644
index 00000000..14f20675
--- /dev/null
+++ b/src/wayland/screencopy/image_copy_capture/image_copy_capture_p.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include <cstdint>
+
+#include <qrect.h>
+#include <qtclasshelpermacros.h>
+#include <qwayland-ext-image-copy-capture-v1.h>
+
+#include "../manager.hpp"
+
+namespace qs::wayland::screencopy::icc {
+
+class IccScreencopyContext
+    : public ScreencopyContext
+    , public QtWayland::ext_image_copy_capture_session_v1
+    , public QtWayland::ext_image_copy_capture_frame_v1 {
+
+public:
+	IccScreencopyContext(::ext_image_copy_capture_session_v1* session);
+	~IccScreencopyContext() override;
+	Q_DISABLE_COPY_MOVE(IccScreencopyContext);
+
+	void captureFrame() override;
+
+protected:
+	// clang-formt off
+	void ext_image_copy_capture_session_v1_buffer_size(uint32_t width, uint32_t height) override;
+	void ext_image_copy_capture_session_v1_shm_format(uint32_t format) override;
+	void ext_image_copy_capture_session_v1_dmabuf_device(wl_array* device) override;
+	void
+	ext_image_copy_capture_session_v1_dmabuf_format(uint32_t format, wl_array* modifiers) override;
+	void ext_image_copy_capture_session_v1_done() override;
+	void ext_image_copy_capture_session_v1_stopped() override;
+
+	void ext_image_copy_capture_frame_v1_transform(uint32_t transform) override;
+	void ext_image_copy_capture_frame_v1_damage(int32_t x, int32_t y, int32_t width, int32_t height)
+	    override;
+	void ext_image_copy_capture_frame_v1_ready() override;
+	void ext_image_copy_capture_frame_v1_failed(uint32_t reason) override;
+	// clang-formt on
+
+private:
+	void clearOldState();
+	void doCapture();
+
+	buffer::WlBufferRequest request;
+	bool statePending = true;
+	bool capturePending = false;
+	QRect damage;
+	QRect lastDamage;
+};
+
+} // namespace qs::wayland::screencopy::icc
diff --git a/src/wayland/screencopy/manager.cpp b/src/wayland/screencopy/manager.cpp
new file mode 100644
index 00000000..8345e314
--- /dev/null
+++ b/src/wayland/screencopy/manager.cpp
@@ -0,0 +1,56 @@
+#include "manager.hpp"
+
+#include <qobject.h>
+
+#include "build.hpp"
+
+#if SCREENCOPY_ICC || SCREENCOPY_WLR
+#include "../../core/qmlscreen.hpp"
+#endif
+
+#if SCREENCOPY_ICC
+#include "image_copy_capture/image_copy_capture.hpp"
+#endif
+
+#if SCREENCOPY_WLR
+#include "wlr_screencopy/wlr_screencopy.hpp"
+#endif
+
+#if SCREENCOPY_HYPRLAND_TOPLEVEL
+#include "../toplevel_management/qml.hpp"
+#include "hyprland_screencopy/hyprland_screencopy.hpp"
+#endif
+
+namespace qs::wayland::screencopy {
+
+ScreencopyContext* ScreencopyManager::createContext(QObject* object, bool paintCursors) {
+	if (auto* screen = qobject_cast<QuickshellScreenInfo*>(object)) {
+#if SCREENCOPY_ICC
+		{
+			auto* manager = icc::IccOutputSourceManager::instance();
+			if (manager->isActive()) {
+				return manager->captureOutput(screen->screen, paintCursors);
+			}
+		}
+#endif
+#if SCREENCOPY_WLR
+		{
+			auto* manager = wlr::WlrScreencopyManager::instance();
+			if (manager->isActive()) {
+				return manager->captureOutput(screen->screen, paintCursors);
+			}
+		}
+#endif
+#if SCREENCOPY_HYPRLAND_TOPLEVEL
+	} else if (auto* toplevel = qobject_cast<toplevel_management::Toplevel*>(object)) {
+		auto* manager = hyprland::HyprlandScreencopyManager::instance();
+		if (manager->isActive()) {
+			return manager->captureToplevel(toplevel->implHandle(), paintCursors);
+		}
+#endif
+	}
+
+	return nullptr;
+}
+
+} // namespace qs::wayland::screencopy
diff --git a/src/wayland/screencopy/manager.hpp b/src/wayland/screencopy/manager.hpp
new file mode 100644
index 00000000..f58e0052
--- /dev/null
+++ b/src/wayland/screencopy/manager.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <qobject.h>
+#include <qtclasshelpermacros.h>
+#include <qtmetamacros.h>
+
+#include "../buffer/manager.hpp"
+
+namespace qs::wayland::screencopy {
+
+class ScreencopyContext: public QObject {
+	Q_OBJECT;
+
+public:
+	[[nodiscard]] buffer::WlBufferSwapchain& swapchain() { return this->mSwapchain; }
+	virtual void captureFrame() = 0;
+
+signals:
+	void frameCaptured();
+	void stopped();
+
+protected:
+	ScreencopyContext() = default;
+
+	buffer::WlBufferSwapchain mSwapchain;
+};
+
+class ScreencopyManager {
+public:
+	static ScreencopyContext* createContext(QObject* object, bool paintCursors);
+};
+
+} // namespace qs::wayland::screencopy
diff --git a/src/wayland/screencopy/view.cpp b/src/wayland/screencopy/view.cpp
new file mode 100644
index 00000000..fe517352
--- /dev/null
+++ b/src/wayland/screencopy/view.cpp
@@ -0,0 +1,149 @@
+#include "view.hpp"
+
+#include <qobject.h>
+#include <qqmlinfo.h>
+#include <qquickitem.h>
+#include <qsize.h>
+#include <qtmetamacros.h>
+
+#include "../buffer/manager.hpp"
+#include "../buffer/qsg.hpp"
+#include "manager.hpp"
+
+namespace qs::wayland::screencopy {
+
+void ScreencopyView::setCaptureSource(QObject* captureSource) {
+	if (captureSource == this->mCaptureSource) return;
+	auto hadContext = this->context != nullptr;
+	this->destroyContext(false);
+
+	this->mCaptureSource = captureSource;
+
+	if (captureSource) {
+		QObject::connect(
+		    captureSource,
+		    &QObject::destroyed,
+		    this,
+		    &ScreencopyView::onCaptureSourceDestroyed
+		);
+
+		if (this->completed) this->createContext();
+	}
+
+	if (!this->context && hadContext) this->update();
+	emit this->captureSourceChanged();
+}
+
+void ScreencopyView::onCaptureSourceDestroyed() {
+	this->mCaptureSource = nullptr;
+	this->destroyContext();
+}
+
+void ScreencopyView::setPaintCursors(bool paintCursors) {
+	if (paintCursors == this->mPaintCursors) return;
+	this->mPaintCursors = paintCursors;
+	if (this->completed && this->context) this->createContext();
+	emit this->paintCursorsChanged();
+}
+
+void ScreencopyView::setLive(bool live) {
+	if (live == this->mLive) return;
+
+	if (live && !this->mLive && this->context) {
+		this->context->captureFrame();
+	}
+
+	this->mLive = live;
+	emit this->liveChanged();
+}
+
+void ScreencopyView::createContext() {
+	this->destroyContext(false);
+	this->context = ScreencopyManager::createContext(this->mCaptureSource, this->mPaintCursors);
+
+	if (!this->context) {
+		qmlWarning(this) << "Capture source set to non captureable object.";
+		return;
+	}
+
+	QObject::connect(
+	    this->context,
+	    &ScreencopyContext::stopped,
+	    this,
+	    &ScreencopyView::destroyContextWithUpdate
+	);
+
+	QObject::connect(
+	    this->context,
+	    &ScreencopyContext::frameCaptured,
+	    this,
+	    &ScreencopyView::onFrameCaptured
+	);
+
+	this->context->captureFrame();
+}
+
+void ScreencopyView::destroyContext(bool update) {
+	auto hadContext = this->context != nullptr;
+	delete this->context;
+	this->context = nullptr;
+	this->bHasContent = false;
+	this->bSourceSize = QSize();
+	if (hadContext && update) this->update();
+}
+
+void ScreencopyView::captureFrame() {
+	if (this->context) this->context->captureFrame();
+	else qmlWarning(this) << "Cannot capture frame, as no recording context is ready.";
+}
+
+void ScreencopyView::onFrameCaptured() {
+	this->setFlag(QQuickItem::ItemHasContents);
+	this->update();
+	this->bHasContent = true;
+	this->bSourceSize = this->context->swapchain().frontbuffer()->size();
+}
+
+void ScreencopyView::componentComplete() {
+	this->QQuickItem::componentComplete();
+
+	auto* bufManager = buffer::WlBufferManager::instance();
+	if (!bufManager->isReady()) {
+		QObject::connect(
+		    bufManager,
+		    &buffer::WlBufferManager::ready,
+		    this,
+		    &ScreencopyView::onBuffersReady
+		);
+	} else {
+		this->onBuffersReady();
+	}
+}
+
+void ScreencopyView::onBuffersReady() {
+	this->completed = true;
+	if (this->mCaptureSource) this->createContext();
+}
+
+QSGNode* ScreencopyView::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* /*unused*/) {
+	if (!this->context || !this->bHasContent) {
+		delete oldNode;
+		this->setFlag(QQuickItem::ItemHasContents, false);
+		return nullptr;
+	}
+
+	auto* node = static_cast<buffer::WlBufferQSGDisplayNode*>(oldNode); // NOLINT
+
+	if (!node) {
+		node = new buffer::WlBufferQSGDisplayNode(this->window());
+	}
+
+	auto& swapchain = this->context->swapchain();
+	node->syncSwapchain(swapchain);
+	node->setRect(this->boundingRect());
+
+	if (this->mLive) this->context->captureFrame();
+	return node;
+}
+
+} // namespace qs::wayland::screencopy
diff --git a/src/wayland/screencopy/view.hpp b/src/wayland/screencopy/view.hpp
new file mode 100644
index 00000000..53f42398
--- /dev/null
+++ b/src/wayland/screencopy/view.hpp
@@ -0,0 +1,96 @@
+#pragma once
+
+#include <qobject.h>
+#include <qproperty.h>
+#include <qqmlintegration.h>
+#include <qquickitem.h>
+#include <qsgnode.h>
+#include <qtmetamacros.h>
+
+#include "manager.hpp"
+
+namespace qs::wayland::screencopy {
+
+///! Displays a video stream from other windows or a monitor.
+/// ScreencopyView displays live video streams or single captured frames from valid
+/// capture sources. See @@captureSource for details on which objects are accepted.
+class ScreencopyView: public QQuickItem {
+	Q_OBJECT;
+	QML_ELEMENT;
+	// clang-format off
+	/// The object to capture from. Accepts any of the following:
+	/// - `null` - Clears the displayed image.
+	/// - @@Quickshell.ShellScreen - A monitor.
+	///   Requires a compositor that supports `wlr-screencopy-unstable`
+	///   or both `ext-image-copy-capture-v1` and `ext-capture-source-v1`.
+	/// - @@Quickshell.Wayland.Toplevel - A toplevel window.
+	///   Requires a compositor that supports `hyprland-toplevel-export-v1`.
+	Q_PROPERTY(QObject* captureSource READ captureSource WRITE setCaptureSource NOTIFY captureSourceChanged);
+	/// If true, the system cursor will be painted on the image. Defaults to false.
+	Q_PROPERTY(bool paintCursor READ paintCursors WRITE setPaintCursors NOTIFY paintCursorsChanged);
+	/// If true, a live video feed from the capture source will be displayed instead of a still image.
+	/// Defaults to false.
+	Q_PROPERTY(bool live READ live WRITE setLive NOTIFY liveChanged);
+	/// If true, the view has content ready to display. Content is not always immediately available,
+	/// and this property can be used to avoid displaying it until ready.
+	Q_PROPERTY(bool hasContent READ default NOTIFY hasContentChanged BINDABLE bindableHasContent);
+	/// The size of the source image. Valid when @@hasContent is true.
+	Q_PROPERTY(QSize sourceSize READ default NOTIFY sourceSizeChanged BINDABLE bindableSourceSize);
+	// clang-format on
+
+public:
+	explicit ScreencopyView(QQuickItem* parent = nullptr): QQuickItem(parent) {}
+
+	void componentComplete() override;
+
+	/// Capture a single frame. Has no effect if @@live is true.
+	Q_INVOKABLE void captureFrame();
+
+	[[nodiscard]] QObject* captureSource() const { return this->mCaptureSource; }
+	void setCaptureSource(QObject* captureSource);
+
+	[[nodiscard]] bool paintCursors() const { return this->mPaintCursors; }
+	void setPaintCursors(bool paintCursors);
+
+	[[nodiscard]] bool live() const { return this->mLive; }
+	void setLive(bool live);
+
+	[[nodiscard]] QBindable<bool> bindableHasContent() { return &this->bHasContent; }
+	[[nodiscard]] QBindable<QSize> bindableSourceSize() { return &this->bSourceSize; }
+
+signals:
+	/// The compositor has ended the video stream. Attempting to restart it may or may not work.
+	void stopped();
+
+	void captureSourceChanged();
+	void paintCursorsChanged();
+	void liveChanged();
+	void hasContentChanged();
+	void sourceSizeChanged();
+
+protected:
+	QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData* data) override;
+
+private slots:
+	void onCaptureSourceDestroyed();
+	void onFrameCaptured();
+	void destroyContextWithUpdate() { this->destroyContext(); }
+	void onBuffersReady();
+
+private:
+	void destroyContext(bool update = true);
+	void createContext();
+
+	// clang-format off
+	Q_OBJECT_BINDABLE_PROPERTY(ScreencopyView, bool, bHasContent, &ScreencopyView::hasContentChanged);
+	Q_OBJECT_BINDABLE_PROPERTY(ScreencopyView, QSize, bSourceSize, &ScreencopyView::sourceSizeChanged);
+	// clang-format on
+
+	QObject* mCaptureSource = nullptr;
+	bool mPaintCursors = false;
+	bool mLive = false;
+	ScreencopyContext* context = nullptr;
+	bool completed = false;
+};
+
+} // namespace qs::wayland::screencopy
diff --git a/src/wayland/screencopy/wlr_screencopy/CMakeLists.txt b/src/wayland/screencopy/wlr_screencopy/CMakeLists.txt
new file mode 100644
index 00000000..5829d915
--- /dev/null
+++ b/src/wayland/screencopy/wlr_screencopy/CMakeLists.txt
@@ -0,0 +1,14 @@
+qt_add_library(quickshell-wayland-screencopy-wlr STATIC
+	wlr_screencopy.cpp
+)
+
+wl_proto(wlp-wlr-screencopy wlr-screencopy-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}")
+
+target_link_libraries(quickshell-wayland-screencopy-wlr PRIVATE
+	Qt::WaylandClient Qt::WaylandClientPrivate wayland-client
+	Qt::Quick # for pch
+)
+
+target_link_libraries(quickshell-wayland-screencopy-wlr PUBLIC wlp-wlr-screencopy)
+
+qs_pch(quickshell-wayland-screencopy-wlr SET large)
diff --git a/src/wayland/screencopy/wlr_screencopy/wlr-screencopy-unstable-v1.xml b/src/wayland/screencopy/wlr_screencopy/wlr-screencopy-unstable-v1.xml
new file mode 100644
index 00000000..50b1b7d2
--- /dev/null
+++ b/src/wayland/screencopy/wlr_screencopy/wlr-screencopy-unstable-v1.xml
@@ -0,0 +1,232 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="wlr_screencopy_unstable_v1">
+  <copyright>
+    Copyright © 2018 Simon Ser
+    Copyright © 2019 Andri Yngvason
+
+    Permission is hereby granted, free of charge, to any person obtaining a
+    copy of this software and associated documentation files (the "Software"),
+    to deal in the Software without restriction, including without limitation
+    the rights to use, copy, modify, merge, publish, distribute, sublicense,
+    and/or sell copies of the Software, and to permit persons to whom the
+    Software is furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice (including the next
+    paragraph) shall be included in all copies or substantial portions of the
+    Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+    THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+    DEALINGS IN THE SOFTWARE.
+  </copyright>
+
+  <description summary="screen content capturing on client buffers">
+    This protocol allows clients to ask the compositor to copy part of the
+    screen content to a client buffer.
+
+    Warning! The protocol described in this file is experimental and
+    backward incompatible changes may be made. Backward compatible changes
+    may be added together with the corresponding interface version bump.
+    Backward incompatible changes are done by bumping the version number in
+    the protocol and interface names and resetting the interface version.
+    Once the protocol is to be declared stable, the 'z' prefix and the
+    version number in the protocol and interface names are removed and the
+    interface version number is reset.
+  </description>
+
+  <interface name="zwlr_screencopy_manager_v1" version="3">
+    <description summary="manager to inform clients and begin capturing">
+      This object is a manager which offers requests to start capturing from a
+      source.
+    </description>
+
+    <request name="capture_output">
+      <description summary="capture an output">
+        Capture the next frame of an entire output.
+      </description>
+      <arg name="frame" type="new_id" interface="zwlr_screencopy_frame_v1"/>
+      <arg name="overlay_cursor" type="int"
+        summary="composite cursor onto the frame"/>
+      <arg name="output" type="object" interface="wl_output"/>
+    </request>
+
+    <request name="capture_output_region">
+      <description summary="capture an output's region">
+        Capture the next frame of an output's region.
+
+        The region is given in output logical coordinates, see
+        xdg_output.logical_size. The region will be clipped to the output's
+        extents.
+      </description>
+      <arg name="frame" type="new_id" interface="zwlr_screencopy_frame_v1"/>
+      <arg name="overlay_cursor" type="int"
+        summary="composite cursor onto the frame"/>
+      <arg name="output" type="object" interface="wl_output"/>
+      <arg name="x" type="int"/>
+      <arg name="y" type="int"/>
+      <arg name="width" type="int"/>
+      <arg name="height" type="int"/>
+    </request>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the manager">
+        All objects created by the manager will still remain valid, until their
+        appropriate destroy request has been called.
+      </description>
+    </request>
+  </interface>
+
+  <interface name="zwlr_screencopy_frame_v1" version="3">
+    <description summary="a frame ready for copy">
+      This object represents a single frame.
+
+      When created, a series of buffer events will be sent, each representing a
+      supported buffer type. The "buffer_done" event is sent afterwards to
+      indicate that all supported buffer types have been enumerated. The client
+      will then be able to send a "copy" request. If the capture is successful,
+      the compositor will send a "flags" followed by a "ready" event.
+
+      For objects version 2 or lower, wl_shm buffers are always supported, ie.
+      the "buffer" event is guaranteed to be sent.
+
+      If the capture failed, the "failed" event is sent. This can happen anytime
+      before the "ready" event.
+
+      Once either a "ready" or a "failed" event is received, the client should
+      destroy the frame.
+    </description>
+
+    <event name="buffer">
+      <description summary="wl_shm buffer information">
+        Provides information about wl_shm buffer parameters that need to be
+        used for this frame. This event is sent once after the frame is created
+        if wl_shm buffers are supported.
+      </description>
+      <arg name="format" type="uint" enum="wl_shm.format" summary="buffer format"/>
+      <arg name="width" type="uint" summary="buffer width"/>
+      <arg name="height" type="uint" summary="buffer height"/>
+      <arg name="stride" type="uint" summary="buffer stride"/>
+    </event>
+
+    <request name="copy">
+      <description summary="copy the frame">
+        Copy the frame to the supplied buffer. The buffer must have a the
+        correct size, see zwlr_screencopy_frame_v1.buffer and
+        zwlr_screencopy_frame_v1.linux_dmabuf. The buffer needs to have a
+        supported format.
+
+        If the frame is successfully copied, a "flags" and a "ready" events are
+        sent. Otherwise, a "failed" event is sent.
+      </description>
+      <arg name="buffer" type="object" interface="wl_buffer"/>
+    </request>
+
+    <enum name="error">
+      <entry name="already_used" value="0"
+        summary="the object has already been used to copy a wl_buffer"/>
+      <entry name="invalid_buffer" value="1"
+        summary="buffer attributes are invalid"/>
+    </enum>
+
+    <enum name="flags" bitfield="true">
+      <entry name="y_invert" value="1" summary="contents are y-inverted"/>
+    </enum>
+
+    <event name="flags">
+      <description summary="frame flags">
+        Provides flags about the frame. This event is sent once before the
+        "ready" event.
+      </description>
+      <arg name="flags" type="uint" enum="flags" summary="frame flags"/>
+    </event>
+
+    <event name="ready">
+      <description summary="indicates frame is available for reading">
+        Called as soon as the frame is copied, indicating it is available
+        for reading. This event includes the time at which presentation happened
+        at.
+
+        The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples,
+        each component being an unsigned 32-bit value. Whole seconds are in
+        tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo,
+        and the additional fractional part in tv_nsec as nanoseconds. Hence,
+        for valid timestamps tv_nsec must be in [0, 999999999]. The seconds part
+        may have an arbitrary offset at start.
+
+        After receiving this event, the client should destroy the object.
+      </description>
+      <arg name="tv_sec_hi" type="uint"
+           summary="high 32 bits of the seconds part of the timestamp"/>
+      <arg name="tv_sec_lo" type="uint"
+           summary="low 32 bits of the seconds part of the timestamp"/>
+      <arg name="tv_nsec" type="uint"
+           summary="nanoseconds part of the timestamp"/>
+    </event>
+
+    <event name="failed">
+      <description summary="frame copy failed">
+        This event indicates that the attempted frame copy has failed.
+
+        After receiving this event, the client should destroy the object.
+      </description>
+    </event>
+
+    <request name="destroy" type="destructor">
+      <description summary="delete this object, used or not">
+        Destroys the frame. This request can be sent at any time by the client.
+      </description>
+    </request>
+
+    <!-- Version 2 additions -->
+    <request name="copy_with_damage" since="2">
+      <description summary="copy the frame when it's damaged">
+        Same as copy, except it waits until there is damage to copy.
+      </description>
+      <arg name="buffer" type="object" interface="wl_buffer"/>
+    </request>
+
+    <event name="damage" since="2">
+      <description summary="carries the coordinates of the damaged region">
+        This event is sent right before the ready event when copy_with_damage is
+        requested. It may be generated multiple times for each copy_with_damage
+        request.
+
+        The arguments describe a box around an area that has changed since the
+        last copy request that was derived from the current screencopy manager
+        instance.
+
+        The union of all regions received between the call to copy_with_damage
+        and a ready event is the total damage since the prior ready event.
+      </description>
+      <arg name="x" type="uint" summary="damaged x coordinates"/>
+      <arg name="y" type="uint" summary="damaged y coordinates"/>
+      <arg name="width" type="uint" summary="current width"/>
+      <arg name="height" type="uint" summary="current height"/>
+    </event>
+
+    <!-- Version 3 additions -->
+    <event name="linux_dmabuf" since="3">
+      <description summary="linux-dmabuf buffer information">
+        Provides information about linux-dmabuf buffer parameters that need to
+        be used for this frame. This event is sent once after the frame is
+        created if linux-dmabuf buffers are supported.
+      </description>
+      <arg name="format" type="uint" summary="fourcc pixel format"/>
+      <arg name="width" type="uint" summary="buffer width"/>
+      <arg name="height" type="uint" summary="buffer height"/>
+    </event>
+
+    <event name="buffer_done" since="3">
+      <description summary="all buffer types reported">
+        This event is sent once after all buffer events have been sent.
+
+        The client should proceed to create a buffer of one of the supported
+        types, and send a "copy" request.
+      </description>
+    </event>
+  </interface>
+</protocol>
diff --git a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp
new file mode 100644
index 00000000..8cc89bca
--- /dev/null
+++ b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.cpp
@@ -0,0 +1,133 @@
+#include "wlr_screencopy.hpp"
+#include <cstdint>
+
+#include <private/qwaylandscreen_p.h>
+#include <qlogging.h>
+#include <qloggingcategory.h>
+#include <qobject.h>
+#include <qscreen.h>
+#include <qtmetamacros.h>
+#include <qwaylandclientextension.h>
+#include <wayland-wlr-screencopy-unstable-v1-client-protocol.h>
+
+#include "../../buffer/manager.hpp"
+#include "../manager.hpp"
+#include "wlr_screencopy_p.hpp"
+
+namespace qs::wayland::screencopy::wlr {
+
+namespace {
+Q_LOGGING_CATEGORY(logScreencopy, "quickshell.wayland.screencopy.wlr", QtWarningMsg);
+}
+
+WlrScreencopyManager::WlrScreencopyManager(): QWaylandClientExtensionTemplate(3) {
+	this->initialize();
+}
+
+WlrScreencopyManager* WlrScreencopyManager::instance() {
+	static auto* instance = new WlrScreencopyManager();
+	return instance;
+}
+
+ScreencopyContext*
+WlrScreencopyManager::captureOutput(QScreen* screen, bool paintCursors, QRect region) {
+	if (!dynamic_cast<QtWaylandClient::QWaylandScreen*>(screen->handle())) return nullptr;
+	return new WlrScreencopyContext(this, screen, paintCursors, region);
+}
+
+WlrScreencopyContext::WlrScreencopyContext(
+    WlrScreencopyManager* manager,
+    QScreen* screen,
+    bool paintCursors,
+    QRect region
+)
+    : manager(manager)
+    , screen(dynamic_cast<QtWaylandClient::QWaylandScreen*>(screen->handle()))
+    , paintCursors(paintCursors)
+    , region(region) {
+	QObject::connect(screen, &QObject::destroyed, this, &WlrScreencopyContext::onScreenDestroyed);
+}
+
+WlrScreencopyContext::~WlrScreencopyContext() {
+	if (this->object()) this->destroy();
+}
+
+void WlrScreencopyContext::onScreenDestroyed() {
+	qCWarning(logScreencopy) << "Screen destroyed while recording. Stopping" << this;
+	if (this->object()) this->destroy();
+	emit this->stopped();
+}
+
+void WlrScreencopyContext::captureFrame() {
+	if (this->object()) return;
+
+	if (this->region.isEmpty()) {
+		this->init(manager->capture_output(this->paintCursors ? 1 : 0, screen->output()));
+	} else {
+		this->init(manager->capture_output_region(
+		    this->paintCursors ? 1 : 0,
+		    screen->output(),
+		    this->region.x(),
+		    this->region.y(),
+		    this->region.width(),
+		    this->region.height()
+		));
+	}
+}
+
+void WlrScreencopyContext::zwlr_screencopy_frame_v1_buffer(
+    uint32_t format,
+    uint32_t width,
+    uint32_t height,
+    uint32_t /*stride*/
+) {
+	// While different sizes can technically be requested, that would be insane.
+	this->request.width = width;
+	this->request.height = height;
+	this->request.shm.formats.push(format);
+}
+
+void WlrScreencopyContext::zwlr_screencopy_frame_v1_linux_dmabuf(
+    uint32_t format,
+    uint32_t width,
+    uint32_t height
+) {
+	// While different sizes can technically be requested, that would be insane.
+	this->request.width = width;
+	this->request.height = height;
+	this->request.dmabuf.formats.push(format);
+}
+
+void WlrScreencopyContext::zwlr_screencopy_frame_v1_flags(uint32_t flags) {
+	if (flags & ZWLR_SCREENCOPY_FRAME_V1_FLAGS_Y_INVERT) {
+		this->mSwapchain.backbuffer()->transform = buffer::WlBufferTransform::Flipped180;
+	}
+}
+
+void WlrScreencopyContext::zwlr_screencopy_frame_v1_buffer_done() {
+	auto* backbuffer = this->mSwapchain.createBackbuffer(this->request);
+
+	if (this->copiedFirstFrame) {
+		this->copy_with_damage(backbuffer->buffer());
+	} else {
+		this->copy(backbuffer->buffer());
+	}
+}
+
+void WlrScreencopyContext::zwlr_screencopy_frame_v1_ready(
+    uint32_t /*tvSecHi*/,
+    uint32_t /*tvSecLo*/,
+    uint32_t /*tvNsec*/
+) {
+	this->destroy();
+	this->copiedFirstFrame = true;
+	this->mSwapchain.swapBuffers();
+	emit this->frameCaptured();
+}
+
+void WlrScreencopyContext::zwlr_screencopy_frame_v1_failed() {
+	qCWarning(logScreencopy) << "Ending recording due to screencopy failure for" << this;
+	emit this->stopped();
+}
+
+} // namespace qs::wayland::screencopy::wlr
diff --git a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.hpp b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.hpp
new file mode 100644
index 00000000..bea17332
--- /dev/null
+++ b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy.hpp
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <qscreen.h>
+#include <qwayland-wlr-screencopy-unstable-v1.h>
+#include <qwaylandclientextension.h>
+
+#include "../manager.hpp"
+
+namespace qs::wayland::screencopy::wlr {
+
+class WlrScreencopyManager
+    : public QWaylandClientExtensionTemplate<WlrScreencopyManager>
+    , public QtWayland::zwlr_screencopy_manager_v1 {
+public:
+	ScreencopyContext* captureOutput(QScreen* screen, bool paintCursors, QRect region = QRect());
+
+	static WlrScreencopyManager* instance();
+
+private:
+	explicit WlrScreencopyManager();
+
+	friend class WlrScreencopyContext;
+};
+
+} // namespace qs::wayland::screencopy::wlr
diff --git a/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp
new file mode 100644
index 00000000..7bdbafb7
--- /dev/null
+++ b/src/wayland/screencopy/wlr_screencopy/wlr_screencopy_p.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include <private/qwaylandscreen_p.h>
+#include <qtclasshelpermacros.h>
+#include <qwayland-wlr-screencopy-unstable-v1.h>
+
+#include "../manager.hpp"
+
+namespace qs::wayland::screencopy::wlr {
+
+class WlrScreencopyManager;
+
+class WlrScreencopyContext
+    : public ScreencopyContext
+    , public QtWayland::zwlr_screencopy_frame_v1 {
+public:
+	explicit WlrScreencopyContext(
+	    WlrScreencopyManager* manager,
+	    QScreen* screen,
+	    bool paintCursors,
+	    QRect region
+	);
+	~WlrScreencopyContext() override;
+	Q_DISABLE_COPY_MOVE(WlrScreencopyContext);
+
+	void captureFrame() override;
+
+protected:
+	// clang-format off
+	void zwlr_screencopy_frame_v1_buffer(uint32_t format, uint32_t width, uint32_t height, uint32_t stride) override;
+	void zwlr_screencopy_frame_v1_linux_dmabuf(uint32_t format, uint32_t width, uint32_t height) override;
+	void zwlr_screencopy_frame_v1_flags(uint32_t flags) override;
+	void zwlr_screencopy_frame_v1_buffer_done() override;
+	void zwlr_screencopy_frame_v1_ready(uint32_t tvSecHi, uint32_t tvSecLo, uint32_t tvNsec) override;
+	void zwlr_screencopy_frame_v1_failed() override;
+	// clang-format on
+
+private slots:
+	void onScreenDestroyed();
+
+private:
+	WlrScreencopyManager* manager;
+	buffer::WlBufferRequest request;
+	bool copiedFirstFrame = false;
+
+	QtWaylandClient::QWaylandScreen* screen;
+	bool paintCursors;
+	QRect region;
+};
+
+} // namespace qs::wayland::screencopy::wlr
diff --git a/src/wayland/toplevel_management/qml.hpp b/src/wayland/toplevel_management/qml.hpp
index 127b4c8f..20347343 100644
--- a/src/wayland/toplevel_management/qml.hpp
+++ b/src/wayland/toplevel_management/qml.hpp
@@ -13,7 +13,7 @@
 namespace qs::wayland::toplevel_management {
 
 namespace impl {
-class ToplevelManager;
+class ToplevelManager; // NOLINT
 class ToplevelHandle;
 } // namespace impl
 
@@ -80,6 +80,8 @@ public:
 	[[nodiscard]] bool fullscreen() const;
 	void setFullscreen(bool fullscreen);
 
+	[[nodiscard]] impl::ToplevelHandle* implHandle() const { return this->handle; }
+
 signals:
 	void closed();
 	void appIdChanged();