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/server/internal/config/desktop.go b/server/internal/config/desktop.go index 8331035e7..51a9508bf 100644 --- a/server/internal/config/desktop.go +++ b/server/internal/config/desktop.go @@ -13,7 +13,10 @@ import ( ) type Desktop struct { - Display string + Display string + Wayland bool + WaylandOutput string + WaylandResizeCommand string ScreenSize types.ScreenSize @@ -31,6 +34,21 @@ 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.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 @@ -75,6 +93,9 @@ 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/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..6e200e873 --- /dev/null +++ b/server/internal/desktop/wayland.go @@ -0,0 +1,299 @@ +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) 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() + 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..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" @@ -11,14 +12,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.position() + } 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 +54,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 +94,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 +121,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 +153,29 @@ 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 + } + 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 +194,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 +211,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 +239,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 +276,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 +299,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/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/desktop.md b/webpage/docs/configuration/desktop.md index b492e9cec..e6635a14f 100644 --- a/webpage/docs/configuration/desktop.md +++ b/webpage/docs/configuration/desktop.md @@ -10,14 +10,20 @@ 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. +- 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 8320f7e9c..19162bfb8 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", @@ -240,6 +260,35 @@ "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", + "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",