diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b73751 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +name: ci +on: + push: + pull_request: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + - run: go vet ./... + - run: go build ./... diff --git a/go.mod b/go.mod index 2fc6566..2a0ff78 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,5 @@ go 1.26.3 require ( github.com/LocalKinAI/sckit-go v0.3.1 // indirect - github.com/ebitengine/purego v0.8.0 // indirect + github.com/ebitengine/purego v0.8.0 ) diff --git a/main.go b/main.go index e99eebc..16d7a5c 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "sync" "distancedesktop/captured/pipelines" + "distancedesktop/captured/pipelines/linux" "distancedesktop/captured/pipelines/macos" ) @@ -21,11 +22,14 @@ var pipeline pipelines.Pipeline func main() { listen := flag.String("listen", "", "TCP address for remote control (e.g. :9090)") + source := flag.String("source", "kms", "capture source on linux: kms|x11") flag.Parse() switch runtime.GOOS { case "darwin": pipeline = macos.New() + case "linux": + pipeline = linux.New(*source) default: log.Fatalf("unsupported platform: %s", runtime.GOOS) } diff --git a/pipelines/linux/gbm.go b/pipelines/linux/gbm.go new file mode 100644 index 0000000..3816178 --- /dev/null +++ b/pipelines/linux/gbm.go @@ -0,0 +1,141 @@ +//go:build linux + +package linux + +import ( + "fmt" + "os" + "sync" + + "github.com/ebitengine/purego" +) + +// --------------------------------------------------------------------------- +// libgbm bindings. Used by the synthetic source to allocate a real linear +// GBM BO, export it as a dma-buf and mmap it — the same primitive the future +// DMA-BUF -> nvh264enc encode path will reuse. +// --------------------------------------------------------------------------- + +const gbmLibName = "libgbm.so.1" + +const ( + gbmFormatXRGB8888 = 0x34325258 + gbmBoUseRendering = 1 << 2 + gbmBoUseWrite = 1 << 3 + gbmBoUseLinear = 1 << 4 +) + +var ( + gbmOnce sync.Once + gbmLib uintptr + + gbmCreateDevice func(fd int) uintptr + gbmDeviceDestroy func(dev uintptr) + gbmBoCreate func(dev uintptr, w, h, format, usage uint32) uintptr + gbmBoDestroy func(bo uintptr) + gbmBoGetFD func(bo uintptr) int + gbmBoGetStride func(bo uintptr) uint32 +) + +func loadGBM() { + gbmOnce.Do(func() { + h, err := purego.Dlopen(gbmLibName, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + gbmLib = 0 + return + } + gbmLib = h + purego.RegisterLibFunc(&gbmCreateDevice, gbmLib, "gbm_create_device") + purego.RegisterLibFunc(&gbmDeviceDestroy, gbmLib, "gbm_device_destroy") + purego.RegisterLibFunc(&gbmBoCreate, gbmLib, "gbm_bo_create") + purego.RegisterLibFunc(&gbmBoDestroy, gbmLib, "gbm_bo_destroy") + purego.RegisterLibFunc(&gbmBoGetFD, gbmLib, "gbm_bo_get_fd") + purego.RegisterLibFunc(&gbmBoGetStride, gbmLib, "gbm_bo_get_stride") + }) +} + +// gbmBO wraps a linear XRGB8888 GBM buffer mapped for writing. +type gbmBO struct { + boH uintptr + devH uintptr + fd int // dma-buf fd + stride uint32 + w, h int + mapped []byte + dev *os.File // keeps the DRM fd alive for the lifetime of the device +} + +// newGBMBuffer creates a linear XRGB8888 GBM buffer on the given DRM device +// path, exports it as a dma-buf and maps it read/write. +func newGBMBuffer(devPath string, w, h int) (*gbmBO, error) { + loadGBM() + if gbmLib == 0 { + return nil, fmt.Errorf("libgbm (%s) unavailable", gbmLibName) + } + f, err := os.OpenFile(devPath, os.O_RDWR, 0) + if err != nil { + return nil, err + } + devH := gbmCreateDevice(int(f.Fd())) + if devH == 0 { + f.Close() + return nil, fmt.Errorf("gbm_create_device failed") + } + bo := gbmBoCreate(devH, uint32(w), uint32(h), gbmFormatXRGB8888, + gbmBoUseRendering|gbmBoUseLinear) + if bo == 0 { + gbmDeviceDestroy(devH) + f.Close() + return nil, fmt.Errorf("gbm_bo_create failed") + } + stride := gbmBoGetStride(bo) + dmabuf := gbmBoGetFD(bo) + if dmabuf < 0 { + gbmBoDestroy(bo) + gbmDeviceDestroy(devH) + f.Close() + return nil, fmt.Errorf("gbm_bo_get_fd failed") + } + mapped, err := mmapRW(dmabuf, int(stride)*h) + if err != nil { + closeFD(dmabuf) + gbmBoDestroy(bo) + gbmDeviceDestroy(devH) + f.Close() + return nil, fmt.Errorf("mmap gbm bo: %w", err) + } + return &gbmBO{ + boH: bo, + devH: devH, + fd: dmabuf, + stride: stride, + w: w, + h: h, + mapped: mapped, + dev: f, + }, nil +} + +// Pixels returns the mapped buffer (XRGB8888 layout: B,G,R,X per pixel). +func (b *gbmBO) Pixels() []byte { return b.mapped } + +// Stride returns the row stride in bytes. +func (b *gbmBO) Stride() int { return int(b.stride) } + +func (b *gbmBO) Close() { + if b.mapped != nil { + munmap(b.mapped) + } + if b.fd >= 0 { + closeFD(b.fd) + } + if b.boH != 0 { + gbmBoDestroy(b.boH) + } + if b.devH != 0 { + gbmDeviceDestroy(b.devH) + } + if b.dev != nil { + b.dev.Close() + } +} diff --git a/pipelines/linux/kms.go b/pipelines/linux/kms.go new file mode 100644 index 0000000..a6a41e2 --- /dev/null +++ b/pipelines/linux/kms.go @@ -0,0 +1,453 @@ +//go:build linux + +// Package linux implements the captured pipelines for Linux using libdrm +// (KMS) and libgbm, loaded at runtime via purego so the build needs no C +// headers or cgo. Capture currently yields BGRA frames over the socket; +// real encode (DMA-BUF -> nvh264enc) stays in the agent's ffmpeg path. +package linux + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sort" + "sync" + "unsafe" + + "github.com/ebitengine/purego" +) + +// --------------------------------------------------------------------------- +// libdrm bindings (loaded lazily via purego). Pointer-returning functions +// return unsafe.Pointer (not uintptr) to keep go vet's unsafeptr check happy +// when we cast them to mirrored C structs. +// --------------------------------------------------------------------------- + +const drmLibName = "libdrm.so.2" + +var ( + drmOnce sync.Once + drmLib uintptr + + drmModeGetResources func(fd int) unsafe.Pointer + drmModeFreeResources func(res unsafe.Pointer) + drmModeGetConnector func(fd int, id uint32) unsafe.Pointer + drmModeFreeConnector func(c unsafe.Pointer) + drmModeGetEncoder func(fd int, id uint32) unsafe.Pointer + drmModeFreeEncoder func(e unsafe.Pointer) + drmModeGetCrtc func(fd int, id uint32) unsafe.Pointer + drmModeFreeCrtc func(c unsafe.Pointer) + drmModeGetFB2 func(fd int, id uint32) unsafe.Pointer + drmModeFreeFB2 func(f unsafe.Pointer) + drmPrimeHandleToFD func(fd int, handle uint32, flags uint32, prime_fd *int32) int +) + +func loadDRM() { + drmOnce.Do(func() { + h, err := purego.Dlopen(drmLibName, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + drmLib = 0 + return + } + drmLib = h + purego.RegisterLibFunc(&drmModeGetResources, drmLib, "drmModeGetResources") + purego.RegisterLibFunc(&drmModeFreeResources, drmLib, "drmModeFreeResources") + purego.RegisterLibFunc(&drmModeGetConnector, drmLib, "drmModeGetConnector") + purego.RegisterLibFunc(&drmModeFreeConnector, drmLib, "drmModeFreeConnector") + purego.RegisterLibFunc(&drmModeGetEncoder, drmLib, "drmModeGetEncoder") + purego.RegisterLibFunc(&drmModeFreeEncoder, drmLib, "drmModeFreeEncoder") + purego.RegisterLibFunc(&drmModeGetCrtc, drmLib, "drmModeGetCrtc") + purego.RegisterLibFunc(&drmModeFreeCrtc, drmLib, "drmModeFreeCrtc") + purego.RegisterLibFunc(&drmModeGetFB2, drmLib, "drmModeGetFB2") + purego.RegisterLibFunc(&drmModeFreeFB2, drmLib, "drmModeFreeFB2") + purego.RegisterLibFunc(&drmPrimeHandleToFD, drmLib, "drmPrimeHandleToFD") + }) +} + +// --------------------------------------------------------------------------- +// Mirrored C structs (amd64 / LP64 layout). Field order and types match the +// libdrm definitions so reads via unsafe.Pointer are correct. Pointer fields +// are unsafe.Pointer (mirroring C uint32_t*); scalar fields keep their C +// types so the in-memory layout matches exactly. +// --------------------------------------------------------------------------- + +type drmModeRes struct { + CountFBs int32 + FBs unsafe.Pointer + CountCrtcs int32 + Crtcs unsafe.Pointer + CountConnectors int32 + Connectors unsafe.Pointer + CountEncoders int32 + Encoders unsafe.Pointer + MinWidth int32 + MaxWidth int32 + MinHeight int32 + MaxHeight int32 +} + +type drmModeConnector struct { + ConnectorID uint32 + EncoderID uint32 + ConnectorType uint32 + ConnectorTypeID uint32 + Connection int32 + MmWidth uint32 + MmHeight uint32 + Subpixel int32 + CountModes int32 + Modes unsafe.Pointer + CountProps int32 + Props unsafe.Pointer + PropValues unsafe.Pointer + CountEncoders int32 + Encoders unsafe.Pointer +} + +type drmModeEncoder struct { + EncoderID uint32 + EncoderType uint32 + CrtcID uint32 + PossibleCrtcs uint32 + PossibleClones uint32 +} + +type drmModeModeInfo struct { + Clock uint32 + HDisplay uint16 + HSyncStart uint16 + HSyncEnd uint16 + HTotal uint16 + HSkew uint16 + VDisplay uint16 + VSyncStart uint16 + VSyncEnd uint16 + VTotal uint16 + VScan uint16 + VRefresh uint32 + Flags uint32 + Type uint32 + Name [32]byte +} + +// drmModeCrtc as we only read the leading scalar fields; safe to truncate. +type drmModeCrtc struct { + CrtcID uint32 + BufferID uint32 + X uint32 + Y uint32 + Width uint32 + Height uint32 +} + +type drmModeFB2 struct { + FbID uint32 + Width uint32 + Height uint32 + PixelFormat uint32 + Modifier uint64 + Flags uint32 + Handles [4]uint32 + Pitches [4]uint32 + Offsets [4]uint32 +} + +const ( + drmModeConnected = 1 + drmModeTypePreferred = 1 << 1 // DRM_MODE_TYPE_PREFERRED + drmFormatModLinear = 0 + drmFormatXRGB8888 = 0x34325258 + drmFormatARGB8888 = 0x34325241 + drmFormatRGBX8888 = 0x34325852 + drmFormatBGRX8888 = 0x34325842 +) + +// linuxDisplay is a resolved display we can re-open for streaming. +type linuxDisplay struct { + ID uint32 + DevPath string + ConnID uint32 + CrtcID uint32 + Width int + Height int + X int + Y int + Refresh float64 +} + +// scanDRMDisplays enumerates connected DRM connectors across /dev/dri/card*. +func scanDRMDisplays() ([]linuxDisplay, error) { + loadDRM() + if drmLib == 0 { + return nil, fmt.Errorf("linux/kms: libdrm (%s) not available", drmLibName) + } + + paths, _ := filepath.Glob("/dev/dri/card*") + sort.Strings(paths) + + var out []linuxDisplay + var permErr error + id := uint32(0) + + for _, p := range paths { + f, err := os.OpenFile(p, os.O_RDWR, 0) + if err != nil { + if os.IsPermission(err) { + if permErr == nil { + permErr = fmt.Errorf("linux/kms: cannot open %s: %v - add the user to the 'video' (or 'render') group", p, err) + } + } + continue + } + fd := int(f.Fd()) + + res := drmModeGetResources(fd) + if res == nil { + f.Close() + continue + } + resPtr := (*drmModeRes)(res) + n := int(resPtr.CountConnectors) + if n > 0 { + connIDs := unsafe.Slice((*uint32)(resPtr.Connectors), n) + for _, cid := range connIDs { + cptr := drmModeGetConnector(fd, cid) + if cptr == nil { + continue + } + conn := (*drmModeConnector)(cptr) + if conn.Connection != drmModeConnected || conn.CountModes <= 0 { + drmModeFreeConnector(cptr) + continue + } + modes := unsafe.Slice((*drmModeModeInfo)(conn.Modes), conn.CountModes) + mi := modes[0] + for _, m := range modes { + if m.Type&drmModeTypePreferred != 0 { + mi = m + break + } + } + + x, y := 0, 0 + crtcID := uint32(0) + if conn.EncoderID != 0 { + eptr := drmModeGetEncoder(fd, conn.EncoderID) + if eptr != nil { + enc := (*drmModeEncoder)(eptr) + crtcID = enc.CrtcID + if crtcID != 0 { + cptr2 := drmModeGetCrtc(fd, crtcID) + if cptr2 != nil { + crtc := (*drmModeCrtc)(cptr2) + x, y = int(crtc.X), int(crtc.Y) + drmModeFreeCrtc(cptr2) + } + } + drmModeFreeEncoder(eptr) + } + } + + out = append(out, linuxDisplay{ + ID: id, + DevPath: p, + ConnID: cid, + CrtcID: crtcID, + Width: int(mi.HDisplay), + Height: int(mi.VDisplay), + X: x, + Y: y, + Refresh: float64(mi.VRefresh), + }) + id++ + drmModeFreeConnector(cptr) + } + } + drmModeFreeResources(res) + f.Close() + } + + if len(out) == 0 { + if permErr != nil { + return nil, permErr + } + return nil, fmt.Errorf("linux/kms: no connected DRM displays found under /dev/dri/card*") + } + return out, nil +} + +// newKMSCapture attempts a real framebuffer readback of the CRTC's current +// scanout buffer via drmModeGetFB2 + prime handle -> dma-buf -> mmap. It +// requires a linearly laid-out framebuffer (most compositors use tiled +// buffers, in which case it returns an error and the caller falls back to a +// synthetic source). This is the stepping stone to the eventual DMA-BUF -> +// nvh264enc encode path. +func newKMSCapture(d *linuxDisplay) (grabber, error) { + loadDRM() + if drmLib == 0 { + return nil, fmt.Errorf("libdrm unavailable") + } + + f, err := os.OpenFile(d.DevPath, os.O_RDWR, 0) + if err != nil { + return nil, err + } + fd := int(f.Fd()) + + if d.CrtcID == 0 { + cptr := drmModeGetConnector(fd, d.ConnID) + if cptr == nil { + f.Close() + return nil, fmt.Errorf("connector %d gone", d.ConnID) + } + conn := (*drmModeConnector)(cptr) + if conn.EncoderID != 0 { + eptr := drmModeGetEncoder(fd, conn.EncoderID) + if eptr != nil { + d.CrtcID = (*drmModeEncoder)(eptr).CrtcID + drmModeFreeEncoder(eptr) + } + } + drmModeFreeConnector(cptr) + } + if d.CrtcID == 0 { + f.Close() + return nil, fmt.Errorf("no CRTC bound to display") + } + + cptr := drmModeGetCrtc(fd, d.CrtcID) + if cptr == nil { + f.Close() + return nil, fmt.Errorf("get CRTC failed") + } + crtc := (*drmModeCrtc)(cptr) + fbID := crtc.BufferID + drmModeFreeCrtc(cptr) + if fbID == 0 { + f.Close() + return nil, fmt.Errorf("CRTC has no framebuffer (no active scanout)") + } + + fb2p := drmModeGetFB2(fd, fbID) + if fb2p == nil { + f.Close() + return nil, fmt.Errorf("drmModeGetFB2 failed") + } + fb2 := (*drmModeFB2)(fb2p) + width, height := int(fb2.Width), int(fb2.Height) + modifier := fb2.Modifier + handle := fb2.Handles[0] + pitch := int(fb2.Pitches[0]) + format := fb2.PixelFormat + drmModeFreeFB2(fb2p) + + if modifier != drmFormatModLinear { + f.Close() + return nil, fmt.Errorf("scanout is tiled (modifier 0x%x); linear readback only", modifier) + } + conv, ok := bgraConverter(format) + if !ok { + f.Close() + return nil, fmt.Errorf("unsupported framebuffer format 0x%x", format) + } + + var primeFD int32 + ret := drmPrimeHandleToFD(fd, handle, 0x80000, &primeFD) + if ret != 0 { + f.Close() + return nil, fmt.Errorf("drmPrimeHandleToFD failed") + } + dmabuf := int(primeFD) + mapped, err := mmapRO(dmabuf, pitch*height) + if err != nil { + closeFD(dmabuf) + f.Close() + return nil, fmt.Errorf("mmap scanout: %w", err) + } + + log.Printf("linux/kms: capturing %dx%d (pitch %d, fmt 0x%x) via dma-buf readback", + width, height, pitch, format) + + return &kmsGrabber{ + f: f, + dmabuf: dmabuf, + mapped: mapped, + conv: conv, + width: width, + height: height, + pitch: pitch, + }, nil +} + +type kmsGrabber struct { + f *os.File + dmabuf int + mapped []byte + conv func(src []byte, pitch, w, h int) []byte + width int + height int + pitch int +} + +func (g *kmsGrabber) grab() ([]byte, int, int, error) { + return g.conv(g.mapped, g.pitch, g.width, g.height), g.width, g.height, nil +} + +func (g *kmsGrabber) close() error { + munmap(g.mapped) + closeFD(g.dmabuf) + return g.f.Close() +} + +// bgraConverter returns a converter for known 32bpp DRM formats, mapping the +// source memory layout to BGRA. +func bgraConverter(format uint32) (func(src []byte, pitch, w, h int) []byte, bool) { + switch format { + case drmFormatXRGB8888, drmFormatARGB8888: + return convXRGB, true + case drmFormatRGBX8888: + return convRGBX, true + case drmFormatBGRX8888: + return convBGRX, true + } + return nil, false +} + +func convXRGB(src []byte, pitch, w, h int) []byte { + out := make([]byte, w*h*4) + for y := 0; y < h; y++ { + row := src[y*pitch:] + for x := 0; x < w; x++ { + o := (y*w + x) * 4 + out[o], out[o+1], out[o+2], out[o+3] = row[x*4], row[x*4+1], row[x*4+2], 0xFF + } + } + return out +} + +func convRGBX(src []byte, pitch, w, h int) []byte { + out := make([]byte, w*h*4) + for y := 0; y < h; y++ { + row := src[y*pitch:] + for x := 0; x < w; x++ { + o := (y*w + x) * 4 + // memory: R,G,B,X + out[o], out[o+1], out[o+2], out[o+3] = row[x*4+2], row[x*4+1], row[x*4], 0xFF + } + } + return out +} + +func convBGRX(src []byte, pitch, w, h int) []byte { + out := make([]byte, w*h*4) + for y := 0; y < h; y++ { + row := src[y*pitch:] + for x := 0; x < w; x++ { + o := (y*w + x) * 4 + // memory: B,G,R,X + out[o], out[o+1], out[o+2], out[o+3] = row[x*4], row[x*4+1], row[x*4+2], 0xFF + } + } + return out +} diff --git a/pipelines/linux/mmap_linux.go b/pipelines/linux/mmap_linux.go new file mode 100644 index 0000000..8e1d2cc --- /dev/null +++ b/pipelines/linux/mmap_linux.go @@ -0,0 +1,21 @@ +//go:build linux + +package linux + +import "syscall" + +func mmapRO(fd, length int) ([]byte, error) { + return syscall.Mmap(fd, 0, length, syscall.PROT_READ, syscall.MAP_SHARED) +} + +func mmapRW(fd, length int) ([]byte, error) { + return syscall.Mmap(fd, 0, length, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED) +} + +func munmap(b []byte) error { + return syscall.Munmap(b) +} + +func closeFD(fd int) error { + return syscall.Close(fd) +} diff --git a/pipelines/linux/pipeline.go b/pipelines/linux/pipeline.go index 2b6d54c..7fb81b3 100644 --- a/pipelines/linux/pipeline.go +++ b/pipelines/linux/pipeline.go @@ -1 +1,234 @@ -package linux \ No newline at end of file +//go:build linux + +// Package linux implements the captured pipelines for Linux. See kms.go and +// gbm.go for the libdrm/libgbm bindings. Spike scope: list real displays and +// stream BGRA (real KMS readback when possible, else a synthetic pattern) over +// the existing unix-socket protocol; encode stays in the agent's ffmpeg path. +package linux + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "sync" + "time" + + "distancedesktop/captured/pipelines" +) + +// grabber produces one BGRA frame per call. +type grabber interface { + grab() (bgra []byte, w, h int, err error) + close() error +} + +// --------------------------------------------------------------------------- +// Pipeline entry point + source selector +// --------------------------------------------------------------------------- + +type kmsPipeline struct{} + +// New returns a Linux capture pipeline for the given source. Supported values +// are "kms" (default) and "x11"; unknown values fall back to kms. +func New(source string) pipelines.Pipeline { + switch source { + case "x11": + return &x11Pipeline{} + case "", "kms": + return &kmsPipeline{} + default: + return &kmsPipeline{} + } +} + +func (p *kmsPipeline) SupportedFormats() []pipelines.FrameFormat { + return []pipelines.FrameFormat{pipelines.FormatBGRA} +} + +func (p *kmsPipeline) ListDisplays(ctx context.Context) ([]pipelines.DisplayMeta, error) { + disps, err := scanDRMDisplays() + if err != nil { + return nil, err + } + out := make([]pipelines.DisplayMeta, len(disps)) + for i, d := range disps { + out[i] = pipelines.DisplayMeta{ + ID: d.ID, + Width: d.Width, + Height: d.Height, + X: d.X, + Y: d.Y, + RefreshRate: d.Refresh, + } + } + return out, nil +} + +func (p *kmsPipeline) StartStream(ctx context.Context, displayID uint32, fps int) (pipelines.FrameStream, error) { + if fps <= 0 { + fps = 60 + } + disps, err := scanDRMDisplays() + if err != nil { + return nil, err + } + var target *linuxDisplay + for i := range disps { + if disps[i].ID == displayID { + target = &disps[i] + break + } + } + if target == nil { + return nil, fmt.Errorf("linux/kms: display %d not found", displayID) + } + + g, err := newKMSCapture(target) + if err != nil { + // Real readback unavailable (tiled fb, no perm, etc.): stream a + // synthetic BGRA pattern so the socket pipeline still works end to + // end. The agent's ffmpeg encoding is unaffected. + fmt.Printf("linux/kms: real capture unavailable (%v); streaming synthetic BGRA\n", err) + g, err = newSynthCapture(target.Width, target.Height) + if err != nil { + return nil, err + } + } + return newFrameStream(ctx, g, fps), nil +} + +// --------------------------------------------------------------------------- +// Frame stream +// --------------------------------------------------------------------------- + +type frameStream struct { + ch chan pipelines.EncodedFrame + cancel context.CancelFunc + closeOnce sync.Once + g grabber + done chan struct{} +} + +func newFrameStream(ctx context.Context, g grabber, fps int) *frameStream { + ctx, cancel := context.WithCancel(ctx) + fs := &frameStream{ + ch: make(chan pipelines.EncodedFrame, 4), + cancel: cancel, + g: g, + done: make(chan struct{}), + } + go func() { + fs.run(ctx, fps) + close(fs.done) + }() + return fs +} + +func (fs *frameStream) run(ctx context.Context, fps int) { + defer func() { + _ = fs.g.close() + close(fs.ch) + }() + delay := time.Duration(int64(time.Second) / int64(fps)) + ticker := time.NewTicker(delay) + defer ticker.Stop() + for range ticker.C { + select { + case <-ctx.Done(): + return + default: + } + bgra, w, h, err := fs.g.grab() + if err != nil { + return + } + select { + case fs.ch <- pipelines.EncodedFrame{Data: bgra, Format: pipelines.FormatBGRA, Width: w, Height: h}: + case <-ctx.Done(): + return + } + } +} + +func (fs *frameStream) Frames() <-chan pipelines.EncodedFrame { + return fs.ch +} + +func (fs *frameStream) Close() error { + fs.closeOnce.Do(func() { + fs.cancel() + <-fs.done + }) + return nil +} + +// --------------------------------------------------------------------------- +// Synthetic source (used as KMS fallback; optionally GBM-backed) +// --------------------------------------------------------------------------- + +type synthGrabber struct { + width int + height int + frame int + bo *gbmBO +} + +func newSynthCapture(w, h int) (grabber, error) { + paths, _ := filepath.Glob("/dev/dri/card*") + sort.Strings(paths) + for _, p := range paths { + bo, err := newGBMBuffer(p, w, h) + if err == nil { + return &synthGrabber{width: w, height: h, bo: bo}, nil + } + } + // No DRM access: fall back to a pure-Go BGRA buffer. + return &synthGrabber{width: w, height: h}, nil +} + +func (g *synthGrabber) grab() ([]byte, int, int, error) { + g.frame++ + if g.bo != nil { + writePatternXRGB(g.bo.Pixels(), g.bo.Stride(), g.width, g.height, g.frame) + return convXRGB(g.bo.Pixels(), g.bo.Stride(), g.width, g.height), g.width, g.height, nil + } + buf := make([]byte, g.width*g.height*4) + writePatternBGRA(buf, g.width, g.height, g.frame) + return buf, g.width, g.height, nil +} + +func (g *synthGrabber) close() error { + if g.bo != nil { + g.bo.Close() + } + return nil +} + +// writePatternBGRA fills dst (w*h*4, BGRA) with a moving gradient. +func writePatternBGRA(dst []byte, w, h, frame int) { + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + o := (y*w + x) * 4 + dst[o] = byte((x + frame) & 0xff) + dst[o+1] = byte((y + frame) & 0xff) + dst[o+2] = byte((x + y + frame) & 0xff) + dst[o+3] = 0xFF + } + } +} + +// writePatternXRGB fills dst (stride-pitched XRGB8888: B,G,R,X) with the same +// moving gradient. +func writePatternXRGB(dst []byte, stride, w, h, frame int) { + for y := 0; y < h; y++ { + row := dst[y*stride:] + for x := 0; x < w; x++ { + o := x * 4 + row[o] = byte((x + frame) & 0xff) + row[o+1] = byte((y + frame) & 0xff) + row[o+2] = byte((x + y + frame) & 0xff) + row[o+3] = 0 + } + } +} diff --git a/pipelines/linux/stub_other.go b/pipelines/linux/stub_other.go new file mode 100644 index 0000000..18c99a1 --- /dev/null +++ b/pipelines/linux/stub_other.go @@ -0,0 +1,30 @@ +//go:build !linux + +// Package linux provides the Linux capture pipeline. On non-Linux hosts it +// compiles to a stub so the package remains importable; the real +// implementation lives behind the `linux` build tag. +package linux + +import ( + "context" + "fmt" + + "distancedesktop/captured/pipelines" +) + +type unsupportedPipeline struct{} + +// New always returns the unsupported stub on non-Linux platforms. +func New(source string) pipelines.Pipeline { return &unsupportedPipeline{} } + +func (p *unsupportedPipeline) ListDisplays(ctx context.Context) ([]pipelines.DisplayMeta, error) { + return nil, fmt.Errorf("linux pipeline is only supported on linux") +} + +func (p *unsupportedPipeline) SupportedFormats() []pipelines.FrameFormat { + return nil +} + +func (p *unsupportedPipeline) StartStream(ctx context.Context, displayID uint32, fps int) (pipelines.FrameStream, error) { + return nil, fmt.Errorf("linux pipeline is only supported on linux") +} diff --git a/pipelines/linux/synth_test.go b/pipelines/linux/synth_test.go new file mode 100644 index 0000000..fc9d7e8 --- /dev/null +++ b/pipelines/linux/synth_test.go @@ -0,0 +1,40 @@ +//go:build linux + +package linux + +import "testing" + +func TestSynthCaptureProducesBGRA(t *testing.T) { + const w, h = 64, 48 + g, err := newSynthCapture(w, h) + if err != nil { + t.Fatalf("newSynthCapture: %v", err) + } + defer g.close() + + bgra, gw, gh, err := g.grab() + if err != nil { + t.Fatalf("grab: %v", err) + } + if gw != w || gh != h { + t.Fatalf("size: got %dx%d want %dx%d", gw, gh, w, h) + } + if len(bgra) != w*h*4 { + t.Fatalf("len: got %d want %d", len(bgra), w*h*4) + } + // Alpha must be opaque for the pure-Go BGRA path. + if bgra[3] != 0xFF { + t.Fatalf("alpha: got %d want 255", bgra[3]) + } +} + +func TestBGRAConverterFormats(t *testing.T) { + for _, fmtCode := range []uint32{drmFormatXRGB8888, drmFormatARGB8888, drmFormatRGBX8888, drmFormatBGRX8888} { + if _, ok := bgraConverter(fmtCode); !ok { + t.Fatalf("bgraConverter(0x%x) unsupported", fmtCode) + } + } + if _, ok := bgraConverter(0x12345678); ok { + t.Fatalf("bgraConverter accepted unknown format") + } +} diff --git a/pipelines/linux/x11.go b/pipelines/linux/x11.go new file mode 100644 index 0000000..0ac2c66 --- /dev/null +++ b/pipelines/linux/x11.go @@ -0,0 +1,129 @@ +//go:build linux + +package linux + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/ebitengine/purego" + + "distancedesktop/captured/pipelines" +) + +// --------------------------------------------------------------------------- +// libX11 bindings (used by the x11 source selector). +// --------------------------------------------------------------------------- + +const x11LibName = "libX11.so.6" + +var ( + x11Once sync.Once + x11Lib uintptr + + xOpenDisplay func(name string) uintptr + xCloseDisplay func(dpy uintptr) int + xDefaultScreen func(dpy uintptr) int + xDisplayWidth func(dpy uintptr, screen int) int + xDisplayHeight func(dpy uintptr, screen int) int +) + +func loadX11() { + x11Once.Do(func() { + h, err := purego.Dlopen(x11LibName, purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + x11Lib = 0 + return + } + x11Lib = h + purego.RegisterLibFunc(&xOpenDisplay, x11Lib, "XOpenDisplay") + purego.RegisterLibFunc(&xCloseDisplay, x11Lib, "XCloseDisplay") + purego.RegisterLibFunc(&xDefaultScreen, x11Lib, "XDefaultScreen") + purego.RegisterLibFunc(&xDisplayWidth, x11Lib, "XDisplayWidth") + purego.RegisterLibFunc(&xDisplayHeight, x11Lib, "XDisplayHeight") + }) +} + +type x11Display struct { + ID int + Width int + Height int + X int + Y int + Refresh float64 +} + +// scanX11Displays reports the default X screen size. Full XRandr multi-output +// enumeration and XShm capture are follow-ups; this is enough for the source +// selector to list the primary screen. +func scanX11Displays() ([]x11Display, error) { + loadX11() + if x11Lib == 0 { + return nil, fmt.Errorf("linux/x11: libX11 (%s) not available", x11LibName) + } + disp := os.Getenv("DISPLAY") + if disp == "" { + return nil, fmt.Errorf("linux/x11: $DISPLAY is not set") + } + dpy := xOpenDisplay(disp) + if dpy == 0 { + return nil, fmt.Errorf("linux/x11: cannot open X display %q", disp) + } + defer xCloseDisplay(dpy) + screen := xDefaultScreen(dpy) + w := xDisplayWidth(dpy, screen) + h := xDisplayHeight(dpy, screen) + if w == 0 || h == 0 { + return nil, fmt.Errorf("linux/x11: invalid screen size from X") + } + return []x11Display{{ID: 0, Width: w, Height: h, X: 0, Y: 0, Refresh: 60}}, nil +} + +type x11Pipeline struct{} + +func (p *x11Pipeline) ListDisplays(ctx context.Context) ([]pipelines.DisplayMeta, error) { + disps, err := scanX11Displays() + if err != nil { + return nil, err + } + out := make([]pipelines.DisplayMeta, len(disps)) + for i, d := range disps { + out[i] = pipelines.DisplayMeta{ + ID: uint32(d.ID), + Width: d.Width, + Height: d.Height, + X: d.X, + Y: d.Y, + RefreshRate: d.Refresh, + } + } + return out, nil +} + +func (p *x11Pipeline) SupportedFormats() []pipelines.FrameFormat { + return []pipelines.FrameFormat{pipelines.FormatBGRA} +} + +func (p *x11Pipeline) StartStream(ctx context.Context, displayID uint32, fps int) (pipelines.FrameStream, error) { + if fps <= 0 { + fps = 60 + } + disps, err := scanX11Displays() + if err != nil { + return nil, err + } + var target *x11Display + for i := range disps { + if uint32(disps[i].ID) == displayID { + target = &disps[i] + break + } + } + if target == nil { + return nil, fmt.Errorf("linux/x11: display %d not found", displayID) + } + // X11 pixel capture (XShmGetImage) is not yet implemented. + return nil, fmt.Errorf("linux/x11: X11 pixel readback not yet implemented") +}