From 0cd3a366ffb1baaf8d1259439cc4110b5bcc10e2 Mon Sep 17 00:00:00 2001
From: Chris Lee <23645059+chruffins@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:25:26 +0000
Subject: [PATCH 1/3] Add Wayland screencopy video capture
---
server/internal/capture/manager.go | 23 +++-
server/internal/capture/streamsink.go | 36 ++++++-
server/internal/capture/wayland.go | 136 ++++++++++++++++++++++++
server/internal/capture/wayland_test.go | 46 ++++++++
server/internal/config/capture.go | 16 +++
webpage/docs/configuration/capture.md | 6 +-
webpage/docs/configuration/help.json | 20 ++++
7 files changed, 277 insertions(+), 6 deletions(-)
create mode 100644 server/internal/capture/wayland.go
create mode 100644 server/internal/capture/wayland_test.go
diff --git a/server/internal/capture/manager.go b/server/internal/capture/manager.go
index d16919e9f..d5ac42c97 100644
--- a/server/internal/capture/manager.go
+++ b/server/internal/capture/manager.go
@@ -39,6 +39,9 @@ func New(desktop types.DesktopManager, config *config.Capture) *CaptureManagerCt
createPipeline := func() (string, error) {
if pipelineConf.GstPipeline != "" {
+ if config.Wayland {
+ return "", errors.New("custom video pipelines are not supported with Wayland capture")
+ }
// replace {display} with valid display
return strings.Replace(pipelineConf.GstPipeline, "{display}", config.Display, 1), nil
}
@@ -49,6 +52,18 @@ func New(desktop types.DesktopManager, config *config.Capture) *CaptureManagerCt
return "", err
}
+ if config.Wayland {
+ fps := screen.Rate
+ if fps <= 0 {
+ fps = 25
+ }
+ return fmt.Sprintf(
+ "appsrc name=appsrc is-live=true format=time do-timestamp=true "+
+ "caps=video/x-raw,format=BGRx,width=%d,height=%d,framerate=%d/1 "+
+ "%s ! appsink name=appsink", screen.Width, screen.Height, fps, pipeline,
+ ), nil
+ }
+
return fmt.Sprintf(
"ximagesrc display-name=%s show-pointer=%v use-damage=false "+
"%s ! appsink name=appsink", config.Display, pipelineConf.ShowPointer, pipeline,
@@ -69,7 +84,13 @@ func New(desktop types.DesktopManager, config *config.Capture) *CaptureManagerCt
Msg("syntax check for video stream pipeline passed")
// append to videos
- videos[video_id] = streamSinkNew(config.VideoCodec, createPipeline, video_id)
+ video := streamSinkNew(config.VideoCodec, createPipeline, video_id)
+ if config.Wayland {
+ video.SetFrameSourceFactory(func() (frameSource, error) {
+ return newWaylandFrameSource(config.WaylandRecorder, desktop.GetScreenSize()), nil
+ })
+ }
+ videos[video_id] = video
}
return &CaptureManagerCtx{
diff --git a/server/internal/capture/streamsink.go b/server/internal/capture/streamsink.go
index a9e5146ea..14ff08dd2 100644
--- a/server/internal/capture/streamsink.go
+++ b/server/internal/capture/streamsink.go
@@ -32,10 +32,12 @@ type StreamSinkManagerCtx struct {
mu sync.Mutex
wg sync.WaitGroup
- codec codec.RTPCodec
- pipeline gst.Pipeline
- pipelineMu sync.Mutex
- pipelineFn func() (string, error)
+ codec codec.RTPCodec
+ pipeline gst.Pipeline
+ pipelineMu sync.Mutex
+ pipelineFn func() (string, error)
+ frameSourceFn func() (frameSource, error)
+ frameSource frameSource
listeners map[uintptr]types.SampleListener
listenersKf map[uintptr]types.SampleListener // keyframe lobby
@@ -142,6 +144,10 @@ func (manager *StreamSinkManagerCtx) ID() string {
return manager.id
}
+func (manager *StreamSinkManagerCtx) SetFrameSourceFactory(factory func() (frameSource, error)) {
+ manager.frameSourceFn = factory
+}
+
func (manager *StreamSinkManagerCtx) Bitrate() uint64 {
manager.listenersMu.Lock()
defer manager.listenersMu.Unlock()
@@ -325,9 +331,27 @@ func (manager *StreamSinkManagerCtx) CreatePipeline() error {
return err
}
+ if manager.frameSourceFn != nil {
+ manager.frameSource, err = manager.frameSourceFn()
+ if err != nil {
+ manager.pipeline.Destroy()
+ manager.pipeline = nil
+ return err
+ }
+ manager.pipeline.AttachAppsrc("appsrc")
+ }
manager.pipeline.AttachAppsink("appsink")
manager.pipeline.Play()
+ if manager.frameSource != nil {
+ if err := manager.frameSource.Start(manager.pipeline.Push); err != nil {
+ manager.pipeline.Destroy()
+ manager.pipeline = nil
+ manager.frameSource = nil
+ return err
+ }
+ }
+
manager.wg.Add(1)
pipeline := manager.pipeline
@@ -405,6 +429,10 @@ func (manager *StreamSinkManagerCtx) DestroyPipeline() {
return
}
+ if manager.frameSource != nil {
+ manager.frameSource.Stop()
+ manager.frameSource = nil
+ }
manager.pipeline.Destroy()
manager.logger.Info().Msgf("destroying pipeline")
manager.pipeline = nil
diff --git a/server/internal/capture/wayland.go b/server/internal/capture/wayland.go
new file mode 100644
index 000000000..12411f248
--- /dev/null
+++ b/server/internal/capture/wayland.go
@@ -0,0 +1,136 @@
+package capture
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strconv"
+ "sync"
+
+ "github.com/rs/zerolog/log"
+
+ "github.com/m1k1o/neko/server/pkg/types"
+)
+
+type frameSource interface {
+ Start(func([]byte)) error
+ Stop()
+}
+
+type waylandFrameSource struct {
+ recorder string
+ width int
+ height int
+ fps int
+
+ mu sync.Mutex
+ cancel context.CancelFunc
+ done chan struct{}
+}
+
+func newWaylandFrameSource(recorder string, screen types.ScreenSize) *waylandFrameSource {
+ fps := int(screen.Rate)
+ if fps <= 0 {
+ fps = 25
+ }
+
+ return &waylandFrameSource{
+ recorder: recorder,
+ width: screen.Width,
+ height: screen.Height,
+ fps: fps,
+ }
+}
+
+func (source *waylandFrameSource) frameSize() int {
+ return source.width * source.height * 4
+}
+
+func (source *waylandFrameSource) args() []string {
+ return []string{
+ "--no-damage",
+ "--no-dmabuf",
+ "--framerate", strconv.Itoa(source.fps),
+ "--muxer", "rawvideo",
+ "--codec", "rawvideo",
+ "--pixel-format", "bgr0",
+ "--file", "/dev/stdout",
+ "--overwrite",
+ }
+}
+
+func (source *waylandFrameSource) command() *exec.Cmd {
+ return exec.Command(source.recorder, source.args()...)
+}
+
+func (source *waylandFrameSource) Start(push func([]byte)) error {
+ if push == nil {
+ return fmt.Errorf("frame push callback is required")
+ }
+ if source.recorder == "" {
+ return fmt.Errorf("Wayland recorder executable is required")
+ }
+ if source.width <= 0 || source.height <= 0 {
+ return fmt.Errorf("invalid Wayland output size: %dx%d", source.width, source.height)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, source.recorder, source.args()...)
+ cmd.Stderr = os.Stderr
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ return fmt.Errorf("create Wayland recorder pipe: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ cancel()
+ return fmt.Errorf("start Wayland recorder: %w", err)
+ }
+
+ done := make(chan struct{})
+ source.mu.Lock()
+ source.cancel = cancel
+ source.done = done
+ source.mu.Unlock()
+
+ go func() {
+ defer close(done)
+ defer stdout.Close()
+
+ frame := make([]byte, source.frameSize())
+ for {
+ if _, err := io.ReadFull(stdout, frame); err != nil {
+ if err != io.EOF && err != io.ErrUnexpectedEOF {
+ log.Warn().Err(err).Msg("Wayland recorder stopped while reading a frame")
+ }
+ break
+ }
+
+ push(frame)
+ }
+
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil {
+ log.Warn().Err(err).Msg("Wayland recorder exited")
+ }
+ }()
+
+ return nil
+}
+
+func (source *waylandFrameSource) Stop() {
+ source.mu.Lock()
+ cancel := source.cancel
+ done := source.done
+ source.cancel = nil
+ source.done = nil
+ source.mu.Unlock()
+
+ if cancel == nil {
+ return
+ }
+ cancel()
+ <-done
+}
diff --git a/server/internal/capture/wayland_test.go b/server/internal/capture/wayland_test.go
new file mode 100644
index 000000000..facd77211
--- /dev/null
+++ b/server/internal/capture/wayland_test.go
@@ -0,0 +1,46 @@
+package capture
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/m1k1o/neko/server/pkg/types"
+)
+
+func TestWaylandFrameSourceCommand(t *testing.T) {
+ source := newWaylandFrameSource("wf-recorder", types.ScreenSize{
+ Width: 1920,
+ Height: 1080,
+ Rate: 25,
+ })
+
+ got := source.command().Args
+ want := []string{
+ "wf-recorder",
+ "--no-damage",
+ "--no-dmabuf",
+ "--framerate", "25",
+ "--muxer", "rawvideo",
+ "--codec", "rawvideo",
+ "--pixel-format", "bgr0",
+ "--file", "/dev/stdout",
+ "--overwrite",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("command args = %#v, want %#v", got, want)
+ }
+}
+
+func TestWaylandFrameSourceDefaultsFrameRate(t *testing.T) {
+ source := newWaylandFrameSource("wf-recorder", types.ScreenSize{
+ Width: 10,
+ Height: 20,
+ })
+
+ if source.fps != 25 {
+ t.Fatalf("fps = %d, want 25", source.fps)
+ }
+ if source.frameSize() != 800 {
+ t.Fatalf("frame size = %d, want 800", source.frameSize())
+ }
+}
diff --git a/server/internal/config/capture.go b/server/internal/config/capture.go
index 6bf568974..03f652b7c 100644
--- a/server/internal/config/capture.go
+++ b/server/internal/config/capture.go
@@ -28,6 +28,9 @@ const (
type Capture struct {
Display string
+ Wayland bool
+ WaylandRecorder string
+
VideoCodec codec.RTPCodec
VideoIDs []string
VideoPipelines map[string]types.VideoConfig
@@ -80,6 +83,16 @@ func (Capture) Init(cmd *cobra.Command) error {
return err
}
+ cmd.PersistentFlags().Bool("capture.video.wayland", false, "capture a Wayland compositor output")
+ if err := viper.BindPFlag("capture.video.wayland", cmd.PersistentFlags().Lookup("capture.video.wayland")); err != nil {
+ return err
+ }
+
+ cmd.PersistentFlags().String("capture.video.wayland_recorder", "wf-recorder", "Wayland screencopy recorder executable")
+ if err := viper.BindPFlag("capture.video.wayland_recorder", cmd.PersistentFlags().Lookup("capture.video.wayland_recorder")); err != nil {
+ return err
+ }
+
cmd.PersistentFlags().String("capture.video.codec", "vp8", "video codec to be used")
if err := viper.BindPFlag("capture.video.codec", cmd.PersistentFlags().Lookup("capture.video.codec")); err != nil {
return err
@@ -326,6 +339,9 @@ func (s *Capture) Set() {
s.Display = os.Getenv("DISPLAY")
}
+ s.Wayland = viper.GetBool("capture.video.wayland")
+ s.WaylandRecorder = viper.GetString("capture.video.wayland_recorder")
+
// video
videoCodec := viper.GetString("capture.video.codec")
s.VideoCodec, ok = codec.ParseStr(videoCodec)
diff --git a/webpage/docs/configuration/capture.md b/webpage/docs/configuration/capture.md
index 6349943ea..3f8fb79a9 100644
--- a/webpage/docs/configuration/capture.md
+++ b/webpage/docs/configuration/capture.md
@@ -31,6 +31,8 @@ The Gstreamer pipeline is started when the first client requests the video strea
- is the name of the [X display](https://www.x.org/wiki/) that you want to capture. If not specified, the environment variable `DISPLAY` will be used.
-- available codecs are `vp8`, `vp9`, `av1`, `h264`. [Supported video codecs](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/WebRTC_codecs#supported_video_codecs) are dependent on the WebRTC implementation used by the client, `vp8` and `h264` are supported by all WebRTC implementations.
+- switches the video source from `ximagesrc` to a `wf-recorder` process using the compositor's `wlr-screencopy-unstable-v1` protocol. It requires a Wayland compositor that exposes that protocol and an executable . Custom `gst_pipeline` values are not supported in this mode.
+- is the executable used to produce raw `BGRx` frames on stdout. The default is `wf-recorder`.
+- available codecs are `vp8`, `vp9`, `av1`, `h264`. [Supported video codecs](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/WebRTC_codecs#supported_video_codecs) are dependent on the WebRTC implementation used by the client, `vp8` and `h264` are supported by all WebRTC implementations.
- is a list of pipeline ids that are defined in the section. The first pipeline in the list will be the default pipeline.
- is a shorthand for defining [Gstreamer pipeline description](#video.gst_pipeline) for a single pipeline. This is option is ignored if is defined.
- is a dictionary of pipeline configurations. Each pipeline configuration is defined by a unique pipeline id. They can be defined in two ways: either by building the pipeline dynamically using [Expression-Driven Configuration](#video.expression) or by defining the pipeline using a [Gstreamer Pipeline Description](#video.gst_pipeline).
diff --git a/webpage/docs/configuration/help.json b/webpage/docs/configuration/help.json
index 8320f7e9c..e97ccbf23 100644
--- a/webpage/docs/configuration/help.json
+++ b/webpage/docs/configuration/help.json
@@ -164,6 +164,26 @@
"type": "string",
"description": "X display to capture"
},
+ {
+ "key": [
+ "capture",
+ "video",
+ "wayland"
+ ],
+ "type": "boolean",
+ "defaultValue": false,
+ "description": "capture a Wayland compositor output"
+ },
+ {
+ "key": [
+ "capture",
+ "video",
+ "wayland_recorder"
+ ],
+ "type": "string",
+ "defaultValue": "wf-recorder",
+ "description": "Wayland screencopy recorder executable"
+ },
{
"key": [
"capture",
From 5e0d014821f7166f9ba81c25b4ac9db6bc27b18f Mon Sep 17 00:00:00 2001
From: Chris Lee <23645059+chruffins@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:41:35 +0000
Subject: [PATCH 2/3] Add Wayland desktop input backend
---
server/internal/config/desktop.go | 7 +
server/internal/desktop/manager.go | 30 ++-
server/internal/desktop/wayland.go | 293 ++++++++++++++++++++++++
server/internal/desktop/wayland_test.go | 41 ++++
server/internal/desktop/xorg.go | 88 +++++++
webpage/docs/configuration/desktop.md | 4 +-
webpage/docs/configuration/help.json | 9 +
7 files changed, 464 insertions(+), 8 deletions(-)
create mode 100644 server/internal/desktop/wayland.go
create mode 100644 server/internal/desktop/wayland_test.go
diff --git a/server/internal/config/desktop.go b/server/internal/config/desktop.go
index 8331035e7..a1c835789 100644
--- a/server/internal/config/desktop.go
+++ b/server/internal/config/desktop.go
@@ -14,6 +14,7 @@ import (
type Desktop struct {
Display string
+ Wayland bool
ScreenSize types.ScreenSize
@@ -31,6 +32,11 @@ func (Desktop) Init(cmd *cobra.Command) error {
return err
}
+ cmd.PersistentFlags().Bool("desktop.wayland", false, "use Wayland desktop input and screen management")
+ if err := viper.BindPFlag("desktop.wayland", cmd.PersistentFlags().Lookup("desktop.wayland")); err != nil {
+ return err
+ }
+
cmd.PersistentFlags().String("desktop.screen", "1280x720@30", "default screen size and framerate")
if err := viper.BindPFlag("desktop.screen", cmd.PersistentFlags().Lookup("desktop.screen")); err != nil {
return err
@@ -75,6 +81,7 @@ func (Desktop) InitV2(cmd *cobra.Command) error {
func (s *Desktop) Set() {
s.Display = viper.GetString("desktop.display")
+ s.Wayland = viper.GetBool("desktop.wayland")
// Display is provided by env variable unless explicitly set
if s.Display == "" {
diff --git a/server/internal/desktop/manager.go b/server/internal/desktop/manager.go
index ccae14704..390827512 100644
--- a/server/internal/desktop/manager.go
+++ b/server/internal/desktop/manager.go
@@ -20,13 +20,14 @@ import (
var mu = sync.Mutex{}
type DesktopManagerCtx struct {
- logger zerolog.Logger
- wg sync.WaitGroup
- shutdown chan struct{}
- emmiter events.EventEmmiter
- config *config.Desktop
- screenSize types.ScreenSize // cached screen size
- input xinput.Driver
+ logger zerolog.Logger
+ wg sync.WaitGroup
+ shutdown chan struct{}
+ emmiter events.EventEmmiter
+ config *config.Desktop
+ screenSize types.ScreenSize // cached screen size
+ input xinput.Driver
+ waylandInput *waylandInput
// Clipboard process holding the most recent clipboard data.
// It must remain running to allow pasting clipboard data.
@@ -54,6 +55,16 @@ func New(config *config.Desktop) *DesktopManagerCtx {
}
func (manager *DesktopManagerCtx) Start() {
+ if manager.config.Wayland {
+ input, err := newWaylandInput(manager.screenSize.Width, manager.screenSize.Height)
+ if err != nil {
+ manager.logger.Panic().Err(err).Msg("unable to create Wayland input device")
+ }
+ manager.waylandInput = input
+ manager.logger.Info().Str("screen_size", manager.screenSize.String()).Msg("using Wayland desktop backend")
+ return
+ }
+
if xorg.DisplayOpen(manager.config.Display) {
manager.logger.Panic().Str("display", manager.config.Display).Msg("unable to open display")
}
@@ -139,6 +150,11 @@ func (manager *DesktopManagerCtx) Shutdown() error {
manager.logger.Info().Msgf("shutdown")
close(manager.shutdown)
+ if manager.waylandInput != nil {
+ manager.waylandInput.close()
+ manager.waylandInput = nil
+ return nil
+ }
manager.replaceClipboardCommand(nil)
manager.wg.Wait()
diff --git a/server/internal/desktop/wayland.go b/server/internal/desktop/wayland.go
new file mode 100644
index 000000000..e847e131a
--- /dev/null
+++ b/server/internal/desktop/wayland.go
@@ -0,0 +1,293 @@
+package desktop
+
+import (
+ "encoding/binary"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+const (
+ waylandInputPath = "/dev/uinput"
+
+ evSyn = 0x00
+ evKey = 0x01
+ evRel = 0x02
+ evAbs = 0x03
+
+ relX = 0x00
+ relY = 0x01
+ relWheel = 0x08
+ relHWheel = 0x06
+
+ absX = 0x00
+ absY = 0x01
+
+ btnLeft = 0x110
+ btnMiddle = 0x112
+ btnRight = 0x111
+
+ uiSetEvbit = 0x40045564
+ uiSetKeybit = 0x40045565
+ uiSetRelbit = 0x40045566
+ uiSetAbsbit = 0x40045567
+ uiDevCreate = 0x5501
+ uiDevDestroy = 0x5502
+)
+
+type uinputID struct {
+ BusType uint16
+ Vendor uint16
+ Product uint16
+ Version uint16
+}
+
+type uinputUserDev struct {
+ Name [80]byte
+ ID uinputID
+ FFEffectsMax uint32
+ AbsMax [64]int32
+ AbsMin [64]int32
+ AbsFuzz [64]int32
+ AbsFlat [64]int32
+}
+
+type inputEvent struct {
+ Sec int64
+ Usec int64
+ Type uint16
+ Code uint16
+ Value int32
+}
+
+type waylandInput struct {
+ fd *os.File
+ width int
+ height int
+
+ mu sync.Mutex
+ cursorX int
+ cursorY int
+ pressed map[uint16]struct{}
+}
+
+func newWaylandInput(width, height int) (*waylandInput, error) {
+ if width <= 0 || height <= 0 {
+ return nil, fmt.Errorf("invalid Wayland input size: %dx%d", width, height)
+ }
+
+ fd, err := os.OpenFile(waylandInputPath, os.O_WRONLY|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", waylandInputPath, err)
+ }
+
+ input := &waylandInput{
+ fd: fd,
+ width: width,
+ height: height,
+ pressed: make(map[uint16]struct{}),
+ }
+ if err := input.create(); err != nil {
+ _ = fd.Close()
+ return nil, err
+ }
+ return input, nil
+}
+
+func (input *waylandInput) ioctl(request, value uintptr) error {
+ _, _, errno := unix.Syscall(unix.SYS_IOCTL, input.fd.Fd(), request, value)
+ if errno != 0 {
+ return errno
+ }
+ return nil
+}
+
+func (input *waylandInput) create() error {
+ for _, eventType := range []int{evKey, evRel, evAbs} {
+ if err := input.ioctl(uiSetEvbit, uintptr(eventType)); err != nil {
+ return fmt.Errorf("enable uinput event type %d: %w", eventType, err)
+ }
+ }
+ for key := 0; key <= 0xff; key++ {
+ if err := input.ioctl(uiSetKeybit, uintptr(key)); err != nil {
+ return fmt.Errorf("enable uinput key %d: %w", key, err)
+ }
+ }
+ for _, rel := range []int{relX, relY, relWheel, relHWheel} {
+ if err := input.ioctl(uiSetRelbit, uintptr(rel)); err != nil {
+ return fmt.Errorf("enable uinput relative axis %d: %w", rel, err)
+ }
+ }
+ for _, abs := range []int{absX, absY} {
+ if err := input.ioctl(uiSetAbsbit, uintptr(abs)); err != nil {
+ return fmt.Errorf("enable uinput absolute axis %d: %w", abs, err)
+ }
+ }
+
+ device := uinputUserDev{
+ ID: uinputID{BusType: 0x03, Vendor: 0x1, Product: 0x1, Version: 1},
+ }
+ copy(device.Name[:], "Neko Wayland input")
+ device.AbsMax[absX] = int32(input.width - 1)
+ device.AbsMax[absY] = int32(input.height - 1)
+ if err := binary.Write(input.fd, binary.LittleEndian, &device); err != nil {
+ return fmt.Errorf("configure uinput device: %w", err)
+ }
+ if err := input.ioctl(uiDevCreate, 0); err != nil {
+ return fmt.Errorf("create uinput device: %w", err)
+ }
+ return nil
+}
+
+func (input *waylandInput) close() {
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ if input.fd == nil {
+ return
+ }
+ _ = input.ioctl(uiDevDestroy, 0)
+ _ = input.fd.Close()
+ input.fd = nil
+}
+
+func (input *waylandInput) emit(eventType, code uint16, value int32) error {
+ now := time.Now()
+ event := inputEvent{
+ Sec: now.Unix(),
+ Usec: int64(now.Nanosecond()) / 1000,
+ Type: eventType,
+ Code: code,
+ Value: value,
+ }
+ return binary.Write(input.fd, binary.LittleEndian, &event)
+}
+
+func (input *waylandInput) sync() error {
+ return input.emit(evSyn, 0, 0)
+}
+
+func (input *waylandInput) move(x, y int) error {
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ if err := input.emit(evAbs, absX, int32(clamp(x, 0, input.width-1))); err != nil {
+ return err
+ }
+ if err := input.emit(evAbs, absY, int32(clamp(y, 0, input.height-1))); err != nil {
+ return err
+ }
+ if err := input.sync(); err != nil {
+ return err
+ }
+ input.cursorX, input.cursorY = x, y
+ return nil
+}
+
+func (input *waylandInput) button(code uint32, down bool) error {
+ linuxCode, ok := mapButton(code)
+ if !ok {
+ return fmt.Errorf("unsupported mouse button: %d", code)
+ }
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ if err := input.emit(evKey, linuxCode, boolValue(down)); err != nil {
+ return err
+ }
+ if err := input.sync(); err != nil {
+ return err
+ }
+ if down {
+ input.pressed[linuxCode] = struct{}{}
+ } else {
+ delete(input.pressed, linuxCode)
+ }
+ return nil
+}
+
+func (input *waylandInput) key(code uint32, down bool) error {
+ linuxCode, ok := mapKey(code)
+ if !ok {
+ return fmt.Errorf("unsupported keyboard key: %d", code)
+ }
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ if err := input.emit(evKey, linuxCode, boolValue(down)); err != nil {
+ return err
+ }
+ if err := input.sync(); err != nil {
+ return err
+ }
+ if down {
+ input.pressed[linuxCode] = struct{}{}
+ } else {
+ delete(input.pressed, linuxCode)
+ }
+ return nil
+}
+
+func (input *waylandInput) scroll(deltaX, deltaY int) error {
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ if deltaY != 0 {
+ if err := input.emit(evRel, relWheel, int32(-deltaY)); err != nil {
+ return err
+ }
+ }
+ if deltaX != 0 {
+ if err := input.emit(evRel, relHWheel, int32(deltaX)); err != nil {
+ return err
+ }
+ }
+ return input.sync()
+}
+
+func (input *waylandInput) resetKeys() error {
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ for code := range input.pressed {
+ if err := input.emit(evKey, code, 0); err != nil {
+ return err
+ }
+ }
+ input.pressed = make(map[uint16]struct{})
+ return input.sync()
+}
+
+func mapButton(code uint32) (uint16, bool) {
+ switch code {
+ case 1:
+ return btnLeft, true
+ case 2:
+ return btnMiddle, true
+ case 3:
+ return btnRight, true
+ default:
+ return 0, false
+ }
+}
+
+func mapKey(code uint32) (uint16, bool) {
+ if code < 8 || code > 263 {
+ return 0, false
+ }
+ return uint16(code - 8), true
+}
+
+func boolValue(value bool) int32 {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+func clamp(value, min, max int) int {
+ if value < min {
+ return min
+ }
+ if value > max {
+ return max
+ }
+ return value
+}
diff --git a/server/internal/desktop/wayland_test.go b/server/internal/desktop/wayland_test.go
new file mode 100644
index 000000000..8bbd2531f
--- /dev/null
+++ b/server/internal/desktop/wayland_test.go
@@ -0,0 +1,41 @@
+package desktop
+
+import "testing"
+
+func TestMapKeyConvertsX11Keycodes(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ in uint32
+ want uint16
+ }{
+ {name: "escape", in: 9, want: 1},
+ {name: "a", in: 38, want: 30},
+ {name: "f12", in: 96, want: 88},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ got, ok := mapKey(test.in)
+ if !ok || got != test.want {
+ t.Fatalf("mapKey(%d) = (%d, %v), want (%d, true)", test.in, got, ok, test.want)
+ }
+ })
+ }
+}
+
+func TestMapButton(t *testing.T) {
+ for _, test := range []struct {
+ in uint32
+ want uint16
+ }{
+ {in: 1, want: btnLeft},
+ {in: 2, want: btnMiddle},
+ {in: 3, want: btnRight},
+ } {
+ got, ok := mapButton(test.in)
+ if !ok || got != test.want {
+ t.Fatalf("mapButton(%d) = (%d, %v), want (%d, true)", test.in, got, ok, test.want)
+ }
+ }
+ if _, ok := mapButton(9); ok {
+ t.Fatal("mapButton(9) unexpectedly succeeded")
+ }
+}
diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go
index 907f1abc1..d61f0ab03 100644
--- a/server/internal/desktop/xorg.go
+++ b/server/internal/desktop/xorg.go
@@ -11,14 +11,30 @@ import (
)
func (manager *DesktopManagerCtx) Move(x, y int) {
+ if manager.waylandInput != nil {
+ if err := manager.waylandInput.move(x, y); err != nil {
+ manager.logger.Warn().Err(err).Msg("Wayland pointer move failed")
+ }
+ return
+ }
xorg.Move(x, y)
}
func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) {
+ if manager.waylandInput != nil {
+ return manager.waylandInput.cursorX, manager.waylandInput.cursorY
+ }
return xorg.GetCursorPosition()
}
func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) {
+ if manager.waylandInput != nil {
+ returnErr := manager.waylandInput.scroll(deltaX, deltaY)
+ if returnErr != nil {
+ manager.logger.Warn().Err(returnErr).Msg("Wayland scroll failed")
+ }
+ return
+ }
if manager.config.UseInputDriver {
// XI2.1 smooth scrolling via xf86-input-neko: set modifier before the
// driver posts the motion event so the X server sees Ctrl held.
@@ -37,22 +53,39 @@ func (manager *DesktopManagerCtx) Scroll(deltaX, deltaY int, controlKey bool) {
}
func (manager *DesktopManagerCtx) ButtonDown(code uint32) error {
+ if manager.waylandInput != nil {
+ return manager.waylandInput.button(code, true)
+ }
return xorg.ButtonDown(code)
}
func (manager *DesktopManagerCtx) KeyDown(code uint32) error {
+ if manager.waylandInput != nil {
+ return manager.waylandInput.key(code, true)
+ }
return xorg.KeyDown(code)
}
func (manager *DesktopManagerCtx) ButtonUp(code uint32) error {
+ if manager.waylandInput != nil {
+ return manager.waylandInput.button(code, false)
+ }
return xorg.ButtonUp(code)
}
func (manager *DesktopManagerCtx) KeyUp(code uint32) error {
+ if manager.waylandInput != nil {
+ return manager.waylandInput.key(code, false)
+ }
return xorg.KeyUp(code)
}
func (manager *DesktopManagerCtx) ButtonPress(code uint32) error {
+ if manager.waylandInput != nil {
+ manager.ResetKeys()
+ defer manager.ResetKeys()
+ return manager.ButtonDown(code)
+ }
xorg.ResetKeys()
defer xorg.ResetKeys()
@@ -60,6 +93,16 @@ func (manager *DesktopManagerCtx) ButtonPress(code uint32) error {
}
func (manager *DesktopManagerCtx) KeyPress(codes ...uint32) error {
+ if manager.waylandInput != nil {
+ manager.ResetKeys()
+ defer manager.ResetKeys()
+ for _, code := range codes {
+ if err := manager.KeyDown(code); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
xorg.ResetKeys()
defer xorg.ResetKeys()
@@ -77,10 +120,19 @@ func (manager *DesktopManagerCtx) KeyPress(codes ...uint32) error {
}
func (manager *DesktopManagerCtx) ResetKeys() {
+ if manager.waylandInput != nil {
+ if err := manager.waylandInput.resetKeys(); err != nil {
+ manager.logger.Warn().Err(err).Msg("Wayland key reset failed")
+ }
+ return
+ }
xorg.ResetKeys()
}
func (manager *DesktopManagerCtx) ScreenConfigurations() []types.ScreenSize {
+ if manager.waylandInput != nil {
+ return []types.ScreenSize{manager.screenSize}
+ }
var configs []types.ScreenSize
for _, size := range xorg.ScreenConfigurations {
for _, fps := range size.Rates {
@@ -100,6 +152,21 @@ func (manager *DesktopManagerCtx) ScreenConfigurations() []types.ScreenSize {
}
func (manager *DesktopManagerCtx) SetScreenSize(screenSize types.ScreenSize) (types.ScreenSize, error) {
+ if manager.waylandInput != nil {
+ input, err := newWaylandInput(screenSize.Width, screenSize.Height)
+ if err != nil {
+ return manager.screenSize, err
+ }
+ mu.Lock()
+ manager.emmiter.Emit("before_screen_size_change")
+ oldInput := manager.waylandInput
+ manager.waylandInput = input
+ manager.screenSize = screenSize
+ manager.emmiter.Emit("after_screen_size_change")
+ mu.Unlock()
+ oldInput.close()
+ return screenSize, nil
+ }
mu.Lock()
manager.emmiter.Emit("before_screen_size_change")
@@ -118,10 +185,16 @@ func (manager *DesktopManagerCtx) SetScreenSize(screenSize types.ScreenSize) (ty
}
func (manager *DesktopManagerCtx) GetScreenSize() types.ScreenSize {
+ if manager.waylandInput != nil {
+ return manager.screenSize
+ }
return xorg.GetScreenSize()
}
func (manager *DesktopManagerCtx) SetKeyboardMap(kbd types.KeyboardMap) error {
+ if manager.waylandInput != nil {
+ return nil
+ }
// TOOD: Use native API.
cmd := exec.Command("setxkbmap", "-layout", kbd.Layout, "-variant", kbd.Variant)
_, err := cmd.Output()
@@ -129,6 +202,9 @@ func (manager *DesktopManagerCtx) SetKeyboardMap(kbd types.KeyboardMap) error {
}
func (manager *DesktopManagerCtx) GetKeyboardMap() (*types.KeyboardMap, error) {
+ if manager.waylandInput != nil {
+ return &types.KeyboardMap{}, nil
+ }
// TOOD: Use native API.
cmd := exec.Command("setxkbmap", "-query")
res, err := cmd.Output()
@@ -154,6 +230,9 @@ func (manager *DesktopManagerCtx) GetKeyboardMap() (*types.KeyboardMap, error) {
}
func (manager *DesktopManagerCtx) SetKeyboardModifiers(mod types.KeyboardModifiers) {
+ if manager.waylandInput != nil {
+ return
+ }
if mod.Shift != nil {
xorg.SetKeyboardModifier(xorg.KbdModShift, *mod.Shift)
}
@@ -188,6 +267,9 @@ func (manager *DesktopManagerCtx) SetKeyboardModifiers(mod types.KeyboardModifie
}
func (manager *DesktopManagerCtx) GetKeyboardModifiers() types.KeyboardModifiers {
+ if manager.waylandInput != nil {
+ return types.KeyboardModifiers{}
+ }
modifiers := xorg.GetKeyboardModifiers()
isset := func(mod xorg.KbdMod) *bool {
@@ -208,9 +290,15 @@ func (manager *DesktopManagerCtx) GetKeyboardModifiers() types.KeyboardModifiers
}
func (manager *DesktopManagerCtx) GetCursorImage() *types.CursorImage {
+ if manager.waylandInput != nil {
+ return nil
+ }
return xorg.GetCursorImage()
}
func (manager *DesktopManagerCtx) GetScreenshotImage() *image.RGBA {
+ if manager.waylandInput != nil {
+ return nil
+ }
return xorg.GetScreenshotImage()
}
diff --git a/webpage/docs/configuration/desktop.md b/webpage/docs/configuration/desktop.md
index b492e9cec..41c6fa3a5 100644
--- a/webpage/docs/configuration/desktop.md
+++ b/webpage/docs/configuration/desktop.md
@@ -10,14 +10,16 @@ import configOptions from './help.json';
This section describes how to configure the desktop environment inside neko.
-Neko uses the [X Server](https://www.x.org/archive/X11R7.6/doc/man/man1/Xserver.1.xhtml) as the display server with [Openbox](http://openbox.org/wiki/Main_Page) as the default window manager. For audio, [PulseAudio](https://www.freedesktop.org/wiki/Software/PulseAudio/) is used.
+Neko uses the [X Server](https://www.x.org/archive/X11R7.6/doc/man/man1/Xserver.1.xhtml) as the display server with [Openbox](http://openbox.org/wiki/Main_Page) as the default window manager. For audio, [PulseAudio](https://www.freedesktop.org/wiki/Software/PulseAudio/) is used. Set to use a Wayland compositor and `/dev/uinput` for desktop input instead.
- refers to the X server that is running on the system. If it is not specified, the environment variable `DISPLAY` is used. The same display is referred to in the [Capture](capture#video.display) configuration to capture the screen. In most cases, we want to use the same display for both.
+- disables X11 desktop initialization and injects pointer and keyboard events through a `/dev/uinput` virtual device. The container must have access to `/dev/uinput`; the compositor must accept libinput devices.
- refers to the screen resolution and refresh rate. The format is `x@`. If not specified, the default is `1280x720@30`.
:::tip
diff --git a/webpage/docs/configuration/help.json b/webpage/docs/configuration/help.json
index e97ccbf23..dc5495a33 100644
--- a/webpage/docs/configuration/help.json
+++ b/webpage/docs/configuration/help.json
@@ -260,6 +260,15 @@
"type": "string",
"description": "X display to use for desktop sharing"
},
+ {
+ "key": [
+ "desktop",
+ "wayland"
+ ],
+ "type": "boolean",
+ "defaultValue": false,
+ "description": "use Wayland desktop input and screen management"
+ },
{
"key": [
"desktop",
From cdbc2296236d0c360accf3ba43214dea0501114e Mon Sep 17 00:00:00 2001
From: Chris Lee <23645059+chruffins@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:45:24 +0000
Subject: [PATCH 3/3] Support Wayland output resizing
---
server/internal/config/desktop.go | 18 ++++++++++++++++--
server/internal/desktop/wayland.go | 6 ++++++
server/internal/desktop/xorg.go | 11 ++++++++++-
webpage/docs/configuration/desktop.md | 4 ++++
webpage/docs/configuration/help.json | 20 ++++++++++++++++++++
5 files changed, 56 insertions(+), 3 deletions(-)
diff --git a/server/internal/config/desktop.go b/server/internal/config/desktop.go
index a1c835789..51a9508bf 100644
--- a/server/internal/config/desktop.go
+++ b/server/internal/config/desktop.go
@@ -13,8 +13,10 @@ import (
)
type Desktop struct {
- Display string
- Wayland bool
+ Display string
+ Wayland bool
+ WaylandOutput string
+ WaylandResizeCommand string
ScreenSize types.ScreenSize
@@ -37,6 +39,16 @@ func (Desktop) Init(cmd *cobra.Command) error {
return err
}
+ cmd.PersistentFlags().String("desktop.wayland.output", "HEADLESS-1", "Wayland output name used for resizing")
+ if err := viper.BindPFlag("desktop.wayland.output", cmd.PersistentFlags().Lookup("desktop.wayland.output")); err != nil {
+ return err
+ }
+
+ cmd.PersistentFlags().String("desktop.wayland.resize_command", "wlr-randr", "Wayland output resize executable")
+ if err := viper.BindPFlag("desktop.wayland.resize_command", cmd.PersistentFlags().Lookup("desktop.wayland.resize_command")); err != nil {
+ return err
+ }
+
cmd.PersistentFlags().String("desktop.screen", "1280x720@30", "default screen size and framerate")
if err := viper.BindPFlag("desktop.screen", cmd.PersistentFlags().Lookup("desktop.screen")); err != nil {
return err
@@ -82,6 +94,8 @@ func (Desktop) InitV2(cmd *cobra.Command) error {
func (s *Desktop) Set() {
s.Display = viper.GetString("desktop.display")
s.Wayland = viper.GetBool("desktop.wayland")
+ s.WaylandOutput = viper.GetString("desktop.wayland.output")
+ s.WaylandResizeCommand = viper.GetString("desktop.wayland.resize_command")
// Display is provided by env variable unless explicitly set
if s.Display == "" {
diff --git a/server/internal/desktop/wayland.go b/server/internal/desktop/wayland.go
index e847e131a..6e200e873 100644
--- a/server/internal/desktop/wayland.go
+++ b/server/internal/desktop/wayland.go
@@ -169,6 +169,12 @@ func (input *waylandInput) sync() error {
return input.emit(evSyn, 0, 0)
}
+func (input *waylandInput) position() (int, int) {
+ input.mu.Lock()
+ defer input.mu.Unlock()
+ return input.cursorX, input.cursorY
+}
+
func (input *waylandInput) move(x, y int) error {
input.mu.Lock()
defer input.mu.Unlock()
diff --git a/server/internal/desktop/xorg.go b/server/internal/desktop/xorg.go
index d61f0ab03..1c18e827d 100644
--- a/server/internal/desktop/xorg.go
+++ b/server/internal/desktop/xorg.go
@@ -1,6 +1,7 @@
package desktop
import (
+ "fmt"
"image"
"os/exec"
"regexp"
@@ -22,7 +23,7 @@ func (manager *DesktopManagerCtx) Move(x, y int) {
func (manager *DesktopManagerCtx) GetCursorPosition() (int, int) {
if manager.waylandInput != nil {
- return manager.waylandInput.cursorX, manager.waylandInput.cursorY
+ return manager.waylandInput.position()
}
return xorg.GetCursorPosition()
}
@@ -153,6 +154,14 @@ func (manager *DesktopManagerCtx) ScreenConfigurations() []types.ScreenSize {
func (manager *DesktopManagerCtx) SetScreenSize(screenSize types.ScreenSize) (types.ScreenSize, error) {
if manager.waylandInput != nil {
+ if manager.config.WaylandResizeCommand == "" || manager.config.WaylandOutput == "" {
+ return manager.screenSize, fmt.Errorf("Wayland output resize is not configured")
+ }
+ resize := fmt.Sprintf("%dx%d", screenSize.Width, screenSize.Height)
+ if err := exec.Command(manager.config.WaylandResizeCommand, "--output", manager.config.WaylandOutput, "--mode", resize).Run(); err != nil {
+ return manager.screenSize, fmt.Errorf("resize Wayland output: %w", err)
+ }
+
input, err := newWaylandInput(screenSize.Width, screenSize.Height)
if err != nil {
return manager.screenSize, err
diff --git a/webpage/docs/configuration/desktop.md b/webpage/docs/configuration/desktop.md
index 41c6fa3a5..e6635a14f 100644
--- a/webpage/docs/configuration/desktop.md
+++ b/webpage/docs/configuration/desktop.md
@@ -15,11 +15,15 @@ Neko uses the [X Server](https://www.x.org/archive/X11R7.6/doc/man/man1/Xserver.
- refers to the X server that is running on the system. If it is not specified, the environment variable `DISPLAY` is used. The same display is referred to in the [Capture](capture#video.display) configuration to capture the screen. In most cases, we want to use the same display for both.
- disables X11 desktop initialization and injects pointer and keyboard events through a `/dev/uinput` virtual device. The container must have access to `/dev/uinput`; the compositor must accept libinput devices.
+- is the compositor output name passed to the resize command. The default `HEADLESS-1` matches the wlroots headless backend.
+- is the executable used to resize the output. It must accept `--output --mode x`, as `wlr-randr` does.
- refers to the screen resolution and refresh rate. The format is `x@`. If not specified, the default is `1280x720@30`.
:::tip
diff --git a/webpage/docs/configuration/help.json b/webpage/docs/configuration/help.json
index dc5495a33..19162bfb8 100644
--- a/webpage/docs/configuration/help.json
+++ b/webpage/docs/configuration/help.json
@@ -269,6 +269,26 @@
"defaultValue": false,
"description": "use Wayland desktop input and screen management"
},
+ {
+ "key": [
+ "desktop",
+ "wayland",
+ "output"
+ ],
+ "type": "string",
+ "defaultValue": "HEADLESS-1",
+ "description": "Wayland output name used for resizing"
+ },
+ {
+ "key": [
+ "desktop",
+ "wayland",
+ "resize_command"
+ ],
+ "type": "string",
+ "defaultValue": "wlr-randr",
+ "description": "Wayland output resize executable"
+ },
{
"key": [
"desktop",