package screenshot import ( "fmt" "sync" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy" wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" ) type WaylandOutput struct { wlOutput *client.Output globalName uint32 name string x, y int32 width int32 height int32 scale int32 fractionalScale float64 transform int32 } type CaptureResult struct { Buffer *ShmBuffer Region Region YInverted bool Format uint32 } type Screenshoter struct { config Config display *client.Display registry *client.Registry ctx *client.Context compositor *client.Compositor shm *client.Shm screencopy *wlr_screencopy.ZwlrScreencopyManagerV1 outputs map[uint32]*WaylandOutput outputsMu sync.Mutex } func New(config Config) *Screenshoter { return &Screenshoter{ config: config, outputs: make(map[uint32]*WaylandOutput), } } func (s *Screenshoter) Run() (*CaptureResult, error) { if err := s.connect(); err != nil { return nil, fmt.Errorf("wayland connect: %w", err) } defer s.cleanup() if err := s.setupRegistry(); err != nil { return nil, fmt.Errorf("registry setup: %w", err) } if err := s.roundtrip(); err != nil { return nil, fmt.Errorf("roundtrip: %w", err) } if s.screencopy == nil { return nil, fmt.Errorf("compositor does not support wlr-screencopy-unstable-v1") } if err := s.roundtrip(); err != nil { return nil, fmt.Errorf("roundtrip: %w", err) } switch s.config.Mode { case ModeLastRegion: return s.captureLastRegion() case ModeRegion: return s.captureRegion() case ModeWindow: return s.captureWindow() case ModeOutput: return s.captureOutput(s.config.OutputName) case ModeFullScreen: return s.captureFullScreen() case ModeAllScreens: return s.captureAllScreens() default: return s.captureRegion() } } func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) { lastRegion := GetLastRegion() if lastRegion.IsEmpty() { return s.captureRegion() } output := s.findOutputForRegion(lastRegion) if output == nil { return s.captureRegion() } return s.captureRegionOnOutput(output, lastRegion) } func (s *Screenshoter) captureRegion() (*CaptureResult, error) { selector := NewRegionSelector(s) result, cancelled, err := selector.Run() if err != nil { return nil, fmt.Errorf("region selection: %w", err) } if cancelled || result == nil { return nil, nil } if err := SaveLastRegion(result.Region); err != nil { log.Debug("failed to save last region", "err", err) } return result, nil } func (s *Screenshoter) captureWindow() (*CaptureResult, error) { geom, err := GetActiveWindow() if err != nil { return nil, err } region := Region{ X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, } var output *WaylandOutput if geom.Output != "" { output = s.findOutputByName(geom.Output) } if output == nil { output = s.findOutputForRegion(region) } if output == nil { return nil, fmt.Errorf("could not find output for window") } switch DetectCompositor() { case CompositorHyprland: return s.captureAndCrop(output, region) case CompositorDWL: return s.captureDWLWindow(output, region, geom) default: return s.captureRegionOnOutput(output, region) } } func (s *Screenshoter) captureDWLWindow(output *WaylandOutput, region Region, geom *WindowGeometry) (*CaptureResult, error) { result, err := s.captureWholeOutput(output) if err != nil { return nil, err } scale := geom.Scale if scale <= 0 || scale == 1.0 { if output.fractionalScale > 1.0 { scale = output.fractionalScale } } if scale <= 0 { scale = 1.0 } localX := int(float64(region.X-geom.OutputX) * scale) localY := int(float64(region.Y-geom.OutputY) * scale) w := int(float64(region.Width) * scale) h := int(float64(region.Height) * scale) if localX < 0 { w += localX localX = 0 } if localY < 0 { h += localY localY = 0 } if localX+w > result.Buffer.Width { w = result.Buffer.Width - localX } if localY+h > result.Buffer.Height { h = result.Buffer.Height - localY } if w <= 0 || h <= 0 { result.Buffer.Close() return nil, fmt.Errorf("window not visible on output") } cropped, err := CreateShmBuffer(w, h, w*4) if err != nil { result.Buffer.Close() return nil, fmt.Errorf("create crop buffer: %w", err) } srcData := result.Buffer.Data() dstData := cropped.Data() for y := 0; y < h; y++ { srcY := localY + y if result.YInverted { srcY = result.Buffer.Height - 1 - (localY + y) } if srcY < 0 || srcY >= result.Buffer.Height { continue } dstY := y if result.YInverted { dstY = h - 1 - y } for x := 0; x < w; x++ { srcX := localX + x if srcX < 0 || srcX >= result.Buffer.Width { continue } si := srcY*result.Buffer.Stride + srcX*4 di := dstY*cropped.Stride + x*4 if si+3 >= len(srcData) || di+3 >= len(dstData) { continue } dstData[di+0] = srcData[si+0] dstData[di+1] = srcData[si+1] dstData[di+2] = srcData[si+2] dstData[di+3] = srcData[si+3] } } result.Buffer.Close() cropped.Format = PixelFormat(result.Format) return &CaptureResult{ Buffer: cropped, Region: region, YInverted: false, Format: result.Format, }, nil } func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) { output := s.findFocusedOutput() if output == nil { s.outputsMu.Lock() for _, o := range s.outputs { output = o break } s.outputsMu.Unlock() } if output == nil { return nil, fmt.Errorf("no output available") } return s.captureWholeOutput(output) } func (s *Screenshoter) captureOutput(name string) (*CaptureResult, error) { s.outputsMu.Lock() var output *WaylandOutput for _, o := range s.outputs { if o.name == name { output = o break } } s.outputsMu.Unlock() if output == nil { return nil, fmt.Errorf("output %q not found", name) } return s.captureWholeOutput(output) } func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) { s.outputsMu.Lock() outputs := make([]*WaylandOutput, 0, len(s.outputs)) for _, o := range s.outputs { outputs = append(outputs, o) } s.outputsMu.Unlock() if len(outputs) == 0 { return nil, fmt.Errorf("no outputs available") } if len(outputs) == 1 { return s.captureWholeOutput(outputs[0]) } // Capture all outputs first to get actual buffer sizes type capturedOutput struct { output *WaylandOutput result *CaptureResult physX int physY int } captured := make([]capturedOutput, 0, len(outputs)) var minX, minY, maxX, maxY int first := true for _, output := range outputs { result, err := s.captureWholeOutput(output) if err != nil { log.Warn("failed to capture output", "name", output.name, "err", err) continue } outX, outY := output.x, output.y scale := float64(output.scale) switch DetectCompositor() { case CompositorHyprland: if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok { outX, outY = hx, hy } if s := GetHyprlandMonitorScale(output.name); s > 0 { scale = s } case CompositorDWL: if info, ok := getOutputInfo(output.name); ok { outX, outY = info.x, info.y } } if scale <= 0 { scale = 1.0 } physX := int(float64(outX) * scale) physY := int(float64(outY) * scale) captured = append(captured, capturedOutput{ output: output, result: result, physX: physX, physY: physY, }) right := physX + result.Buffer.Width bottom := physY + result.Buffer.Height if first { minX, minY = physX, physY maxX, maxY = right, bottom first = false continue } if physX < minX { minX = physX } if physY < minY { minY = physY } if right > maxX { maxX = right } if bottom > maxY { maxY = bottom } } if len(captured) == 0 { return nil, fmt.Errorf("failed to capture any outputs") } if len(captured) == 1 { return captured[0].result, nil } totalW := maxX - minX totalH := maxY - minY compositeStride := totalW * 4 composite, err := CreateShmBuffer(totalW, totalH, compositeStride) if err != nil { for _, c := range captured { c.result.Buffer.Close() } return nil, fmt.Errorf("create composite buffer: %w", err) } composite.Clear() var format uint32 for _, c := range captured { if format == 0 { format = c.result.Format } s.blitBuffer(composite, c.result.Buffer, c.physX-minX, c.physY-minY, c.result.YInverted) c.result.Buffer.Close() } return &CaptureResult{ Buffer: composite, Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)}, Format: format, }, nil } func (s *Screenshoter) blitBuffer(dst, src *ShmBuffer, dstX, dstY int, yInverted bool) { srcData := src.Data() dstData := dst.Data() for srcY := 0; srcY < src.Height; srcY++ { actualSrcY := srcY if yInverted { actualSrcY = src.Height - 1 - srcY } dy := dstY + srcY if dy < 0 || dy >= dst.Height { continue } srcRowOff := actualSrcY * src.Stride dstRowOff := dy * dst.Stride for srcX := 0; srcX < src.Width; srcX++ { dx := dstX + srcX if dx < 0 || dx >= dst.Width { continue } si := srcRowOff + srcX*4 di := dstRowOff + dx*4 if si+3 >= len(srcData) || di+3 >= len(dstData) { continue } dstData[di+0] = srcData[si+0] dstData[di+1] = srcData[si+1] dstData[di+2] = srcData[si+2] dstData[di+3] = srcData[si+3] } } } func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult, error) { cursor := int32(0) if s.config.IncludeCursor { cursor = 1 } frame, err := s.screencopy.CaptureOutput(cursor, output.wlOutput) if err != nil { return nil, fmt.Errorf("capture output: %w", err) } result, err := s.processFrame(frame, Region{ X: output.x, Y: output.y, Width: output.width, Height: output.height, Output: output.name, }) if err != nil { return nil, err } if result.YInverted { result.Buffer.FlipVertical() result.YInverted = false } if output.transform == TransformNormal { return result, nil } invTransform := InverseTransform(output.transform) transformed, err := result.Buffer.ApplyTransform(invTransform) if err != nil { result.Buffer.Close() return nil, fmt.Errorf("apply transform: %w", err) } if transformed != result.Buffer { result.Buffer.Close() result.Buffer = transformed } result.Region.Width = int32(transformed.Width) result.Region.Height = int32(transformed.Height) return result, nil } func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*CaptureResult, error) { result, err := s.captureWholeOutput(output) if err != nil { return nil, err } outX, outY := output.x, output.y scale := float64(output.scale) if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok { outX, outY = hx, hy } if s := GetHyprlandMonitorScale(output.name); s > 0 { scale = s } if scale <= 0 { scale = 1.0 } localX := int(float64(region.X-outX) * scale) localY := int(float64(region.Y-outY) * scale) w := int(float64(region.Width) * scale) h := int(float64(region.Height) * scale) cropped, err := CreateShmBuffer(w, h, w*4) if err != nil { result.Buffer.Close() return nil, fmt.Errorf("create crop buffer: %w", err) } srcData := result.Buffer.Data() dstData := cropped.Data() for y := 0; y < h; y++ { srcY := localY + y if result.YInverted { srcY = result.Buffer.Height - 1 - (localY + y) } if srcY < 0 || srcY >= result.Buffer.Height { continue } dstY := y if result.YInverted { dstY = h - 1 - y } for x := 0; x < w; x++ { srcX := localX + x if srcX < 0 || srcX >= result.Buffer.Width { continue } si := srcY*result.Buffer.Stride + srcX*4 di := dstY*cropped.Stride + x*4 if si+3 >= len(srcData) || di+3 >= len(dstData) { continue } dstData[di+0] = srcData[si+0] dstData[di+1] = srcData[si+1] dstData[di+2] = srcData[si+2] dstData[di+3] = srcData[si+3] } } result.Buffer.Close() cropped.Format = PixelFormat(result.Format) return &CaptureResult{ Buffer: cropped, Region: region, YInverted: false, Format: result.Format, }, nil } func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Region) (*CaptureResult, error) { if output.transform != TransformNormal { return s.captureRegionOnTransformedOutput(output, region) } scale := output.fractionalScale if scale <= 0 && DetectCompositor() == CompositorHyprland { scale = GetHyprlandMonitorScale(output.name) } if scale <= 0 { scale = float64(output.scale) } if scale <= 0 { scale = 1.0 } localX := int32(float64(region.X-output.x) * scale) localY := int32(float64(region.Y-output.y) * scale) w := int32(float64(region.Width) * scale) h := int32(float64(region.Height) * scale) if DetectCompositor() == CompositorDWL { scaledOutW := int32(float64(output.width) * scale) scaledOutH := int32(float64(output.height) * scale) if localX >= scaledOutW { localX = localX % scaledOutW } if localY >= scaledOutH { localY = localY % scaledOutH } if localX+w > scaledOutW { w = scaledOutW - localX } if localY+h > scaledOutH { h = scaledOutH - localY } if localX < 0 { w += localX localX = 0 } if localY < 0 { h += localY localY = 0 } } cursor := int32(0) if s.config.IncludeCursor { cursor = 1 } frame, err := s.screencopy.CaptureOutputRegion(cursor, output.wlOutput, localX, localY, w, h) if err != nil { return nil, fmt.Errorf("capture region: %w", err) } return s.processFrame(frame, region) } func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) { result, err := s.captureWholeOutput(output) if err != nil { return nil, err } scale := output.fractionalScale if scale <= 0 && DetectCompositor() == CompositorHyprland { scale = GetHyprlandMonitorScale(output.name) } if scale <= 0 { scale = float64(output.scale) } if scale <= 0 { scale = 1.0 } localX := int(float64(region.X-output.x) * scale) localY := int(float64(region.Y-output.y) * scale) w := int(float64(region.Width) * scale) h := int(float64(region.Height) * scale) if localX < 0 { w += localX localX = 0 } if localY < 0 { h += localY localY = 0 } if localX+w > result.Buffer.Width { w = result.Buffer.Width - localX } if localY+h > result.Buffer.Height { h = result.Buffer.Height - localY } if w <= 0 || h <= 0 { result.Buffer.Close() return nil, fmt.Errorf("region not visible on output") } cropped, err := CreateShmBuffer(w, h, w*4) if err != nil { result.Buffer.Close() return nil, fmt.Errorf("create crop buffer: %w", err) } srcData := result.Buffer.Data() dstData := cropped.Data() for y := 0; y < h; y++ { srcOff := (localY+y)*result.Buffer.Stride + localX*4 dstOff := y * cropped.Stride if srcOff+w*4 <= len(srcData) && dstOff+w*4 <= len(dstData) { copy(dstData[dstOff:dstOff+w*4], srcData[srcOff:srcOff+w*4]) } } result.Buffer.Close() cropped.Format = PixelFormat(result.Format) return &CaptureResult{ Buffer: cropped, Region: region, YInverted: false, Format: result.Format, }, nil } func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1, region Region) (*CaptureResult, error) { var buf *ShmBuffer var pool *client.ShmPool var wlBuf *client.Buffer var format PixelFormat var yInverted bool ready := false failed := false frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) { format = PixelFormat(e.Format) bpp := format.BytesPerPixel() if int(e.Stride) < int(e.Width)*bpp { log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp) return } var err error buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride)) if err != nil { log.Error("failed to create buffer", "err", err) return } buf.Format = format }) frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) { yInverted = (e.Flags & 1) != 0 }) frame.SetBufferDoneHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferDoneEvent) { if buf == nil { return } var err error pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size())) if err != nil { log.Error("failed to create pool", "err", err) return } wlBuf, err = pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), uint32(format)) if err != nil { pool.Destroy() pool = nil log.Error("failed to create wl_buffer", "err", err) return } if err := frame.Copy(wlBuf); err != nil { log.Error("failed to copy frame", "err", err) } }) frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) { ready = true }) frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) { failed = true }) for !ready && !failed { if err := s.ctx.Dispatch(); err != nil { frame.Destroy() return nil, fmt.Errorf("dispatch: %w", err) } } frame.Destroy() if wlBuf != nil { wlBuf.Destroy() } if pool != nil { pool.Destroy() } if failed { if buf != nil { buf.Close() } return nil, fmt.Errorf("frame capture failed") } if format.Is24Bit() { converted, newFormat, err := buf.ConvertTo32Bit(format) if err != nil { buf.Close() return nil, fmt.Errorf("convert 24-bit to 32-bit: %w", err) } if converted != buf { buf.Close() buf = converted } format = newFormat } return &CaptureResult{ Buffer: buf, Region: region, YInverted: yInverted, Format: uint32(format), }, nil } func (s *Screenshoter) findOutputByName(name string) *WaylandOutput { s.outputsMu.Lock() defer s.outputsMu.Unlock() for _, o := range s.outputs { if o.name == name { return o } } return nil } func (s *Screenshoter) findOutputForRegion(region Region) *WaylandOutput { s.outputsMu.Lock() defer s.outputsMu.Unlock() cx := region.X + region.Width/2 cy := region.Y + region.Height/2 for _, o := range s.outputs { x, y, w, h := o.x, o.y, o.width, o.height if DetectCompositor() == CompositorHyprland { if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { x, y, w, h = hx, hy, hw, hh } } if cx >= x && cx < x+w && cy >= y && cy < y+h { return o } } for _, o := range s.outputs { x, y, w, h := o.x, o.y, o.width, o.height if DetectCompositor() == CompositorHyprland { if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { x, y, w, h = hx, hy, hw, hh } } if region.X >= x && region.X < x+w && region.Y >= y && region.Y < y+h { return o } } return nil } func (s *Screenshoter) findFocusedOutput() *WaylandOutput { if mon := GetFocusedMonitor(); mon != "" { s.outputsMu.Lock() defer s.outputsMu.Unlock() for _, o := range s.outputs { if o.name == mon { return o } } } s.outputsMu.Lock() defer s.outputsMu.Unlock() for _, o := range s.outputs { return o } return nil } func (s *Screenshoter) connect() error { display, err := client.Connect("") if err != nil { return err } s.display = display s.ctx = display.Context() return nil } func (s *Screenshoter) roundtrip() error { return wlhelpers.Roundtrip(s.display, s.ctx) } func (s *Screenshoter) setupRegistry() error { registry, err := s.display.GetRegistry() if err != nil { return err } s.registry = registry registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { s.handleGlobal(e) }) registry.SetGlobalRemoveHandler(func(e client.RegistryGlobalRemoveEvent) { s.outputsMu.Lock() delete(s.outputs, e.Name) s.outputsMu.Unlock() }) return nil } func (s *Screenshoter) handleGlobal(e client.RegistryGlobalEvent) { switch e.Interface { case client.CompositorInterfaceName: comp := client.NewCompositor(s.ctx) if err := s.registry.Bind(e.Name, e.Interface, e.Version, comp); err == nil { s.compositor = comp } case client.ShmInterfaceName: shm := client.NewShm(s.ctx) if err := s.registry.Bind(e.Name, e.Interface, e.Version, shm); err == nil { s.shm = shm } case client.OutputInterfaceName: output := client.NewOutput(s.ctx) version := e.Version if version > 4 { version = 4 } if err := s.registry.Bind(e.Name, e.Interface, version, output); err == nil { s.outputsMu.Lock() s.outputs[e.Name] = &WaylandOutput{ wlOutput: output, globalName: e.Name, scale: 1, fractionalScale: 1.0, } s.outputsMu.Unlock() s.setupOutputHandlers(e.Name, output) } case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName: sc := wlr_screencopy.NewZwlrScreencopyManagerV1(s.ctx) version := e.Version if version > 3 { version = 3 } if err := s.registry.Bind(e.Name, e.Interface, version, sc); err == nil { s.screencopy = sc } } } func (s *Screenshoter) setupOutputHandlers(name uint32, output *client.Output) { output.SetGeometryHandler(func(e client.OutputGeometryEvent) { s.outputsMu.Lock() if o, ok := s.outputs[name]; ok { o.x, o.y = e.X, e.Y o.transform = int32(e.Transform) } s.outputsMu.Unlock() }) output.SetModeHandler(func(e client.OutputModeEvent) { if e.Flags&uint32(client.OutputModeCurrent) == 0 { return } s.outputsMu.Lock() if o, ok := s.outputs[name]; ok { o.width, o.height = e.Width, e.Height } s.outputsMu.Unlock() }) output.SetScaleHandler(func(e client.OutputScaleEvent) { s.outputsMu.Lock() if o, ok := s.outputs[name]; ok { o.scale = e.Factor o.fractionalScale = float64(e.Factor) } s.outputsMu.Unlock() }) output.SetNameHandler(func(e client.OutputNameEvent) { s.outputsMu.Lock() if o, ok := s.outputs[name]; ok { o.name = e.Name } s.outputsMu.Unlock() }) } func (s *Screenshoter) cleanup() { if s.screencopy != nil { s.screencopy.Destroy() } if s.display != nil { s.ctx.Close() } } func (s *Screenshoter) GetOutputs() []*WaylandOutput { s.outputsMu.Lock() defer s.outputsMu.Unlock() out := make([]*WaylandOutput, 0, len(s.outputs)) for _, o := range s.outputs { out = append(out, o) } return out } func ListOutputs() ([]Output, error) { sc := New(DefaultConfig()) if err := sc.connect(); err != nil { return nil, err } defer sc.cleanup() if err := sc.setupRegistry(); err != nil { return nil, err } if err := sc.roundtrip(); err != nil { return nil, err } if err := sc.roundtrip(); err != nil { return nil, err } sc.outputsMu.Lock() defer sc.outputsMu.Unlock() compositor := DetectCompositor() result := make([]Output, 0, len(sc.outputs)) for _, o := range sc.outputs { out := Output{ Name: o.name, X: o.x, Y: o.y, Width: o.width, Height: o.height, Scale: o.scale, FractionalScale: o.fractionalScale, Transform: o.transform, } switch compositor { case CompositorHyprland: if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { out.X, out.Y = hx, hy out.Width, out.Height = hw, hh } if s := GetHyprlandMonitorScale(o.name); s > 0 { out.FractionalScale = s } } result = append(result, out) } return result, nil }