feat(output): support wlr-output-management-unstable-v1 (#12)
* feat(output): support explicit runtime output positions * feat(wayland): add wlr output management heads and configurations * feat(output): wait for first modeset before completing applies * test(output): cover relay readiness rollback and reload * test(wayland): verify stale output serial and inventory revisions * test(wayland): cover output management protocol errors * test(wayland): stabilize output inventory serial setup * test(app): cover timeout rollback through output relay * fix(wayland): maintain output management resource lifetimes and state * test(wayland): cover initial placement and released current mode * fix(app): track output apply readiness per instance * fix(app): keep output settings relay responsive during backend apply * fix(drm): tolerate connector inventory errors and report run panics * fix(app): include hotplugged outputs in pending apply * fix(app): wait for stopped instance before evaluating replacement * fix(app): reject applies while reload backend work is pending * fix(app): queue output settings relay sends in its select loop * fix(app): keep output apply and inventory consistent on DRM errors
- commit
- c746896110317de79f420e6841a9288d9e246f64
- author
- bnema · · 2026-09-27 09:00 UTC
- committer
- GitHub · · 2026-09-27 09:00 UTC
- parent
- 19da7f2
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
22 files · +2176 -34
M internal/adapters/drm/card.go +39 -0
| @@ -125,6 +125,45 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) { | |||
| 125 | 125 | return added, removed, replaced, nil | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | // ConnectedHeads inventories physical connectors independently of the desired | |
| 129 | + | // enabled state. The caller owns Scan and must invoke this on the same goroutine. | |
| 130 | + | func (c *Card) ConnectedHeads() ([]ports.OutputHead, error) { | |
| 131 | + | _, ids, err := resources(c.fd) | |
| 132 | + | if err != nil { | |
| 133 | + | return nil, err | |
| 134 | + | } | |
| 135 | + | var heads []ports.OutputHead | |
| 136 | + | for _, id := range ids { | |
| 137 | + | conn, err := readConnector(c.fd, id) | |
| 138 | + | if err != nil { | |
| 139 | + | c.log.Warn().Err(err).Uint32("connector", id).Msg("read connector for inventory") | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | if !conn.connected || len(conn.modes) == 0 { | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | monitor := readMonitor(c.path, conn.name) | |
| 146 | + | h := ports.OutputHead{Info: ports.OutputInfo{Name: conn.name, Make: monitor.Make, Model: monitor.Model, Serial: monitor.Serial, PhysicalW: conn.mmW, PhysicalH: conn.mmH}} | |
| 147 | + | if h.Info.Make == "Unknown" { | |
| 148 | + | h.Info.Make = "" | |
| 149 | + | } | |
| 150 | + | if h.Info.Model == "Unknown" { | |
| 151 | + | h.Info.Model = "" | |
| 152 | + | } | |
| 153 | + | if o := c.outputs[conn.name]; o != nil { | |
| 154 | + | h.Info = o.Info() | |
| 155 | + | m := ports.OutputMode{Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli()} | |
| 156 | + | h.Current = &m | |
| 157 | + | h.Enabled = true | |
| 158 | + | } | |
| 159 | + | for _, m := range conn.modes { | |
| 160 | + | h.Modes = append(h.Modes, ports.OutputMode{Width: int(m.HDisplay), Height: int(m.VDisplay), RefreshMilli: m.refreshMilli(), Preferred: m.Type&modeTypePrefered != 0}) | |
| 161 | + | } | |
| 162 | + | heads = append(heads, h) | |
| 163 | + | } | |
| 164 | + | return heads, nil | |
| 165 | + | } | |
| 166 | + | ||
| 128 | 167 | // Release forgets an output closed by its goroutine, freeing its CRTC and | |
| 129 | 168 | // planes for the next Scan. | |
| 130 | 169 | func (c *Card) Release(name string) { | |
| @@ -125,6 +125,45 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) { | |||||
| 125 | return added, removed, replaced, nil | 125 | return added, removed, replaced, nil | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | // ConnectedHeads inventories physical connectors independently of the desired | |||
| 129 | + | // enabled state. The caller owns Scan and must invoke this on the same goroutine. | |||
| 130 | + | func (c *Card) ConnectedHeads() ([]ports.OutputHead, error) { | |||
| 131 | + | _, ids, err := resources(c.fd) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return nil, err | |||
| 134 | + | } | |||
| 135 | + | var heads []ports.OutputHead | |||
| 136 | + | for _, id := range ids { | |||
| 137 | + | conn, err := readConnector(c.fd, id) | |||
| 138 | + | if err != nil { | |||
| 139 | + | c.log.Warn().Err(err).Uint32("connector", id).Msg("read connector for inventory") | |||
| 140 | + | continue | |||
| 141 | + | } | |||
| 142 | + | if !conn.connected || len(conn.modes) == 0 { | |||
| 143 | + | continue | |||
| 144 | + | } | |||
| 145 | + | monitor := readMonitor(c.path, conn.name) | |||
| 146 | + | h := ports.OutputHead{Info: ports.OutputInfo{Name: conn.name, Make: monitor.Make, Model: monitor.Model, Serial: monitor.Serial, PhysicalW: conn.mmW, PhysicalH: conn.mmH}} | |||
| 147 | + | if h.Info.Make == "Unknown" { | |||
| 148 | + | h.Info.Make = "" | |||
| 149 | + | } | |||
| 150 | + | if h.Info.Model == "Unknown" { | |||
| 151 | + | h.Info.Model = "" | |||
| 152 | + | } | |||
| 153 | + | if o := c.outputs[conn.name]; o != nil { | |||
| 154 | + | h.Info = o.Info() | |||
| 155 | + | m := ports.OutputMode{Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli()} | |||
| 156 | + | h.Current = &m | |||
| 157 | + | h.Enabled = true | |||
| 158 | + | } | |||
| 159 | + | for _, m := range conn.modes { | |||
| 160 | + | h.Modes = append(h.Modes, ports.OutputMode{Width: int(m.HDisplay), Height: int(m.VDisplay), RefreshMilli: m.refreshMilli(), Preferred: m.Type&modeTypePrefered != 0}) | |||
| 161 | + | } | |||
| 162 | + | heads = append(heads, h) | |||
| 163 | + | } | |||
| 164 | + | return heads, nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 128 | // Release forgets an output closed by its goroutine, freeing its CRTC and | 167 | // Release forgets an output closed by its goroutine, freeing its CRTC and | ||
| 129 | // planes for the next Scan. | 168 | // planes for the next Scan. | ||
| 130 | func (c *Card) Release(name string) { | 169 | func (c *Card) Release(name string) { | ||
M internal/adapters/drm/output.go +27 -5
| @@ -113,11 +113,16 @@ type Output struct { | |||
| 113 | 113 | kind imageKind | |
| 114 | 114 | // formats receives the direct scanout formats after each modeset; | |
| 115 | 115 | // sampled and device are what they are built from (Want). | |
| 116 | - | formats chan<- ports.OutputFormats | |
| 117 | - | sampled []ports.DMABufFormat | |
| 118 | - | device uint64 | |
| 116 | + | formats chan<- ports.OutputFormats | |
| 117 | + | sampled []ports.DMABufFormat | |
| 118 | + | device uint64 | |
| 119 | + | ready chan error // first successful modeset, or a startup failure | |
| 120 | + | readySent bool | |
| 119 | 121 | } | |
| 120 | 122 | ||
| 123 | + | // Ready reports the result of the first modeset exactly once. | |
| 124 | + | func (o *Output) Ready() <-chan error { return o.ready } | |
| 125 | + | ||
| 121 | 126 | // pendingFrame is what the pending frame commit shows. | |
| 122 | 127 | type pendingFrame struct { | |
| 123 | 128 | frame bool | |
| @@ -286,7 +291,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu | |||
| 286 | 291 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | |
| 287 | 292 | log := card.log | |
| 288 | 293 | pipe := slices.Index(card.crtcs, crtc) | |
| 289 | - | o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"} | |
| 294 | + | o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)} | |
| 290 | 295 | var err error | |
| 291 | 296 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | |
| 292 | 297 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | |
| @@ -456,6 +461,10 @@ func (o *Output) modeset() error { | |||
| 456 | 461 | o.testCursor() | |
| 457 | 462 | } | |
| 458 | 463 | o.sendFormats() | |
| 464 | + | if o.ready != nil && !o.readySent { | |
| 465 | + | o.ready <- nil | |
| 466 | + | o.readySent = true | |
| 467 | + | } | |
| 459 | 468 | return nil | |
| 460 | 469 | } | |
| 461 | 470 | ||
| @@ -873,7 +882,20 @@ func (o *Output) Close() { | |||
| 873 | 882 | ||
| 874 | 883 | // Run renders scenes and commits them until ctx ends. active reports seat | |
| 875 | 884 | // enable/disable. What the output shows and read is reported on presented. | |
| 876 | - | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) error { | |
| 885 | + | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) { | |
| 886 | + | defer func() { | |
| 887 | + | p := recover() | |
| 888 | + | if p != nil { | |
| 889 | + | runErr = fmt.Errorf("output panic: %v", p) | |
| 890 | + | } | |
| 891 | + | if o.ready != nil && !o.readySent { | |
| 892 | + | o.ready <- runErr | |
| 893 | + | o.readySent = true | |
| 894 | + | } | |
| 895 | + | if p != nil { | |
| 896 | + | panic(p) | |
| 897 | + | } | |
| 898 | + | }() | |
| 877 | 899 | r, err := newRenderer(o.Width(), o.Height()) | |
| 878 | 900 | if err != nil { | |
| 879 | 901 | return fmt.Errorf("create renderer: %w", err) | |
| @@ -113,11 +113,16 @@ type Output struct { | |||||
| 113 | kind imageKind | 113 | kind imageKind | ||
| 114 | // formats receives the direct scanout formats after each modeset; | 114 | // formats receives the direct scanout formats after each modeset; | ||
| 115 | // sampled and device are what they are built from (Want). | 115 | // sampled and device are what they are built from (Want). | ||
| 116 | - | formats chan<- ports.OutputFormats | 116 | + | formats chan<- ports.OutputFormats |
| 117 | - | sampled []ports.DMABufFormat | 117 | + | sampled []ports.DMABufFormat |
| 118 | - | device uint64 | 118 | + | device uint64 |
| 119 | + | ready chan error // first successful modeset, or a startup failure | |||
| 120 | + | readySent bool | |||
| 119 | } | 121 | } | ||
| 120 | 122 | ||||
| 123 | + | // Ready reports the result of the first modeset exactly once. | |||
| 124 | + | func (o *Output) Ready() <-chan error { return o.ready } | |||
| 125 | + | ||||
| 121 | // pendingFrame is what the pending frame commit shows. | 126 | // pendingFrame is what the pending frame commit shows. | ||
| 122 | type pendingFrame struct { | 127 | type pendingFrame struct { | ||
| 123 | frame bool | 128 | frame bool | ||
| @@ -286,7 +291,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu | |||||
| 286 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | 291 | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | ||
| 287 | log := card.log | 292 | log := card.log | ||
| 288 | pipe := slices.Index(card.crtcs, crtc) | 293 | pipe := slices.Index(card.crtcs, crtc) | ||
| 289 | - | o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"} | 294 | + | o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, formats: card.formats, sampled: card.want.Sampled, device: card.want.Device, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start", ready: make(chan error, 1)} |
| 290 | var err error | 295 | var err error | ||
| 291 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | 296 | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | ||
| 292 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | 297 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | ||
| @@ -456,6 +461,10 @@ func (o *Output) modeset() error { | |||||
| 456 | o.testCursor() | 461 | o.testCursor() | ||
| 457 | } | 462 | } | ||
| 458 | o.sendFormats() | 463 | o.sendFormats() | ||
| 464 | + | if o.ready != nil && !o.readySent { | |||
| 465 | + | o.ready <- nil | |||
| 466 | + | o.readySent = true | |||
| 467 | + | } | |||
| 459 | return nil | 468 | return nil | ||
| 460 | } | 469 | } | ||
| 461 | 470 | ||||
| @@ -873,7 +882,20 @@ func (o *Output) Close() { | |||||
| 873 | 882 | ||||
| 874 | // Run renders scenes and commits them until ctx ends. active reports seat | 883 | // Run renders scenes and commits them until ctx ends. active reports seat | ||
| 875 | // enable/disable. What the output shows and read is reported on presented. | 884 | // enable/disable. What the output shows and read is reported on presented. | ||
| 876 | - | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) error { | 885 | + | func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone) (runErr error) { |
| 886 | + | defer func() { | |||
| 887 | + | p := recover() | |||
| 888 | + | if p != nil { | |||
| 889 | + | runErr = fmt.Errorf("output panic: %v", p) | |||
| 890 | + | } | |||
| 891 | + | if o.ready != nil && !o.readySent { | |||
| 892 | + | o.ready <- runErr | |||
| 893 | + | o.readySent = true | |||
| 894 | + | } | |||
| 895 | + | if p != nil { | |||
| 896 | + | panic(p) | |||
| 897 | + | } | |||
| 898 | + | }() | |||
| 877 | r, err := newRenderer(o.Width(), o.Height()) | 899 | r, err := newRenderer(o.Width(), o.Height()) | ||
| 878 | if err != nil { | 900 | if err != nil { | ||
| 879 | return fmt.Errorf("create renderer: %w", err) | 901 | return fmt.Errorf("create renderer: %w", err) | ||
M internal/adapters/drm/output_test.go +19 -0
| @@ -525,3 +525,22 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||
| 525 | 525 | t.Fatal(err) | |
| 526 | 526 | } | |
| 527 | 527 | } | |
| 528 | + | ||
| 529 | + | func TestRunPanicReportsReadinessError(t *testing.T) { | |
| 530 | + | o, _, _ := testOutput(t) | |
| 531 | + | o.ready = make(chan error, 1) | |
| 532 | + | defer func() { | |
| 533 | + | if recover() == nil { | |
| 534 | + | t.Fatal("panic not propagated") | |
| 535 | + | } | |
| 536 | + | select { | |
| 537 | + | case err := <-o.Ready(): | |
| 538 | + | if err == nil { | |
| 539 | + | t.Fatal("panic reported as ready") | |
| 540 | + | } | |
| 541 | + | default: | |
| 542 | + | t.Fatal("missing readiness") | |
| 543 | + | } | |
| 544 | + | }() | |
| 545 | + | o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil) | |
| 546 | + | } | |
| @@ -525,3 +525,22 @@ func TestCaptureForcesDRMComposition(t *testing.T) { | |||||
| 525 | t.Fatal(err) | 525 | t.Fatal(err) | ||
| 526 | } | 526 | } | ||
| 527 | } | 527 | } | ||
| 528 | + | ||||
| 529 | + | func TestRunPanicReportsReadinessError(t *testing.T) { | |||
| 530 | + | o, _, _ := testOutput(t) | |||
| 531 | + | o.ready = make(chan error, 1) | |||
| 532 | + | defer func() { | |||
| 533 | + | if recover() == nil { | |||
| 534 | + | t.Fatal("panic not propagated") | |||
| 535 | + | } | |||
| 536 | + | select { | |||
| 537 | + | case err := <-o.Ready(): | |||
| 538 | + | if err == nil { | |||
| 539 | + | t.Fatal("panic reported as ready") | |||
| 540 | + | } | |||
| 541 | + | default: | |||
| 542 | + | t.Fatal("missing readiness") | |||
| 543 | + | } | |||
| 544 | + | }() | |||
| 545 | + | o.Run(context.Background(), func(int, int) (ports.Renderer, error) { panic("renderer panic") }, nil, nil, nil, nil, nil, nil, nil, nil) | |||
| 546 | + | } | |||
M internal/adapters/wayland/globals.go +1 -0
| @@ -12,6 +12,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 12 | 12 | func() error { return registerXDG(d, s) }, | |
| 13 | 13 | func() error { return registerLayer(d, s) }, | |
| 14 | 14 | func() error { return registerXDGOutput(d, s) }, | |
| 15 | + | func() error { return registerOutputManagement(d, s) }, | |
| 15 | 16 | func() error { return registerDecoration(d, s) }, | |
| 16 | 17 | func() error { return registerScale(d, s) }, | |
| 17 | 18 | func() error { return registerCursorShape(d, s) }, | |
| @@ -12,6 +12,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 12 | func() error { return registerXDG(d, s) }, | 12 | func() error { return registerXDG(d, s) }, | ||
| 13 | func() error { return registerLayer(d, s) }, | 13 | func() error { return registerLayer(d, s) }, | ||
| 14 | func() error { return registerXDGOutput(d, s) }, | 14 | func() error { return registerXDGOutput(d, s) }, | ||
| 15 | + | func() error { return registerOutputManagement(d, s) }, | |||
| 15 | func() error { return registerDecoration(d, s) }, | 16 | func() error { return registerDecoration(d, s) }, | ||
| 16 | func() error { return registerScale(d, s) }, | 17 | func() error { return registerScale(d, s) }, | ||
| 17 | func() error { return registerCursorShape(d, s) }, | 18 | func() error { return registerCursorShape(d, s) }, | ||
A internal/adapters/wayland/outputconfiguration.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "math" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |
| 9 | + | "github.com/bnema/purego-libwayland/server" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type outputConfiguration struct { | |
| 13 | + | manager *outputManager | |
| 14 | + | res *wlr.ZwlrOutputConfigurationV1 | |
| 15 | + | heads map[string]*headConfiguration | |
| 16 | + | used, cancelled bool | |
| 17 | + | serial uint32 | |
| 18 | + | } | |
| 19 | + | type headConfiguration struct { | |
| 20 | + | parent *outputConfiguration | |
| 21 | + | res *wlr.ZwlrOutputConfigurationHeadV1 | |
| 22 | + | change ports.HeadChange | |
| 23 | + | modeSet, positionSet, transformSet, scaleSet, syncSet bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *outputConfiguration) head(r *wlr.ZwlrOutputHeadV1) (string, bool) { | |
| 27 | + | if r == nil || !r.Resource.Alive() || r.Client() != c.res.Client() { | |
| 28 | + | return "", false | |
| 29 | + | } | |
| 30 | + | for name, h := range c.manager.heads { | |
| 31 | + | if h.res.Resource == r.Resource { | |
| 32 | + | return name, true | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | return "", false | |
| 36 | + | } | |
| 37 | + | func (c *outputConfiguration) add(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1, id uint32, enabled bool) { | |
| 38 | + | if c.used { | |
| 39 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used") | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | name, ok := c.head(head) | |
| 43 | + | if !ok { | |
| 44 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "unknown head") | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | if _, exists := c.heads[name]; exists { | |
| 48 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "head configured twice") | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | h := &headConfiguration{parent: c, change: ports.HeadChange{Name: name, Enabled: enabled}} | |
| 52 | + | c.heads[name] = h | |
| 53 | + | if enabled { | |
| 54 | + | res, err := wlr.NewZwlrOutputConfigurationHeadV1(r.Client(), r.Version(), id, h) | |
| 55 | + | if err != nil { | |
| 56 | + | delete(c.heads, name) | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | h.res = res | |
| 60 | + | res.OnDestroy = func() { h.res = nil } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | func (c *outputConfiguration) EnableHead(r *wlr.ZwlrOutputConfigurationV1, id uint32, head *wlr.ZwlrOutputHeadV1) { | |
| 64 | + | c.add(r, head, id, true) | |
| 65 | + | } | |
| 66 | + | func (c *outputConfiguration) DisableHead(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1) { | |
| 67 | + | c.add(r, head, 0, false) | |
| 68 | + | } | |
| 69 | + | func (c *outputConfiguration) Destroy(*wlr.ZwlrOutputConfigurationV1) { c.used = true } | |
| 70 | + | func (c *outputConfiguration) Test(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, true) } | |
| 71 | + | func (c *outputConfiguration) Apply(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, false) } | |
| 72 | + | func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool) { | |
| 73 | + | if c.used { | |
| 74 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used") | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | c.used = true | |
| 78 | + | if c.manager.s.managementSerial != c.serial { | |
| 79 | + | c.cancelled = true | |
| 80 | + | r.SendCancelled() | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | if len(c.heads) != len(c.manager.heads) { | |
| 84 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured") | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | request := ports.OutputApply{ID: c.manager.s.nextOutputApply, Test: test} | |
| 88 | + | c.manager.s.nextOutputApply++ | |
| 89 | + | for _, head := range c.manager.s.outputHeads.Heads { | |
| 90 | + | change, ok := c.heads[head.Info.Name] | |
| 91 | + | if !ok { | |
| 92 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured") | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | request.Heads = append(request.Heads, change.change) | |
| 96 | + | } | |
| 97 | + | if c.manager.s.channels.OutputApply == nil { | |
| 98 | + | r.SendFailed() | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | c.manager.s.outputReplies[request.ID] = c | |
| 102 | + | select { | |
| 103 | + | case c.manager.s.channels.OutputApply <- request: | |
| 104 | + | default: | |
| 105 | + | delete(c.manager.s.outputReplies, request.ID) | |
| 106 | + | r.SendFailed() | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | func (h *headConfiguration) once(field *bool, r *wlr.ZwlrOutputConfigurationHeadV1) bool { | |
| 110 | + | if *field { | |
| 111 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorAlreadySet), "property already set") | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | *field = true | |
| 115 | + | return true | |
| 116 | + | } | |
| 117 | + | func (h *headConfiguration) SetMode(r *wlr.ZwlrOutputConfigurationHeadV1, mode *wlr.ZwlrOutputModeV1) { | |
| 118 | + | if !h.once(&h.modeSet, r) { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | head := h.parent.manager.heads[h.change.Name] | |
| 122 | + | if head == nil || mode == nil || !mode.Resource.Alive() { | |
| 123 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode") | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | m, ok := head.modes[mode.Resource] | |
| 127 | + | if !ok { | |
| 128 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode") | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | h.change.Mode = &m | |
| 132 | + | } | |
| 133 | + | func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1, w, height, refresh int32) { | |
| 134 | + | if !h.once(&h.modeSet, r) { | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | if w <= 0 || height <= 0 || refresh < 0 { | |
| 138 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode") | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | h.change.CustomMode = true | |
| 142 | + | h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)} | |
| 143 | + | } | |
| 144 | + | func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) { | |
| 145 | + | if h.once(&h.positionSet, r) { | |
| 146 | + | h.change.Pos = &image.Point{X: int(x), Y: int(y)} | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v int32) { | |
| 150 | + | if !h.once(&h.transformSet, r) { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | if v < 0 || v > 7 { | |
| 154 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | h.change.Transform = int(v) | |
| 158 | + | } | |
| 159 | + | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | |
| 160 | + | if !h.once(&h.scaleSet, r) { | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | scale := v.Float() | |
| 164 | + | if scale <= 0 || math.IsNaN(scale) { | |
| 165 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidScale), "invalid scale") | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | h.change.Scale = scale | |
| 169 | + | } | |
| 170 | + | func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1, v uint32) { | |
| 171 | + | if !h.once(&h.syncSet, r) { | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | if v > 1 { | |
| 175 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync") | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | enabled := v == 1 | |
| 179 | + | h.change.AdaptiveSync = &enabled | |
| 180 | + | } | |
| @@ -0,0 +1,180 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "math" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |||
| 9 | + | "github.com/bnema/purego-libwayland/server" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type outputConfiguration struct { | |||
| 13 | + | manager *outputManager | |||
| 14 | + | res *wlr.ZwlrOutputConfigurationV1 | |||
| 15 | + | heads map[string]*headConfiguration | |||
| 16 | + | used, cancelled bool | |||
| 17 | + | serial uint32 | |||
| 18 | + | } | |||
| 19 | + | type headConfiguration struct { | |||
| 20 | + | parent *outputConfiguration | |||
| 21 | + | res *wlr.ZwlrOutputConfigurationHeadV1 | |||
| 22 | + | change ports.HeadChange | |||
| 23 | + | modeSet, positionSet, transformSet, scaleSet, syncSet bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *outputConfiguration) head(r *wlr.ZwlrOutputHeadV1) (string, bool) { | |||
| 27 | + | if r == nil || !r.Resource.Alive() || r.Client() != c.res.Client() { | |||
| 28 | + | return "", false | |||
| 29 | + | } | |||
| 30 | + | for name, h := range c.manager.heads { | |||
| 31 | + | if h.res.Resource == r.Resource { | |||
| 32 | + | return name, true | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | return "", false | |||
| 36 | + | } | |||
| 37 | + | func (c *outputConfiguration) add(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1, id uint32, enabled bool) { | |||
| 38 | + | if c.used { | |||
| 39 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used") | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | name, ok := c.head(head) | |||
| 43 | + | if !ok { | |||
| 44 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "unknown head") | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | if _, exists := c.heads[name]; exists { | |||
| 48 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead), "head configured twice") | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | h := &headConfiguration{parent: c, change: ports.HeadChange{Name: name, Enabled: enabled}} | |||
| 52 | + | c.heads[name] = h | |||
| 53 | + | if enabled { | |||
| 54 | + | res, err := wlr.NewZwlrOutputConfigurationHeadV1(r.Client(), r.Version(), id, h) | |||
| 55 | + | if err != nil { | |||
| 56 | + | delete(c.heads, name) | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | h.res = res | |||
| 60 | + | res.OnDestroy = func() { h.res = nil } | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | func (c *outputConfiguration) EnableHead(r *wlr.ZwlrOutputConfigurationV1, id uint32, head *wlr.ZwlrOutputHeadV1) { | |||
| 64 | + | c.add(r, head, id, true) | |||
| 65 | + | } | |||
| 66 | + | func (c *outputConfiguration) DisableHead(r *wlr.ZwlrOutputConfigurationV1, head *wlr.ZwlrOutputHeadV1) { | |||
| 67 | + | c.add(r, head, 0, false) | |||
| 68 | + | } | |||
| 69 | + | func (c *outputConfiguration) Destroy(*wlr.ZwlrOutputConfigurationV1) { c.used = true } | |||
| 70 | + | func (c *outputConfiguration) Test(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, true) } | |||
| 71 | + | func (c *outputConfiguration) Apply(r *wlr.ZwlrOutputConfigurationV1) { c.submit(r, false) } | |||
| 72 | + | func (c *outputConfiguration) submit(r *wlr.ZwlrOutputConfigurationV1, test bool) { | |||
| 73 | + | if c.used { | |||
| 74 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed), "configuration already used") | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | c.used = true | |||
| 78 | + | if c.manager.s.managementSerial != c.serial { | |||
| 79 | + | c.cancelled = true | |||
| 80 | + | r.SendCancelled() | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | if len(c.heads) != len(c.manager.heads) { | |||
| 84 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured") | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | request := ports.OutputApply{ID: c.manager.s.nextOutputApply, Test: test} | |||
| 88 | + | c.manager.s.nextOutputApply++ | |||
| 89 | + | for _, head := range c.manager.s.outputHeads.Heads { | |||
| 90 | + | change, ok := c.heads[head.Info.Name] | |||
| 91 | + | if !ok { | |||
| 92 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationV1ErrorUnconfiguredHead), "not all heads configured") | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | request.Heads = append(request.Heads, change.change) | |||
| 96 | + | } | |||
| 97 | + | if c.manager.s.channels.OutputApply == nil { | |||
| 98 | + | r.SendFailed() | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | c.manager.s.outputReplies[request.ID] = c | |||
| 102 | + | select { | |||
| 103 | + | case c.manager.s.channels.OutputApply <- request: | |||
| 104 | + | default: | |||
| 105 | + | delete(c.manager.s.outputReplies, request.ID) | |||
| 106 | + | r.SendFailed() | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | func (h *headConfiguration) once(field *bool, r *wlr.ZwlrOutputConfigurationHeadV1) bool { | |||
| 110 | + | if *field { | |||
| 111 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorAlreadySet), "property already set") | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | *field = true | |||
| 115 | + | return true | |||
| 116 | + | } | |||
| 117 | + | func (h *headConfiguration) SetMode(r *wlr.ZwlrOutputConfigurationHeadV1, mode *wlr.ZwlrOutputModeV1) { | |||
| 118 | + | if !h.once(&h.modeSet, r) { | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | head := h.parent.manager.heads[h.change.Name] | |||
| 122 | + | if head == nil || mode == nil || !mode.Resource.Alive() { | |||
| 123 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode") | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | m, ok := head.modes[mode.Resource] | |||
| 127 | + | if !ok { | |||
| 128 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode), "invalid mode") | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | h.change.Mode = &m | |||
| 132 | + | } | |||
| 133 | + | func (h *headConfiguration) SetCustomMode(r *wlr.ZwlrOutputConfigurationHeadV1, w, height, refresh int32) { | |||
| 134 | + | if !h.once(&h.modeSet, r) { | |||
| 135 | + | return | |||
| 136 | + | } | |||
| 137 | + | if w <= 0 || height <= 0 || refresh < 0 { | |||
| 138 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode), "invalid custom mode") | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | h.change.CustomMode = true | |||
| 142 | + | h.change.Mode = &ports.OutputMode{Width: int(w), Height: int(height), RefreshMilli: int(refresh)} | |||
| 143 | + | } | |||
| 144 | + | func (h *headConfiguration) SetPosition(r *wlr.ZwlrOutputConfigurationHeadV1, x, y int32) { | |||
| 145 | + | if h.once(&h.positionSet, r) { | |||
| 146 | + | h.change.Pos = &image.Point{X: int(x), Y: int(y)} | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | func (h *headConfiguration) SetTransform(r *wlr.ZwlrOutputConfigurationHeadV1, v int32) { | |||
| 150 | + | if !h.once(&h.transformSet, r) { | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | if v < 0 || v > 7 { | |||
| 154 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidTransform), "invalid transform") | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | h.change.Transform = int(v) | |||
| 158 | + | } | |||
| 159 | + | func (h *headConfiguration) SetScale(r *wlr.ZwlrOutputConfigurationHeadV1, v server.Fixed) { | |||
| 160 | + | if !h.once(&h.scaleSet, r) { | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | scale := v.Float() | |||
| 164 | + | if scale <= 0 || math.IsNaN(scale) { | |||
| 165 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidScale), "invalid scale") | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | h.change.Scale = scale | |||
| 169 | + | } | |||
| 170 | + | func (h *headConfiguration) SetAdaptiveSync(r *wlr.ZwlrOutputConfigurationHeadV1, v uint32) { | |||
| 171 | + | if !h.once(&h.syncSet, r) { | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | if v > 1 { | |||
| 175 | + | r.PostError(uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState), "invalid adaptive sync") | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | enabled := v == 1 | |||
| 179 | + | h.change.AdaptiveSync = &enabled | |||
| 180 | + | } | |||
A internal/adapters/wayland/outputmanagement.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |
| 9 | + | "github.com/bnema/purego-libwayland/server" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type managementHead struct { | |
| 13 | + | res *wlr.ZwlrOutputHeadV1 | |
| 14 | + | modes map[*server.Resource]ports.OutputMode | |
| 15 | + | modeResources []*wlr.ZwlrOutputModeV1 | |
| 16 | + | info ports.OutputHead | |
| 17 | + | } | |
| 18 | + | type outputManager struct { | |
| 19 | + | s *Server | |
| 20 | + | res *wlr.ZwlrOutputManagerV1 | |
| 21 | + | heads map[string]*managementHead | |
| 22 | + | stopped bool | |
| 23 | + | places map[string]ports.OutputPlacement | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func registerOutputManagement(d *server.Display, s *Server) error { | |
| 27 | + | return wlr.NewZwlrOutputManagerV1Global(d, 4, func(c server.Client, v, id uint32) { | |
| 28 | + | m := &outputManager{s: s, heads: map[string]*managementHead{}, places: map[string]ports.OutputPlacement{}} | |
| 29 | + | res, err := wlr.NewZwlrOutputManagerV1(c, int32(v), id, m) | |
| 30 | + | if err != nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | m.res = res | |
| 34 | + | s.outputManagers = append(s.outputManagers, m) | |
| 35 | + | res.OnDestroy = func() { s.outputManagers = removeItem(s.outputManagers, m) } | |
| 36 | + | for _, h := range s.outputHeads.Heads { | |
| 37 | + | m.addHead(h) | |
| 38 | + | } | |
| 39 | + | m.update() | |
| 40 | + | res.SendDone(s.managementSerial) | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | func (m *outputManager) Stop(_ *wlr.ZwlrOutputManagerV1) { | |
| 44 | + | m.stopped = true | |
| 45 | + | m.res.SendFinished() | |
| 46 | + | m.res.Destroy() | |
| 47 | + | } | |
| 48 | + | func (m *outputManager) CreateConfiguration(r *wlr.ZwlrOutputManagerV1, id, serial uint32) { | |
| 49 | + | c := &outputConfiguration{manager: m, heads: map[string]*headConfiguration{}, serial: serial} | |
| 50 | + | res, err := wlr.NewZwlrOutputConfigurationV1(r.Client(), r.Version(), id, c) | |
| 51 | + | if err != nil { | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | c.res = res | |
| 55 | + | res.OnDestroy = func() { | |
| 56 | + | for _, h := range c.heads { | |
| 57 | + | if h.res != nil { | |
| 58 | + | h.res.Destroy() | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | for id, pending := range m.s.outputReplies { | |
| 62 | + | if pending == c { | |
| 63 | + | delete(m.s.outputReplies, id) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | if serial != m.s.managementSerial { | |
| 68 | + | c.cancelled = true | |
| 69 | + | c.used = true | |
| 70 | + | res.SendCancelled() | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | func (m *outputManager) addHead(info ports.OutputHead) { | |
| 74 | + | h := &managementHead{info: info, modes: map[*server.Resource]ports.OutputMode{}} | |
| 75 | + | res, err := wlr.NewZwlrOutputHeadV1(m.res.Client(), m.res.Version(), 0, managementHeadHandler{}) | |
| 76 | + | if err != nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | h.res = res | |
| 80 | + | m.heads[info.Info.Name] = h | |
| 81 | + | m.res.SendHead(res) | |
| 82 | + | res.SendName(info.Info.Name) | |
| 83 | + | desc := strings.Join(strings.Fields(info.Info.Make+" "+info.Info.Model+" "+info.Info.Serial+" ("+info.Info.Name+")"), " ") | |
| 84 | + | res.SendDescription(desc) | |
| 85 | + | if info.Info.PhysicalW > 0 && info.Info.PhysicalH > 0 { | |
| 86 | + | res.SendPhysicalSize(int32(info.Info.PhysicalW), int32(info.Info.PhysicalH)) | |
| 87 | + | } | |
| 88 | + | if res.Version() >= 2 { | |
| 89 | + | res.SendMake(info.Info.Make) | |
| 90 | + | res.SendModel(info.Info.Model) | |
| 91 | + | res.SendSerialNumber(info.Info.Serial) | |
| 92 | + | } | |
| 93 | + | for _, mode := range info.Modes { | |
| 94 | + | mr, e := wlr.NewZwlrOutputModeV1(res.Client(), min(res.Version(), 3), 0, managementModeHandler{}) | |
| 95 | + | if e != nil { | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | h.modes[mr.Resource] = mode | |
| 99 | + | h.modeResources = append(h.modeResources, mr) | |
| 100 | + | mr.OnDestroy = func() { | |
| 101 | + | delete(h.modes, mr.Resource) | |
| 102 | + | h.modeResources = removeItem(h.modeResources, mr) | |
| 103 | + | } | |
| 104 | + | res.SendMode(mr) | |
| 105 | + | mr.SendSize(int32(mode.Width), int32(mode.Height)) | |
| 106 | + | mr.SendRefresh(int32(mode.RefreshMilli)) | |
| 107 | + | if mode.Preferred { | |
| 108 | + | mr.SendPreferred() | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | m.sendState(h, info) | |
| 112 | + | } | |
| 113 | + | func (m *outputManager) update() { | |
| 114 | + | if m.stopped { | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | wanted := map[string]ports.OutputHead{} | |
| 118 | + | for _, h := range m.s.outputHeads.Heads { | |
| 119 | + | wanted[h.Info.Name] = h | |
| 120 | + | } | |
| 121 | + | for name, h := range m.heads { | |
| 122 | + | if _, ok := wanted[name]; !ok { | |
| 123 | + | h.res.SendFinished() | |
| 124 | + | for _, mode := range h.modeResources { | |
| 125 | + | mode.SendFinished() | |
| 126 | + | } | |
| 127 | + | delete(m.heads, name) | |
| 128 | + | delete(m.places, name) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | for _, info := range m.s.outputHeads.Heads { | |
| 132 | + | h := m.heads[info.Info.Name] | |
| 133 | + | if h != nil && (!reflect.DeepEqual(h.info.Modes, info.Modes) || h.info.Info.Make != info.Info.Make || h.info.Info.Model != info.Info.Model || h.info.Info.Serial != info.Info.Serial || h.info.Info.PhysicalW != info.Info.PhysicalW || h.info.Info.PhysicalH != info.Info.PhysicalH) { | |
| 134 | + | h.res.SendFinished() | |
| 135 | + | for _, mode := range h.modeResources { | |
| 136 | + | mode.SendFinished() | |
| 137 | + | } | |
| 138 | + | delete(m.heads, info.Info.Name) | |
| 139 | + | delete(m.places, info.Info.Name) | |
| 140 | + | h = nil | |
| 141 | + | } | |
| 142 | + | if h == nil { | |
| 143 | + | m.addHead(info) | |
| 144 | + | continue // addHead has already sent the initial state. | |
| 145 | + | } | |
| 146 | + | changed := !reflect.DeepEqual(h.info, info) | |
| 147 | + | placementChanged := m.places[info.Info.Name] != m.s.placementFor(info.Info.Name) | |
| 148 | + | if !changed && !placementChanged { | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | m.sendState(h, info) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) { | |
| 155 | + | h.info = info | |
| 156 | + | enabled := int32(0) | |
| 157 | + | if info.Enabled { | |
| 158 | + | enabled = 1 | |
| 159 | + | } | |
| 160 | + | h.res.SendEnabled(enabled) | |
| 161 | + | if info.Enabled { | |
| 162 | + | for res, mode := range h.modes { | |
| 163 | + | if info.Current != nil && mode.Width == info.Current.Width && mode.Height == info.Current.Height && mode.RefreshMilli == info.Current.RefreshMilli { | |
| 164 | + | h.res.SendCurrentMode(wlr.WrapZwlrOutputModeV1(res)) | |
| 165 | + | break | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | place := m.s.placementFor(info.Info.Name) | |
| 169 | + | if place.Info.Name != "" { | |
| 170 | + | h.res.SendPosition(int32(place.X), int32(place.Y)) | |
| 171 | + | h.res.SendScale(server.FixedFromFloat(place.Scale)) | |
| 172 | + | } | |
| 173 | + | h.res.SendTransform(0) | |
| 174 | + | } | |
| 175 | + | m.places[info.Info.Name] = m.s.placementFor(info.Info.Name) | |
| 176 | + | } | |
| 177 | + | func (s *Server) placementFor(name string) ports.OutputPlacement { | |
| 178 | + | for _, p := range s.outputPlaces { | |
| 179 | + | if p.Info.Name == name { | |
| 180 | + | return p | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | return ports.OutputPlacement{} | |
| 184 | + | } | |
| 185 | + | func (s *Server) setOutputHeads(h ports.OutputHeads) { | |
| 186 | + | if reflect.DeepEqual(s.outputHeads, h) { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | s.outputHeads = h | |
| 190 | + | s.refreshOutputManagers() | |
| 191 | + | } | |
| 192 | + | func (s *Server) refreshOutputManagers() { | |
| 193 | + | s.managementSerial++ | |
| 194 | + | if s.managementSerial == 0 { | |
| 195 | + | s.managementSerial++ | |
| 196 | + | } | |
| 197 | + | for _, m := range s.outputManagers { | |
| 198 | + | if m.stopped { | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | m.update() | |
| 202 | + | m.res.SendDone(s.managementSerial) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | type managementHeadHandler struct{} | |
| 207 | + | ||
| 208 | + | func (managementHeadHandler) Release(*wlr.ZwlrOutputHeadV1) {} | |
| 209 | + | ||
| 210 | + | type managementModeHandler struct{} | |
| 211 | + | ||
| 212 | + | func (managementModeHandler) Release(*wlr.ZwlrOutputModeV1) {} | |
| @@ -0,0 +1,212 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |||
| 9 | + | "github.com/bnema/purego-libwayland/server" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type managementHead struct { | |||
| 13 | + | res *wlr.ZwlrOutputHeadV1 | |||
| 14 | + | modes map[*server.Resource]ports.OutputMode | |||
| 15 | + | modeResources []*wlr.ZwlrOutputModeV1 | |||
| 16 | + | info ports.OutputHead | |||
| 17 | + | } | |||
| 18 | + | type outputManager struct { | |||
| 19 | + | s *Server | |||
| 20 | + | res *wlr.ZwlrOutputManagerV1 | |||
| 21 | + | heads map[string]*managementHead | |||
| 22 | + | stopped bool | |||
| 23 | + | places map[string]ports.OutputPlacement | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func registerOutputManagement(d *server.Display, s *Server) error { | |||
| 27 | + | return wlr.NewZwlrOutputManagerV1Global(d, 4, func(c server.Client, v, id uint32) { | |||
| 28 | + | m := &outputManager{s: s, heads: map[string]*managementHead{}, places: map[string]ports.OutputPlacement{}} | |||
| 29 | + | res, err := wlr.NewZwlrOutputManagerV1(c, int32(v), id, m) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | m.res = res | |||
| 34 | + | s.outputManagers = append(s.outputManagers, m) | |||
| 35 | + | res.OnDestroy = func() { s.outputManagers = removeItem(s.outputManagers, m) } | |||
| 36 | + | for _, h := range s.outputHeads.Heads { | |||
| 37 | + | m.addHead(h) | |||
| 38 | + | } | |||
| 39 | + | m.update() | |||
| 40 | + | res.SendDone(s.managementSerial) | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | func (m *outputManager) Stop(_ *wlr.ZwlrOutputManagerV1) { | |||
| 44 | + | m.stopped = true | |||
| 45 | + | m.res.SendFinished() | |||
| 46 | + | m.res.Destroy() | |||
| 47 | + | } | |||
| 48 | + | func (m *outputManager) CreateConfiguration(r *wlr.ZwlrOutputManagerV1, id, serial uint32) { | |||
| 49 | + | c := &outputConfiguration{manager: m, heads: map[string]*headConfiguration{}, serial: serial} | |||
| 50 | + | res, err := wlr.NewZwlrOutputConfigurationV1(r.Client(), r.Version(), id, c) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | c.res = res | |||
| 55 | + | res.OnDestroy = func() { | |||
| 56 | + | for _, h := range c.heads { | |||
| 57 | + | if h.res != nil { | |||
| 58 | + | h.res.Destroy() | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | for id, pending := range m.s.outputReplies { | |||
| 62 | + | if pending == c { | |||
| 63 | + | delete(m.s.outputReplies, id) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | if serial != m.s.managementSerial { | |||
| 68 | + | c.cancelled = true | |||
| 69 | + | c.used = true | |||
| 70 | + | res.SendCancelled() | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | func (m *outputManager) addHead(info ports.OutputHead) { | |||
| 74 | + | h := &managementHead{info: info, modes: map[*server.Resource]ports.OutputMode{}} | |||
| 75 | + | res, err := wlr.NewZwlrOutputHeadV1(m.res.Client(), m.res.Version(), 0, managementHeadHandler{}) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | h.res = res | |||
| 80 | + | m.heads[info.Info.Name] = h | |||
| 81 | + | m.res.SendHead(res) | |||
| 82 | + | res.SendName(info.Info.Name) | |||
| 83 | + | desc := strings.Join(strings.Fields(info.Info.Make+" "+info.Info.Model+" "+info.Info.Serial+" ("+info.Info.Name+")"), " ") | |||
| 84 | + | res.SendDescription(desc) | |||
| 85 | + | if info.Info.PhysicalW > 0 && info.Info.PhysicalH > 0 { | |||
| 86 | + | res.SendPhysicalSize(int32(info.Info.PhysicalW), int32(info.Info.PhysicalH)) | |||
| 87 | + | } | |||
| 88 | + | if res.Version() >= 2 { | |||
| 89 | + | res.SendMake(info.Info.Make) | |||
| 90 | + | res.SendModel(info.Info.Model) | |||
| 91 | + | res.SendSerialNumber(info.Info.Serial) | |||
| 92 | + | } | |||
| 93 | + | for _, mode := range info.Modes { | |||
| 94 | + | mr, e := wlr.NewZwlrOutputModeV1(res.Client(), min(res.Version(), 3), 0, managementModeHandler{}) | |||
| 95 | + | if e != nil { | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | h.modes[mr.Resource] = mode | |||
| 99 | + | h.modeResources = append(h.modeResources, mr) | |||
| 100 | + | mr.OnDestroy = func() { | |||
| 101 | + | delete(h.modes, mr.Resource) | |||
| 102 | + | h.modeResources = removeItem(h.modeResources, mr) | |||
| 103 | + | } | |||
| 104 | + | res.SendMode(mr) | |||
| 105 | + | mr.SendSize(int32(mode.Width), int32(mode.Height)) | |||
| 106 | + | mr.SendRefresh(int32(mode.RefreshMilli)) | |||
| 107 | + | if mode.Preferred { | |||
| 108 | + | mr.SendPreferred() | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | m.sendState(h, info) | |||
| 112 | + | } | |||
| 113 | + | func (m *outputManager) update() { | |||
| 114 | + | if m.stopped { | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | wanted := map[string]ports.OutputHead{} | |||
| 118 | + | for _, h := range m.s.outputHeads.Heads { | |||
| 119 | + | wanted[h.Info.Name] = h | |||
| 120 | + | } | |||
| 121 | + | for name, h := range m.heads { | |||
| 122 | + | if _, ok := wanted[name]; !ok { | |||
| 123 | + | h.res.SendFinished() | |||
| 124 | + | for _, mode := range h.modeResources { | |||
| 125 | + | mode.SendFinished() | |||
| 126 | + | } | |||
| 127 | + | delete(m.heads, name) | |||
| 128 | + | delete(m.places, name) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | for _, info := range m.s.outputHeads.Heads { | |||
| 132 | + | h := m.heads[info.Info.Name] | |||
| 133 | + | if h != nil && (!reflect.DeepEqual(h.info.Modes, info.Modes) || h.info.Info.Make != info.Info.Make || h.info.Info.Model != info.Info.Model || h.info.Info.Serial != info.Info.Serial || h.info.Info.PhysicalW != info.Info.PhysicalW || h.info.Info.PhysicalH != info.Info.PhysicalH) { | |||
| 134 | + | h.res.SendFinished() | |||
| 135 | + | for _, mode := range h.modeResources { | |||
| 136 | + | mode.SendFinished() | |||
| 137 | + | } | |||
| 138 | + | delete(m.heads, info.Info.Name) | |||
| 139 | + | delete(m.places, info.Info.Name) | |||
| 140 | + | h = nil | |||
| 141 | + | } | |||
| 142 | + | if h == nil { | |||
| 143 | + | m.addHead(info) | |||
| 144 | + | continue // addHead has already sent the initial state. | |||
| 145 | + | } | |||
| 146 | + | changed := !reflect.DeepEqual(h.info, info) | |||
| 147 | + | placementChanged := m.places[info.Info.Name] != m.s.placementFor(info.Info.Name) | |||
| 148 | + | if !changed && !placementChanged { | |||
| 149 | + | continue | |||
| 150 | + | } | |||
| 151 | + | m.sendState(h, info) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | func (m *outputManager) sendState(h *managementHead, info ports.OutputHead) { | |||
| 155 | + | h.info = info | |||
| 156 | + | enabled := int32(0) | |||
| 157 | + | if info.Enabled { | |||
| 158 | + | enabled = 1 | |||
| 159 | + | } | |||
| 160 | + | h.res.SendEnabled(enabled) | |||
| 161 | + | if info.Enabled { | |||
| 162 | + | for res, mode := range h.modes { | |||
| 163 | + | if info.Current != nil && mode.Width == info.Current.Width && mode.Height == info.Current.Height && mode.RefreshMilli == info.Current.RefreshMilli { | |||
| 164 | + | h.res.SendCurrentMode(wlr.WrapZwlrOutputModeV1(res)) | |||
| 165 | + | break | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | place := m.s.placementFor(info.Info.Name) | |||
| 169 | + | if place.Info.Name != "" { | |||
| 170 | + | h.res.SendPosition(int32(place.X), int32(place.Y)) | |||
| 171 | + | h.res.SendScale(server.FixedFromFloat(place.Scale)) | |||
| 172 | + | } | |||
| 173 | + | h.res.SendTransform(0) | |||
| 174 | + | } | |||
| 175 | + | m.places[info.Info.Name] = m.s.placementFor(info.Info.Name) | |||
| 176 | + | } | |||
| 177 | + | func (s *Server) placementFor(name string) ports.OutputPlacement { | |||
| 178 | + | for _, p := range s.outputPlaces { | |||
| 179 | + | if p.Info.Name == name { | |||
| 180 | + | return p | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | return ports.OutputPlacement{} | |||
| 184 | + | } | |||
| 185 | + | func (s *Server) setOutputHeads(h ports.OutputHeads) { | |||
| 186 | + | if reflect.DeepEqual(s.outputHeads, h) { | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | s.outputHeads = h | |||
| 190 | + | s.refreshOutputManagers() | |||
| 191 | + | } | |||
| 192 | + | func (s *Server) refreshOutputManagers() { | |||
| 193 | + | s.managementSerial++ | |||
| 194 | + | if s.managementSerial == 0 { | |||
| 195 | + | s.managementSerial++ | |||
| 196 | + | } | |||
| 197 | + | for _, m := range s.outputManagers { | |||
| 198 | + | if m.stopped { | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 201 | + | m.update() | |||
| 202 | + | m.res.SendDone(s.managementSerial) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | type managementHeadHandler struct{} | |||
| 207 | + | ||||
| 208 | + | func (managementHeadHandler) Release(*wlr.ZwlrOutputHeadV1) {} | |||
| 209 | + | ||||
| 210 | + | type managementModeHandler struct{} | |||
| 211 | + | ||||
| 212 | + | func (managementModeHandler) Release(*wlr.ZwlrOutputModeV1) {} | |||
A internal/adapters/wayland/outputmanagement_test.go +592 -0
| @@ -0,0 +1,592 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/logging" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |
| 11 | + | "github.com/bnema/wlturbo" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type managementEvents struct { | |
| 15 | + | wlturbo.BaseProxy | |
| 16 | + | events chan [2]uint32 | |
| 17 | + | client *wlturbo.Display | |
| 18 | + | modes chan uint32 | |
| 19 | + | headEvents chan [2]uint32 | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (p *managementEvents) Dispatch(e *wlturbo.Event) { | |
| 23 | + | msg := [2]uint32{uint32(e.Opcode), 0} | |
| 24 | + | if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) || e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventDone) { | |
| 25 | + | msg[1] = e.Uint32() | |
| 26 | + | } | |
| 27 | + | if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) { | |
| 28 | + | h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents} | |
| 29 | + | h.SetID(msg[1]) | |
| 30 | + | p.client.Context().Register(h) | |
| 31 | + | } | |
| 32 | + | p.events <- msg | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type managementHeadEvents struct { | |
| 36 | + | wlturbo.BaseProxy | |
| 37 | + | client *wlturbo.Display | |
| 38 | + | modes chan uint32 | |
| 39 | + | events chan [2]uint32 | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) { | |
| 43 | + | if p.events != nil { | |
| 44 | + | value := uint32(0) | |
| 45 | + | switch uint32(e.Opcode) { | |
| 46 | + | case wlr.ZwlrOutputHeadV1EventEnabled, wlr.ZwlrOutputHeadV1EventCurrentMode, wlr.ZwlrOutputHeadV1EventTransform: | |
| 47 | + | value = e.Uint32() | |
| 48 | + | } | |
| 49 | + | p.events <- [2]uint32{uint32(e.Opcode), value} | |
| 50 | + | } | |
| 51 | + | if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) { | |
| 52 | + | mode := &protocolProxy{} | |
| 53 | + | mode.SetID(e.Uint32()) | |
| 54 | + | p.client.Context().Register(mode) | |
| 55 | + | if p.modes != nil { | |
| 56 | + | p.modes <- mode.ID() | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | func outputTestServer(t *testing.T) (*Server, chan ports.OutputHeads, chan ports.OutputApply, chan ports.OutputApplied, string) { | |
| 61 | + | t.Helper() | |
| 62 | + | dir := t.TempDir() | |
| 63 | + | heads := make(chan ports.OutputHeads, 8) | |
| 64 | + | applies := make(chan ports.OutputApply, 8) | |
| 65 | + | replies := make(chan ports.OutputApplied, 8) | |
| 66 | + | s, err := New(Options{RuntimeDir: dir}, Channels{OutputHeads: heads, OutputApply: applies, OutputApplied: replies}, logging.For(context.Background(), "wayland")) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 71 | + | done := make(chan error, 1) | |
| 72 | + | go func() { done <- s.Run(ctx) }() | |
| 73 | + | t.Cleanup(func() { | |
| 74 | + | cancel() | |
| 75 | + | if err := <-done; err != nil { | |
| 76 | + | t.Error(err) | |
| 77 | + | } | |
| 78 | + | }) | |
| 79 | + | return s, heads, applies, replies, dir | |
| 80 | + | } | |
| 81 | + | func testHead() ports.OutputHeads { | |
| 82 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000, Preferred: true} | |
| 83 | + | return ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}}} | |
| 84 | + | } | |
| 85 | + | func recvManagement(t *testing.T, ch <-chan [2]uint32) [2]uint32 { | |
| 86 | + | t.Helper() | |
| 87 | + | select { | |
| 88 | + | case v := <-ch: | |
| 89 | + | return v | |
| 90 | + | case <-time.After(2 * time.Second): | |
| 91 | + | t.Fatal("management event timeout") | |
| 92 | + | return [2]uint32{} | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | func waitManagementEvent(t *testing.T, c *wlturbo.Display, ch <-chan [2]uint32, want uint32) { | |
| 96 | + | t.Helper() | |
| 97 | + | for i := 0; i < 12; i++ { | |
| 98 | + | if err := c.Roundtrip(); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | select { | |
| 102 | + | case ev := <-ch: | |
| 103 | + | if ev[0] != want { | |
| 104 | + | t.Fatalf("event %v want %d", ev, want) | |
| 105 | + | } | |
| 106 | + | return | |
| 107 | + | default: | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | t.Fatal("missing management event") | |
| 111 | + | } | |
| 112 | + | func TestOutputManagementStaleSerialAndLayout(t *testing.T) { | |
| 113 | + | s, _, _, _, dir := outputTestServer(t) | |
| 114 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |
| 115 | + | t.Fatal("display stopped") | |
| 116 | + | } | |
| 117 | + | c := protocolClient(t, s, dir) | |
| 118 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 119 | + | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | |
| 120 | + | manager.SetID(id) | |
| 121 | + | c.Context().Register(manager) | |
| 122 | + | if err := c.Roundtrip(); err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | var first [2]uint32 | |
| 126 | + | for first[0] != wlr.ZwlrOutputManagerV1EventDone { | |
| 127 | + | first = recvManagement(t, manager.events) | |
| 128 | + | } | |
| 129 | + | if first[0] != wlr.ZwlrOutputManagerV1EventDone { | |
| 130 | + | t.Fatalf("initial: %v", first) | |
| 131 | + | } | |
| 132 | + | if !s.display.Do(func() { | |
| 133 | + | s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, X: 100, Width: 1920, Height: 1080, Scale: 1}}}) | |
| 134 | + | }) { | |
| 135 | + | t.Fatal("display stopped") | |
| 136 | + | } | |
| 137 | + | if err := c.Roundtrip(); err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | second := recvManagement(t, manager.events) | |
| 141 | + | if second[0] != wlr.ZwlrOutputManagerV1EventDone || second[1] == first[1] { | |
| 142 | + | t.Fatalf("layout serial: %v -> %v", first, second) | |
| 143 | + | } | |
| 144 | + | configID := c.AllocateID() | |
| 145 | + | proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |
| 146 | + | proxy.SetID(configID) | |
| 147 | + | c.Context().Register(proxy) | |
| 148 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1]) | |
| 149 | + | waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled)) | |
| 150 | + | if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) { | |
| 151 | + | t.Fatal("display stopped") | |
| 152 | + | } | |
| 153 | + | if err := c.Roundtrip(); err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | third := recvManagement(t, manager.events) | |
| 157 | + | if third[0] != wlr.ZwlrOutputManagerV1EventDone || third[1] == second[1] { | |
| 158 | + | t.Fatalf("inventory serial: %v -> %v", second, third) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestOutputManagementHeadsAndApply(t *testing.T) { | |
| 163 | + | s, heads, apply, replies, dir := outputTestServer(t) | |
| 164 | + | heads <- testHead() | |
| 165 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |
| 166 | + | t.Fatal("display stopped") | |
| 167 | + | } | |
| 168 | + | c := protocolClient(t, s, dir) | |
| 169 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 170 | + | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | |
| 171 | + | manager.SetID(id) | |
| 172 | + | c.Context().Register(manager) | |
| 173 | + | if err := c.Roundtrip(); err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | head := recvManagement(t, manager.events) | |
| 177 | + | if head[0] != wlr.ZwlrOutputManagerV1EventHead { | |
| 178 | + | t.Fatalf("head event: %v", head) | |
| 179 | + | } | |
| 180 | + | var serial [2]uint32 | |
| 181 | + | for serial[0] != wlr.ZwlrOutputManagerV1EventDone { | |
| 182 | + | serial = recvManagement(t, manager.events) | |
| 183 | + | } | |
| 184 | + | if serial[1] == 0 { | |
| 185 | + | t.Fatalf("done: %v", serial) | |
| 186 | + | } | |
| 187 | + | configID := c.AllocateID() | |
| 188 | + | configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |
| 189 | + | configProxy.SetID(configID) | |
| 190 | + | c.Context().Register(configProxy) | |
| 191 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | |
| 192 | + | chID := c.AllocateID() | |
| 193 | + | registerProtocol(t, c, chID) | |
| 194 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1]) | |
| 195 | + | requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(100), int32(0)) | |
| 196 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestTest) | |
| 197 | + | if err := c.Roundtrip(); err != nil { | |
| 198 | + | t.Fatal(err) | |
| 199 | + | } | |
| 200 | + | var req ports.OutputApply | |
| 201 | + | select { | |
| 202 | + | case req = <-apply: | |
| 203 | + | case <-time.After(time.Second): | |
| 204 | + | t.Fatal("no test request") | |
| 205 | + | } | |
| 206 | + | if !req.Test || len(req.Heads) != 1 || req.Heads[0].Pos.X != 100 { | |
| 207 | + | t.Fatalf("test: %+v", req) | |
| 208 | + | } | |
| 209 | + | replies <- ports.OutputApplied{ID: req.ID} | |
| 210 | + | waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded)) | |
| 211 | + | configID = c.AllocateID() | |
| 212 | + | configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c} | |
| 213 | + | configProxy.SetID(configID) | |
| 214 | + | c.Context().Register(configProxy) | |
| 215 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | |
| 216 | + | chID = c.AllocateID() | |
| 217 | + | registerProtocol(t, c, chID) | |
| 218 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1]) | |
| 219 | + | requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(512)) | |
| 220 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply) | |
| 221 | + | if err := c.Roundtrip(); err != nil { | |
| 222 | + | t.Fatal(err) | |
| 223 | + | } | |
| 224 | + | select { | |
| 225 | + | case req = <-apply: | |
| 226 | + | case <-time.After(time.Second): | |
| 227 | + | t.Fatal("no apply request") | |
| 228 | + | } | |
| 229 | + | if req.Test || req.Heads[0].Scale != 2 { | |
| 230 | + | t.Fatalf("apply: %+v", req) | |
| 231 | + | } | |
| 232 | + | replies <- ports.OutputApplied{ID: req.ID} | |
| 233 | + | waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded)) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // Each case uses a new connection because wl_display.error terminates that client. | |
| 237 | + | func TestOutputManagementProtocolErrors(t *testing.T) { | |
| 238 | + | const ( | |
| 239 | + | enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead | |
| 240 | + | disable = wlr.ZwlrOutputConfigurationV1RequestDisableHead | |
| 241 | + | apply = wlr.ZwlrOutputConfigurationV1RequestApply | |
| 242 | + | custom = wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode | |
| 243 | + | ) | |
| 244 | + | cases := []struct { | |
| 245 | + | name string | |
| 246 | + | object string | |
| 247 | + | code uint32 | |
| 248 | + | send func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) | |
| 249 | + | }{ | |
| 250 | + | {"already configured (enable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 251 | + | requestProtocol(t, c, cfg, enable, c.AllocateID(), head) | |
| 252 | + | }}, | |
| 253 | + | {"already configured (disable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 254 | + | requestProtocol(t, c, cfg, disable, head) | |
| 255 | + | }}, | |
| 256 | + | {"unconfigured head", "configuration", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 257 | + | requestProtocol(t, c, cfg, apply) | |
| 258 | + | }}, | |
| 259 | + | {"already used (configuration)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 260 | + | requestProtocol(t, c, cfg, apply) | |
| 261 | + | requestProtocol(t, c, cfg, apply) | |
| 262 | + | }}, | |
| 263 | + | {"already used (head)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 264 | + | requestProtocol(t, c, cfg, apply) | |
| 265 | + | requestProtocol(t, c, cfg, enable, c.AllocateID(), head) | |
| 266 | + | }}, | |
| 267 | + | {"already set mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 268 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |
| 269 | + | requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000)) | |
| 270 | + | }}, | |
| 271 | + | {"already set custom mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 272 | + | requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000)) | |
| 273 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |
| 274 | + | }}, | |
| 275 | + | {"already set position", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 276 | + | for range 2 { | |
| 277 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(0), int32(0)) | |
| 278 | + | } | |
| 279 | + | }}, | |
| 280 | + | {"already set transform", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 281 | + | for range 2 { | |
| 282 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(0)) | |
| 283 | + | } | |
| 284 | + | }}, | |
| 285 | + | {"already set scale", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 286 | + | for range 2 { | |
| 287 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(256)) | |
| 288 | + | } | |
| 289 | + | }}, | |
| 290 | + | {"already set adaptive sync", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 291 | + | for range 2 { | |
| 292 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0)) | |
| 293 | + | } | |
| 294 | + | }}, | |
| 295 | + | {"invalid mode", "head", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 296 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |
| 297 | + | }}, | |
| 298 | + | {"invalid custom mode", "head", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 299 | + | requestProtocol(t, c, ch, custom, int32(0), int32(600), int32(60000)) | |
| 300 | + | }}, | |
| 301 | + | {"invalid transform", "head", 4, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 302 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(8)) | |
| 303 | + | }}, | |
| 304 | + | {"invalid scale", "head", 5, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 305 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(0)) | |
| 306 | + | }}, | |
| 307 | + | {"invalid adaptive sync state", "head", 6, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |
| 308 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(2)) | |
| 309 | + | }}, | |
| 310 | + | } | |
| 311 | + | for _, tc := range cases { | |
| 312 | + | t.Run(tc.name, func(t *testing.T) { | |
| 313 | + | s, _, _, _, dir := outputTestServer(t) | |
| 314 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |
| 315 | + | t.Fatal("display stopped") | |
| 316 | + | } | |
| 317 | + | c := protocolClient(t, s, dir) | |
| 318 | + | managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 319 | + | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |
| 320 | + | p.SetID(managerID) | |
| 321 | + | c.Context().Register(p) | |
| 322 | + | if err := c.Roundtrip(); err != nil { | |
| 323 | + | t.Fatal(err) | |
| 324 | + | } | |
| 325 | + | head := recvManagement(t, p.events) | |
| 326 | + | if head[0] != wlr.ZwlrOutputManagerV1EventHead { | |
| 327 | + | t.Fatalf("head: %v", head) | |
| 328 | + | } | |
| 329 | + | var done [2]uint32 | |
| 330 | + | for done[0] != wlr.ZwlrOutputManagerV1EventDone { | |
| 331 | + | done = recvManagement(t, p.events) | |
| 332 | + | } | |
| 333 | + | mode := <-p.modes | |
| 334 | + | if tc.name == "invalid mode" { | |
| 335 | + | other := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 336 | + | otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |
| 337 | + | otherEvents.SetID(other) | |
| 338 | + | c.Context().Register(otherEvents) | |
| 339 | + | if err := c.Roundtrip(); err != nil { | |
| 340 | + | t.Fatal(err) | |
| 341 | + | } | |
| 342 | + | mode = <-otherEvents.modes | |
| 343 | + | } | |
| 344 | + | cfg := c.AllocateID() | |
| 345 | + | registerProtocol(t, c, cfg) | |
| 346 | + | requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1]) | |
| 347 | + | var ch uint32 | |
| 348 | + | if tc.name != "unconfigured head" { | |
| 349 | + | ch = c.AllocateID() | |
| 350 | + | registerProtocol(t, c, ch) | |
| 351 | + | requestProtocol(t, c, cfg, enable, ch, head[1]) | |
| 352 | + | } | |
| 353 | + | if err := c.Roundtrip(); err != nil { | |
| 354 | + | t.Fatal(err) | |
| 355 | + | } | |
| 356 | + | tc.send(t, c, cfg, ch, head[1], mode) | |
| 357 | + | object := cfg | |
| 358 | + | if tc.object == "head" { | |
| 359 | + | object = ch | |
| 360 | + | } | |
| 361 | + | expectProtocolError(t, c, object, tc.code) | |
| 362 | + | }) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestOutputManagementInitialHeadState(t *testing.T) { | |
| 367 | + | for _, enabled := range []bool{true, false} { | |
| 368 | + | t.Run(map[bool]string{true: "enabled", false: "disabled"}[enabled], func(t *testing.T) { | |
| 369 | + | s, _, _, _, dir := outputTestServer(t) | |
| 370 | + | heads := testHead() | |
| 371 | + | heads.Heads[0].Enabled = enabled | |
| 372 | + | if !s.display.Do(func() { | |
| 373 | + | s.setOutputHeads(heads) | |
| 374 | + | s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, X: 100, Scale: 2}}}) | |
| 375 | + | }) { | |
| 376 | + | t.Fatal("display stopped") | |
| 377 | + | } | |
| 378 | + | c := protocolClient(t, s, dir) | |
| 379 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 380 | + | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |
| 381 | + | p.SetID(id) | |
| 382 | + | c.Context().Register(p) | |
| 383 | + | if err := c.Roundtrip(); err != nil { | |
| 384 | + | t.Fatal(err) | |
| 385 | + | } | |
| 386 | + | for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) { | |
| 387 | + | } | |
| 388 | + | mode := <-p.modes | |
| 389 | + | found := map[uint32]bool{} | |
| 390 | + | for len(p.headEvents) > 0 { | |
| 391 | + | ev := <-p.headEvents | |
| 392 | + | found[ev[0]] = true | |
| 393 | + | if ev[0] == wlr.ZwlrOutputHeadV1EventEnabled && (ev[1] != 0) != enabled { | |
| 394 | + | t.Fatalf("enabled event: %v", ev) | |
| 395 | + | } | |
| 396 | + | if ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode && ev[1] != mode { | |
| 397 | + | t.Fatalf("current mode: %v, want %d", ev, mode) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | if !found[wlr.ZwlrOutputHeadV1EventMode] || !found[wlr.ZwlrOutputHeadV1EventEnabled] || found[wlr.ZwlrOutputHeadV1EventCurrentMode] != enabled || found[wlr.ZwlrOutputHeadV1EventTransform] != enabled || found[wlr.ZwlrOutputHeadV1EventPosition] != enabled || found[wlr.ZwlrOutputHeadV1EventScale] != enabled { | |
| 401 | + | t.Fatalf("head state: %v", found) | |
| 402 | + | } | |
| 403 | + | }) | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func TestOutputManagementStopDestroysManager(t *testing.T) { | |
| 408 | + | s, _, _, _, dir := outputTestServer(t) | |
| 409 | + | c := protocolClient(t, s, dir) | |
| 410 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 411 | + | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |
| 412 | + | p.SetID(id) | |
| 413 | + | c.Context().Register(p) | |
| 414 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop) | |
| 415 | + | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone) | |
| 416 | + | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished) | |
| 417 | + | if !s.display.Do(func() { | |
| 418 | + | if len(s.outputManagers) != 0 { | |
| 419 | + | t.Errorf("manager retained: %d", len(s.outputManagers)) | |
| 420 | + | } | |
| 421 | + | }) { | |
| 422 | + | t.Fatal("display stopped") | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func TestOutputManagementReleasedModeNotReused(t *testing.T) { | |
| 427 | + | s, _, _, _, dir := outputTestServer(t) | |
| 428 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |
| 429 | + | t.Fatal("display stopped") | |
| 430 | + | } | |
| 431 | + | c := protocolClient(t, s, dir) | |
| 432 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 433 | + | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |
| 434 | + | p.SetID(id) | |
| 435 | + | c.Context().Register(p) | |
| 436 | + | if err := c.Roundtrip(); err != nil { | |
| 437 | + | t.Fatal(err) | |
| 438 | + | } | |
| 439 | + | for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) { | |
| 440 | + | } | |
| 441 | + | mode := <-p.modes | |
| 442 | + | for len(p.headEvents) > 0 { | |
| 443 | + | <-p.headEvents | |
| 444 | + | } | |
| 445 | + | requestProtocol(t, c, mode, wlr.ZwlrOutputModeV1RequestRelease) | |
| 446 | + | if err := c.Roundtrip(); err != nil { | |
| 447 | + | t.Fatal(err) | |
| 448 | + | } | |
| 449 | + | if !s.display.Do(func() { | |
| 450 | + | m := s.outputManagers[0] | |
| 451 | + | h := m.heads["HEADLESS-1"] | |
| 452 | + | if len(h.modes) != 0 || len(h.modeResources) != 0 { | |
| 453 | + | t.Errorf("released mode retained: %d %d", len(h.modes), len(h.modeResources)) | |
| 454 | + | } | |
| 455 | + | s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: testHead().Heads[0].Info, X: 100, Scale: 1}}}) | |
| 456 | + | }) { | |
| 457 | + | t.Fatal("display stopped") | |
| 458 | + | } | |
| 459 | + | if err := c.Roundtrip(); err != nil { | |
| 460 | + | t.Fatal(err) | |
| 461 | + | } | |
| 462 | + | for len(p.headEvents) > 0 { | |
| 463 | + | if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode { | |
| 464 | + | t.Fatalf("dead mode referenced: %v", ev) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, uint32, uint32) { | |
| 470 | + | t.Helper() | |
| 471 | + | s, _, _, _, dir := outputTestServer(t) | |
| 472 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |
| 473 | + | t.Fatal("display stopped") | |
| 474 | + | } | |
| 475 | + | c := protocolClient(t, s, dir) | |
| 476 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |
| 477 | + | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |
| 478 | + | p.SetID(id) | |
| 479 | + | c.Context().Register(p) | |
| 480 | + | if err := c.Roundtrip(); err != nil { | |
| 481 | + | t.Fatal(err) | |
| 482 | + | } | |
| 483 | + | head := recvManagement(t, p.events)[1] | |
| 484 | + | var done [2]uint32 | |
| 485 | + | for done[0] != wlr.ZwlrOutputManagerV1EventDone { | |
| 486 | + | done = recvManagement(t, p.events) | |
| 487 | + | } | |
| 488 | + | return s, c, id, head, <-p.modes, done[1] | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func TestOutputManagementSetModeAfterHotplug(t *testing.T) { | |
| 492 | + | s, c, id, head, mode, serial := managementSetup(t) | |
| 493 | + | cfg := c.AllocateID() | |
| 494 | + | registerProtocol(t, c, cfg) | |
| 495 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial) | |
| 496 | + | ch := c.AllocateID() | |
| 497 | + | registerProtocol(t, c, ch) | |
| 498 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head) | |
| 499 | + | if err := c.Roundtrip(); err != nil { | |
| 500 | + | t.Fatal(err) | |
| 501 | + | } | |
| 502 | + | if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) { | |
| 503 | + | t.Fatal("display stopped") | |
| 504 | + | } | |
| 505 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |
| 506 | + | expectProtocolError(t, c, ch, uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode)) | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func TestOutputManagementDestroyConfigurationChildren(t *testing.T) { | |
| 510 | + | s, c, id, head, _, serial := managementSetup(t) | |
| 511 | + | cfg := c.AllocateID() | |
| 512 | + | registerProtocol(t, c, cfg) | |
| 513 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial) | |
| 514 | + | ch := c.AllocateID() | |
| 515 | + | registerProtocol(t, c, ch) | |
| 516 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head) | |
| 517 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | |
| 518 | + | if err := c.Roundtrip(); err != nil { | |
| 519 | + | t.Fatal(err) | |
| 520 | + | } | |
| 521 | + | var child *wlr.ZwlrOutputConfigurationHeadV1 | |
| 522 | + | if !s.display.Do(func() { | |
| 523 | + | for _, pending := range s.outputReplies { | |
| 524 | + | child = pending.heads["HEADLESS-1"].res | |
| 525 | + | } | |
| 526 | + | }) { | |
| 527 | + | t.Fatal("display stopped") | |
| 528 | + | } | |
| 529 | + | if child == nil { | |
| 530 | + | t.Fatal("no child resource") | |
| 531 | + | } | |
| 532 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | |
| 533 | + | if err := c.Roundtrip(); err != nil { | |
| 534 | + | t.Fatal(err) | |
| 535 | + | } | |
| 536 | + | if !s.display.Do(func() { | |
| 537 | + | if child.Alive() { | |
| 538 | + | t.Error("configuration head survived parent destruction") | |
| 539 | + | } | |
| 540 | + | }) { | |
| 541 | + | t.Fatal("display stopped") | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) { | |
| 546 | + | _, c, id, _, _, serial := managementSetup(t) | |
| 547 | + | cfg := c.AllocateID() | |
| 548 | + | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |
| 549 | + | p.SetID(cfg) | |
| 550 | + | c.Context().Register(p) | |
| 551 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1) | |
| 552 | + | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled) | |
| 553 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | |
| 554 | + | expectProtocolError(t, c, cfg, uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed)) | |
| 555 | + | select { | |
| 556 | + | case ev := <-p.events: | |
| 557 | + | t.Fatalf("second terminal event: %v", ev) | |
| 558 | + | default: | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | func TestOutputManagementDestroyDropsPendingReply(t *testing.T) { | |
| 563 | + | s, c, id, head, _, serial := managementSetup(t) | |
| 564 | + | cfg := c.AllocateID() | |
| 565 | + | registerProtocol(t, c, cfg) | |
| 566 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial) | |
| 567 | + | ch := c.AllocateID() | |
| 568 | + | registerProtocol(t, c, ch) | |
| 569 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head) | |
| 570 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | |
| 571 | + | if err := c.Roundtrip(); err != nil { | |
| 572 | + | t.Fatal(err) | |
| 573 | + | } | |
| 574 | + | if !s.display.Do(func() { | |
| 575 | + | if len(s.outputReplies) != 1 { | |
| 576 | + | t.Errorf("pending replies: %d", len(s.outputReplies)) | |
| 577 | + | } | |
| 578 | + | }) { | |
| 579 | + | t.Fatal("display stopped") | |
| 580 | + | } | |
| 581 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | |
| 582 | + | if err := c.Roundtrip(); err != nil { | |
| 583 | + | t.Fatal(err) | |
| 584 | + | } | |
| 585 | + | if !s.display.Do(func() { | |
| 586 | + | if len(s.outputReplies) != 0 { | |
| 587 | + | t.Errorf("orphan replies: %d", len(s.outputReplies)) | |
| 588 | + | } | |
| 589 | + | }) { | |
| 590 | + | t.Fatal("display stopped") | |
| 591 | + | } | |
| 592 | + | } | |
| @@ -0,0 +1,592 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlroutputmanagement" | |||
| 11 | + | "github.com/bnema/wlturbo" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type managementEvents struct { | |||
| 15 | + | wlturbo.BaseProxy | |||
| 16 | + | events chan [2]uint32 | |||
| 17 | + | client *wlturbo.Display | |||
| 18 | + | modes chan uint32 | |||
| 19 | + | headEvents chan [2]uint32 | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p *managementEvents) Dispatch(e *wlturbo.Event) { | |||
| 23 | + | msg := [2]uint32{uint32(e.Opcode), 0} | |||
| 24 | + | if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) || e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventDone) { | |||
| 25 | + | msg[1] = e.Uint32() | |||
| 26 | + | } | |||
| 27 | + | if e.Opcode == uint16(wlr.ZwlrOutputManagerV1EventHead) { | |||
| 28 | + | h := &managementHeadEvents{client: p.client, modes: p.modes, events: p.headEvents} | |||
| 29 | + | h.SetID(msg[1]) | |||
| 30 | + | p.client.Context().Register(h) | |||
| 31 | + | } | |||
| 32 | + | p.events <- msg | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | type managementHeadEvents struct { | |||
| 36 | + | wlturbo.BaseProxy | |||
| 37 | + | client *wlturbo.Display | |||
| 38 | + | modes chan uint32 | |||
| 39 | + | events chan [2]uint32 | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (p *managementHeadEvents) Dispatch(e *wlturbo.Event) { | |||
| 43 | + | if p.events != nil { | |||
| 44 | + | value := uint32(0) | |||
| 45 | + | switch uint32(e.Opcode) { | |||
| 46 | + | case wlr.ZwlrOutputHeadV1EventEnabled, wlr.ZwlrOutputHeadV1EventCurrentMode, wlr.ZwlrOutputHeadV1EventTransform: | |||
| 47 | + | value = e.Uint32() | |||
| 48 | + | } | |||
| 49 | + | p.events <- [2]uint32{uint32(e.Opcode), value} | |||
| 50 | + | } | |||
| 51 | + | if e.Opcode == uint16(wlr.ZwlrOutputHeadV1EventMode) { | |||
| 52 | + | mode := &protocolProxy{} | |||
| 53 | + | mode.SetID(e.Uint32()) | |||
| 54 | + | p.client.Context().Register(mode) | |||
| 55 | + | if p.modes != nil { | |||
| 56 | + | p.modes <- mode.ID() | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | func outputTestServer(t *testing.T) (*Server, chan ports.OutputHeads, chan ports.OutputApply, chan ports.OutputApplied, string) { | |||
| 61 | + | t.Helper() | |||
| 62 | + | dir := t.TempDir() | |||
| 63 | + | heads := make(chan ports.OutputHeads, 8) | |||
| 64 | + | applies := make(chan ports.OutputApply, 8) | |||
| 65 | + | replies := make(chan ports.OutputApplied, 8) | |||
| 66 | + | s, err := New(Options{RuntimeDir: dir}, Channels{OutputHeads: heads, OutputApply: applies, OutputApplied: replies}, logging.For(context.Background(), "wayland")) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 71 | + | done := make(chan error, 1) | |||
| 72 | + | go func() { done <- s.Run(ctx) }() | |||
| 73 | + | t.Cleanup(func() { | |||
| 74 | + | cancel() | |||
| 75 | + | if err := <-done; err != nil { | |||
| 76 | + | t.Error(err) | |||
| 77 | + | } | |||
| 78 | + | }) | |||
| 79 | + | return s, heads, applies, replies, dir | |||
| 80 | + | } | |||
| 81 | + | func testHead() ports.OutputHeads { | |||
| 82 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000, Preferred: true} | |||
| 83 | + | return ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080, RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}}} | |||
| 84 | + | } | |||
| 85 | + | func recvManagement(t *testing.T, ch <-chan [2]uint32) [2]uint32 { | |||
| 86 | + | t.Helper() | |||
| 87 | + | select { | |||
| 88 | + | case v := <-ch: | |||
| 89 | + | return v | |||
| 90 | + | case <-time.After(2 * time.Second): | |||
| 91 | + | t.Fatal("management event timeout") | |||
| 92 | + | return [2]uint32{} | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | func waitManagementEvent(t *testing.T, c *wlturbo.Display, ch <-chan [2]uint32, want uint32) { | |||
| 96 | + | t.Helper() | |||
| 97 | + | for i := 0; i < 12; i++ { | |||
| 98 | + | if err := c.Roundtrip(); err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | select { | |||
| 102 | + | case ev := <-ch: | |||
| 103 | + | if ev[0] != want { | |||
| 104 | + | t.Fatalf("event %v want %d", ev, want) | |||
| 105 | + | } | |||
| 106 | + | return | |||
| 107 | + | default: | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | t.Fatal("missing management event") | |||
| 111 | + | } | |||
| 112 | + | func TestOutputManagementStaleSerialAndLayout(t *testing.T) { | |||
| 113 | + | s, _, _, _, dir := outputTestServer(t) | |||
| 114 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |||
| 115 | + | t.Fatal("display stopped") | |||
| 116 | + | } | |||
| 117 | + | c := protocolClient(t, s, dir) | |||
| 118 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 119 | + | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | |||
| 120 | + | manager.SetID(id) | |||
| 121 | + | c.Context().Register(manager) | |||
| 122 | + | if err := c.Roundtrip(); err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | var first [2]uint32 | |||
| 126 | + | for first[0] != wlr.ZwlrOutputManagerV1EventDone { | |||
| 127 | + | first = recvManagement(t, manager.events) | |||
| 128 | + | } | |||
| 129 | + | if first[0] != wlr.ZwlrOutputManagerV1EventDone { | |||
| 130 | + | t.Fatalf("initial: %v", first) | |||
| 131 | + | } | |||
| 132 | + | if !s.display.Do(func() { | |||
| 133 | + | s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, X: 100, Width: 1920, Height: 1080, Scale: 1}}}) | |||
| 134 | + | }) { | |||
| 135 | + | t.Fatal("display stopped") | |||
| 136 | + | } | |||
| 137 | + | if err := c.Roundtrip(); err != nil { | |||
| 138 | + | t.Fatal(err) | |||
| 139 | + | } | |||
| 140 | + | second := recvManagement(t, manager.events) | |||
| 141 | + | if second[0] != wlr.ZwlrOutputManagerV1EventDone || second[1] == first[1] { | |||
| 142 | + | t.Fatalf("layout serial: %v -> %v", first, second) | |||
| 143 | + | } | |||
| 144 | + | configID := c.AllocateID() | |||
| 145 | + | proxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |||
| 146 | + | proxy.SetID(configID) | |||
| 147 | + | c.Context().Register(proxy) | |||
| 148 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, first[1]) | |||
| 149 | + | waitManagementEvent(t, c, proxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventCancelled)) | |||
| 150 | + | if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) { | |||
| 151 | + | t.Fatal("display stopped") | |||
| 152 | + | } | |||
| 153 | + | if err := c.Roundtrip(); err != nil { | |||
| 154 | + | t.Fatal(err) | |||
| 155 | + | } | |||
| 156 | + | third := recvManagement(t, manager.events) | |||
| 157 | + | if third[0] != wlr.ZwlrOutputManagerV1EventDone || third[1] == second[1] { | |||
| 158 | + | t.Fatalf("inventory serial: %v -> %v", second, third) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestOutputManagementHeadsAndApply(t *testing.T) { | |||
| 163 | + | s, heads, apply, replies, dir := outputTestServer(t) | |||
| 164 | + | heads <- testHead() | |||
| 165 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |||
| 166 | + | t.Fatal("display stopped") | |||
| 167 | + | } | |||
| 168 | + | c := protocolClient(t, s, dir) | |||
| 169 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 170 | + | manager := &managementEvents{events: make(chan [2]uint32, 32), client: c} | |||
| 171 | + | manager.SetID(id) | |||
| 172 | + | c.Context().Register(manager) | |||
| 173 | + | if err := c.Roundtrip(); err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | head := recvManagement(t, manager.events) | |||
| 177 | + | if head[0] != wlr.ZwlrOutputManagerV1EventHead { | |||
| 178 | + | t.Fatalf("head event: %v", head) | |||
| 179 | + | } | |||
| 180 | + | var serial [2]uint32 | |||
| 181 | + | for serial[0] != wlr.ZwlrOutputManagerV1EventDone { | |||
| 182 | + | serial = recvManagement(t, manager.events) | |||
| 183 | + | } | |||
| 184 | + | if serial[1] == 0 { | |||
| 185 | + | t.Fatalf("done: %v", serial) | |||
| 186 | + | } | |||
| 187 | + | configID := c.AllocateID() | |||
| 188 | + | configProxy := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |||
| 189 | + | configProxy.SetID(configID) | |||
| 190 | + | c.Context().Register(configProxy) | |||
| 191 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | |||
| 192 | + | chID := c.AllocateID() | |||
| 193 | + | registerProtocol(t, c, chID) | |||
| 194 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1]) | |||
| 195 | + | requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(100), int32(0)) | |||
| 196 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestTest) | |||
| 197 | + | if err := c.Roundtrip(); err != nil { | |||
| 198 | + | t.Fatal(err) | |||
| 199 | + | } | |||
| 200 | + | var req ports.OutputApply | |||
| 201 | + | select { | |||
| 202 | + | case req = <-apply: | |||
| 203 | + | case <-time.After(time.Second): | |||
| 204 | + | t.Fatal("no test request") | |||
| 205 | + | } | |||
| 206 | + | if !req.Test || len(req.Heads) != 1 || req.Heads[0].Pos.X != 100 { | |||
| 207 | + | t.Fatalf("test: %+v", req) | |||
| 208 | + | } | |||
| 209 | + | replies <- ports.OutputApplied{ID: req.ID} | |||
| 210 | + | waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded)) | |||
| 211 | + | configID = c.AllocateID() | |||
| 212 | + | configProxy = &managementEvents{events: make(chan [2]uint32, 8), client: c} | |||
| 213 | + | configProxy.SetID(configID) | |||
| 214 | + | c.Context().Register(configProxy) | |||
| 215 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, configID, serial[1]) | |||
| 216 | + | chID = c.AllocateID() | |||
| 217 | + | registerProtocol(t, c, chID) | |||
| 218 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestEnableHead, chID, head[1]) | |||
| 219 | + | requestProtocol(t, c, chID, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(512)) | |||
| 220 | + | requestProtocol(t, c, configID, wlr.ZwlrOutputConfigurationV1RequestApply) | |||
| 221 | + | if err := c.Roundtrip(); err != nil { | |||
| 222 | + | t.Fatal(err) | |||
| 223 | + | } | |||
| 224 | + | select { | |||
| 225 | + | case req = <-apply: | |||
| 226 | + | case <-time.After(time.Second): | |||
| 227 | + | t.Fatal("no apply request") | |||
| 228 | + | } | |||
| 229 | + | if req.Test || req.Heads[0].Scale != 2 { | |||
| 230 | + | t.Fatalf("apply: %+v", req) | |||
| 231 | + | } | |||
| 232 | + | replies <- ports.OutputApplied{ID: req.ID} | |||
| 233 | + | waitManagementEvent(t, c, configProxy.events, uint32(wlr.ZwlrOutputConfigurationV1EventSucceeded)) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // Each case uses a new connection because wl_display.error terminates that client. | |||
| 237 | + | func TestOutputManagementProtocolErrors(t *testing.T) { | |||
| 238 | + | const ( | |||
| 239 | + | enable = wlr.ZwlrOutputConfigurationV1RequestEnableHead | |||
| 240 | + | disable = wlr.ZwlrOutputConfigurationV1RequestDisableHead | |||
| 241 | + | apply = wlr.ZwlrOutputConfigurationV1RequestApply | |||
| 242 | + | custom = wlr.ZwlrOutputConfigurationHeadV1RequestSetCustomMode | |||
| 243 | + | ) | |||
| 244 | + | cases := []struct { | |||
| 245 | + | name string | |||
| 246 | + | object string | |||
| 247 | + | code uint32 | |||
| 248 | + | send func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) | |||
| 249 | + | }{ | |||
| 250 | + | {"already configured (enable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 251 | + | requestProtocol(t, c, cfg, enable, c.AllocateID(), head) | |||
| 252 | + | }}, | |||
| 253 | + | {"already configured (disable)", "configuration", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 254 | + | requestProtocol(t, c, cfg, disable, head) | |||
| 255 | + | }}, | |||
| 256 | + | {"unconfigured head", "configuration", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 257 | + | requestProtocol(t, c, cfg, apply) | |||
| 258 | + | }}, | |||
| 259 | + | {"already used (configuration)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 260 | + | requestProtocol(t, c, cfg, apply) | |||
| 261 | + | requestProtocol(t, c, cfg, apply) | |||
| 262 | + | }}, | |||
| 263 | + | {"already used (head)", "configuration", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 264 | + | requestProtocol(t, c, cfg, apply) | |||
| 265 | + | requestProtocol(t, c, cfg, enable, c.AllocateID(), head) | |||
| 266 | + | }}, | |||
| 267 | + | {"already set mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 268 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |||
| 269 | + | requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000)) | |||
| 270 | + | }}, | |||
| 271 | + | {"already set custom mode", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 272 | + | requestProtocol(t, c, ch, custom, int32(800), int32(600), int32(60000)) | |||
| 273 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |||
| 274 | + | }}, | |||
| 275 | + | {"already set position", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 276 | + | for range 2 { | |||
| 277 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetPosition, int32(0), int32(0)) | |||
| 278 | + | } | |||
| 279 | + | }}, | |||
| 280 | + | {"already set transform", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 281 | + | for range 2 { | |||
| 282 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(0)) | |||
| 283 | + | } | |||
| 284 | + | }}, | |||
| 285 | + | {"already set scale", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 286 | + | for range 2 { | |||
| 287 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(256)) | |||
| 288 | + | } | |||
| 289 | + | }}, | |||
| 290 | + | {"already set adaptive sync", "head", 1, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 291 | + | for range 2 { | |||
| 292 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(0)) | |||
| 293 | + | } | |||
| 294 | + | }}, | |||
| 295 | + | {"invalid mode", "head", 2, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 296 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |||
| 297 | + | }}, | |||
| 298 | + | {"invalid custom mode", "head", 3, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 299 | + | requestProtocol(t, c, ch, custom, int32(0), int32(600), int32(60000)) | |||
| 300 | + | }}, | |||
| 301 | + | {"invalid transform", "head", 4, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 302 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetTransform, int32(8)) | |||
| 303 | + | }}, | |||
| 304 | + | {"invalid scale", "head", 5, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 305 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetScale, int32(0)) | |||
| 306 | + | }}, | |||
| 307 | + | {"invalid adaptive sync state", "head", 6, func(t *testing.T, c *wlturbo.Display, cfg, ch, head, mode uint32) { | |||
| 308 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetAdaptiveSync, uint32(2)) | |||
| 309 | + | }}, | |||
| 310 | + | } | |||
| 311 | + | for _, tc := range cases { | |||
| 312 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 313 | + | s, _, _, _, dir := outputTestServer(t) | |||
| 314 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |||
| 315 | + | t.Fatal("display stopped") | |||
| 316 | + | } | |||
| 317 | + | c := protocolClient(t, s, dir) | |||
| 318 | + | managerID := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 319 | + | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |||
| 320 | + | p.SetID(managerID) | |||
| 321 | + | c.Context().Register(p) | |||
| 322 | + | if err := c.Roundtrip(); err != nil { | |||
| 323 | + | t.Fatal(err) | |||
| 324 | + | } | |||
| 325 | + | head := recvManagement(t, p.events) | |||
| 326 | + | if head[0] != wlr.ZwlrOutputManagerV1EventHead { | |||
| 327 | + | t.Fatalf("head: %v", head) | |||
| 328 | + | } | |||
| 329 | + | var done [2]uint32 | |||
| 330 | + | for done[0] != wlr.ZwlrOutputManagerV1EventDone { | |||
| 331 | + | done = recvManagement(t, p.events) | |||
| 332 | + | } | |||
| 333 | + | mode := <-p.modes | |||
| 334 | + | if tc.name == "invalid mode" { | |||
| 335 | + | other := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 336 | + | otherEvents := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |||
| 337 | + | otherEvents.SetID(other) | |||
| 338 | + | c.Context().Register(otherEvents) | |||
| 339 | + | if err := c.Roundtrip(); err != nil { | |||
| 340 | + | t.Fatal(err) | |||
| 341 | + | } | |||
| 342 | + | mode = <-otherEvents.modes | |||
| 343 | + | } | |||
| 344 | + | cfg := c.AllocateID() | |||
| 345 | + | registerProtocol(t, c, cfg) | |||
| 346 | + | requestProtocol(t, c, managerID, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, done[1]) | |||
| 347 | + | var ch uint32 | |||
| 348 | + | if tc.name != "unconfigured head" { | |||
| 349 | + | ch = c.AllocateID() | |||
| 350 | + | registerProtocol(t, c, ch) | |||
| 351 | + | requestProtocol(t, c, cfg, enable, ch, head[1]) | |||
| 352 | + | } | |||
| 353 | + | if err := c.Roundtrip(); err != nil { | |||
| 354 | + | t.Fatal(err) | |||
| 355 | + | } | |||
| 356 | + | tc.send(t, c, cfg, ch, head[1], mode) | |||
| 357 | + | object := cfg | |||
| 358 | + | if tc.object == "head" { | |||
| 359 | + | object = ch | |||
| 360 | + | } | |||
| 361 | + | expectProtocolError(t, c, object, tc.code) | |||
| 362 | + | }) | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestOutputManagementInitialHeadState(t *testing.T) { | |||
| 367 | + | for _, enabled := range []bool{true, false} { | |||
| 368 | + | t.Run(map[bool]string{true: "enabled", false: "disabled"}[enabled], func(t *testing.T) { | |||
| 369 | + | s, _, _, _, dir := outputTestServer(t) | |||
| 370 | + | heads := testHead() | |||
| 371 | + | heads.Heads[0].Enabled = enabled | |||
| 372 | + | if !s.display.Do(func() { | |||
| 373 | + | s.setOutputHeads(heads) | |||
| 374 | + | s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: heads.Heads[0].Info, X: 100, Scale: 2}}}) | |||
| 375 | + | }) { | |||
| 376 | + | t.Fatal("display stopped") | |||
| 377 | + | } | |||
| 378 | + | c := protocolClient(t, s, dir) | |||
| 379 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 380 | + | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |||
| 381 | + | p.SetID(id) | |||
| 382 | + | c.Context().Register(p) | |||
| 383 | + | if err := c.Roundtrip(); err != nil { | |||
| 384 | + | t.Fatal(err) | |||
| 385 | + | } | |||
| 386 | + | for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) { | |||
| 387 | + | } | |||
| 388 | + | mode := <-p.modes | |||
| 389 | + | found := map[uint32]bool{} | |||
| 390 | + | for len(p.headEvents) > 0 { | |||
| 391 | + | ev := <-p.headEvents | |||
| 392 | + | found[ev[0]] = true | |||
| 393 | + | if ev[0] == wlr.ZwlrOutputHeadV1EventEnabled && (ev[1] != 0) != enabled { | |||
| 394 | + | t.Fatalf("enabled event: %v", ev) | |||
| 395 | + | } | |||
| 396 | + | if ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode && ev[1] != mode { | |||
| 397 | + | t.Fatalf("current mode: %v, want %d", ev, mode) | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | if !found[wlr.ZwlrOutputHeadV1EventMode] || !found[wlr.ZwlrOutputHeadV1EventEnabled] || found[wlr.ZwlrOutputHeadV1EventCurrentMode] != enabled || found[wlr.ZwlrOutputHeadV1EventTransform] != enabled || found[wlr.ZwlrOutputHeadV1EventPosition] != enabled || found[wlr.ZwlrOutputHeadV1EventScale] != enabled { | |||
| 401 | + | t.Fatalf("head state: %v", found) | |||
| 402 | + | } | |||
| 403 | + | }) | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func TestOutputManagementStopDestroysManager(t *testing.T) { | |||
| 408 | + | s, _, _, _, dir := outputTestServer(t) | |||
| 409 | + | c := protocolClient(t, s, dir) | |||
| 410 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 411 | + | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |||
| 412 | + | p.SetID(id) | |||
| 413 | + | c.Context().Register(p) | |||
| 414 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestStop) | |||
| 415 | + | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventDone) | |||
| 416 | + | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputManagerV1EventFinished) | |||
| 417 | + | if !s.display.Do(func() { | |||
| 418 | + | if len(s.outputManagers) != 0 { | |||
| 419 | + | t.Errorf("manager retained: %d", len(s.outputManagers)) | |||
| 420 | + | } | |||
| 421 | + | }) { | |||
| 422 | + | t.Fatal("display stopped") | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | func TestOutputManagementReleasedModeNotReused(t *testing.T) { | |||
| 427 | + | s, _, _, _, dir := outputTestServer(t) | |||
| 428 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |||
| 429 | + | t.Fatal("display stopped") | |||
| 430 | + | } | |||
| 431 | + | c := protocolClient(t, s, dir) | |||
| 432 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 433 | + | p := &managementEvents{events: make(chan [2]uint32, 32), headEvents: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |||
| 434 | + | p.SetID(id) | |||
| 435 | + | c.Context().Register(p) | |||
| 436 | + | if err := c.Roundtrip(); err != nil { | |||
| 437 | + | t.Fatal(err) | |||
| 438 | + | } | |||
| 439 | + | for ev := recvManagement(t, p.events); ev[0] != wlr.ZwlrOutputManagerV1EventDone; ev = recvManagement(t, p.events) { | |||
| 440 | + | } | |||
| 441 | + | mode := <-p.modes | |||
| 442 | + | for len(p.headEvents) > 0 { | |||
| 443 | + | <-p.headEvents | |||
| 444 | + | } | |||
| 445 | + | requestProtocol(t, c, mode, wlr.ZwlrOutputModeV1RequestRelease) | |||
| 446 | + | if err := c.Roundtrip(); err != nil { | |||
| 447 | + | t.Fatal(err) | |||
| 448 | + | } | |||
| 449 | + | if !s.display.Do(func() { | |||
| 450 | + | m := s.outputManagers[0] | |||
| 451 | + | h := m.heads["HEADLESS-1"] | |||
| 452 | + | if len(h.modes) != 0 || len(h.modeResources) != 0 { | |||
| 453 | + | t.Errorf("released mode retained: %d %d", len(h.modes), len(h.modeResources)) | |||
| 454 | + | } | |||
| 455 | + | s.setOutputs(ports.SetOutputs{Outputs: ports.Layout{{Info: testHead().Heads[0].Info, X: 100, Scale: 1}}}) | |||
| 456 | + | }) { | |||
| 457 | + | t.Fatal("display stopped") | |||
| 458 | + | } | |||
| 459 | + | if err := c.Roundtrip(); err != nil { | |||
| 460 | + | t.Fatal(err) | |||
| 461 | + | } | |||
| 462 | + | for len(p.headEvents) > 0 { | |||
| 463 | + | if ev := <-p.headEvents; ev[0] == wlr.ZwlrOutputHeadV1EventCurrentMode { | |||
| 464 | + | t.Fatalf("dead mode referenced: %v", ev) | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | func managementSetup(t *testing.T) (*Server, *wlturbo.Display, uint32, uint32, uint32, uint32) { | |||
| 470 | + | t.Helper() | |||
| 471 | + | s, _, _, _, dir := outputTestServer(t) | |||
| 472 | + | if !s.display.Do(func() { s.setOutputHeads(testHead()) }) { | |||
| 473 | + | t.Fatal("display stopped") | |||
| 474 | + | } | |||
| 475 | + | c := protocolClient(t, s, dir) | |||
| 476 | + | id := bindVersion(t, c, "zwlr_output_manager_v1", 4) | |||
| 477 | + | p := &managementEvents{events: make(chan [2]uint32, 32), modes: make(chan uint32, 4), client: c} | |||
| 478 | + | p.SetID(id) | |||
| 479 | + | c.Context().Register(p) | |||
| 480 | + | if err := c.Roundtrip(); err != nil { | |||
| 481 | + | t.Fatal(err) | |||
| 482 | + | } | |||
| 483 | + | head := recvManagement(t, p.events)[1] | |||
| 484 | + | var done [2]uint32 | |||
| 485 | + | for done[0] != wlr.ZwlrOutputManagerV1EventDone { | |||
| 486 | + | done = recvManagement(t, p.events) | |||
| 487 | + | } | |||
| 488 | + | return s, c, id, head, <-p.modes, done[1] | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func TestOutputManagementSetModeAfterHotplug(t *testing.T) { | |||
| 492 | + | s, c, id, head, mode, serial := managementSetup(t) | |||
| 493 | + | cfg := c.AllocateID() | |||
| 494 | + | registerProtocol(t, c, cfg) | |||
| 495 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial) | |||
| 496 | + | ch := c.AllocateID() | |||
| 497 | + | registerProtocol(t, c, ch) | |||
| 498 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head) | |||
| 499 | + | if err := c.Roundtrip(); err != nil { | |||
| 500 | + | t.Fatal(err) | |||
| 501 | + | } | |||
| 502 | + | if !s.display.Do(func() { s.setOutputHeads(ports.OutputHeads{}) }) { | |||
| 503 | + | t.Fatal("display stopped") | |||
| 504 | + | } | |||
| 505 | + | requestProtocol(t, c, ch, wlr.ZwlrOutputConfigurationHeadV1RequestSetMode, mode) | |||
| 506 | + | expectProtocolError(t, c, ch, uint32(wlr.ZwlrOutputConfigurationHeadV1ErrorInvalidMode)) | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func TestOutputManagementDestroyConfigurationChildren(t *testing.T) { | |||
| 510 | + | s, c, id, head, _, serial := managementSetup(t) | |||
| 511 | + | cfg := c.AllocateID() | |||
| 512 | + | registerProtocol(t, c, cfg) | |||
| 513 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial) | |||
| 514 | + | ch := c.AllocateID() | |||
| 515 | + | registerProtocol(t, c, ch) | |||
| 516 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head) | |||
| 517 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | |||
| 518 | + | if err := c.Roundtrip(); err != nil { | |||
| 519 | + | t.Fatal(err) | |||
| 520 | + | } | |||
| 521 | + | var child *wlr.ZwlrOutputConfigurationHeadV1 | |||
| 522 | + | if !s.display.Do(func() { | |||
| 523 | + | for _, pending := range s.outputReplies { | |||
| 524 | + | child = pending.heads["HEADLESS-1"].res | |||
| 525 | + | } | |||
| 526 | + | }) { | |||
| 527 | + | t.Fatal("display stopped") | |||
| 528 | + | } | |||
| 529 | + | if child == nil { | |||
| 530 | + | t.Fatal("no child resource") | |||
| 531 | + | } | |||
| 532 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | |||
| 533 | + | if err := c.Roundtrip(); err != nil { | |||
| 534 | + | t.Fatal(err) | |||
| 535 | + | } | |||
| 536 | + | if !s.display.Do(func() { | |||
| 537 | + | if child.Alive() { | |||
| 538 | + | t.Error("configuration head survived parent destruction") | |||
| 539 | + | } | |||
| 540 | + | }) { | |||
| 541 | + | t.Fatal("display stopped") | |||
| 542 | + | } | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func TestOutputManagementStaleConfigurationSingleTerminal(t *testing.T) { | |||
| 546 | + | _, c, id, _, _, serial := managementSetup(t) | |||
| 547 | + | cfg := c.AllocateID() | |||
| 548 | + | p := &managementEvents{events: make(chan [2]uint32, 8), client: c} | |||
| 549 | + | p.SetID(cfg) | |||
| 550 | + | c.Context().Register(p) | |||
| 551 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial-1) | |||
| 552 | + | waitManagementEvent(t, c, p.events, wlr.ZwlrOutputConfigurationV1EventCancelled) | |||
| 553 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | |||
| 554 | + | expectProtocolError(t, c, cfg, uint32(wlr.ZwlrOutputConfigurationV1ErrorAlreadyUsed)) | |||
| 555 | + | select { | |||
| 556 | + | case ev := <-p.events: | |||
| 557 | + | t.Fatalf("second terminal event: %v", ev) | |||
| 558 | + | default: | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | func TestOutputManagementDestroyDropsPendingReply(t *testing.T) { | |||
| 563 | + | s, c, id, head, _, serial := managementSetup(t) | |||
| 564 | + | cfg := c.AllocateID() | |||
| 565 | + | registerProtocol(t, c, cfg) | |||
| 566 | + | requestProtocol(t, c, id, wlr.ZwlrOutputManagerV1RequestCreateConfiguration, cfg, serial) | |||
| 567 | + | ch := c.AllocateID() | |||
| 568 | + | registerProtocol(t, c, ch) | |||
| 569 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestEnableHead, ch, head) | |||
| 570 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestApply) | |||
| 571 | + | if err := c.Roundtrip(); err != nil { | |||
| 572 | + | t.Fatal(err) | |||
| 573 | + | } | |||
| 574 | + | if !s.display.Do(func() { | |||
| 575 | + | if len(s.outputReplies) != 1 { | |||
| 576 | + | t.Errorf("pending replies: %d", len(s.outputReplies)) | |||
| 577 | + | } | |||
| 578 | + | }) { | |||
| 579 | + | t.Fatal("display stopped") | |||
| 580 | + | } | |||
| 581 | + | requestProtocol(t, c, cfg, wlr.ZwlrOutputConfigurationV1RequestDestroy) | |||
| 582 | + | if err := c.Roundtrip(); err != nil { | |||
| 583 | + | t.Fatal(err) | |||
| 584 | + | } | |||
| 585 | + | if !s.display.Do(func() { | |||
| 586 | + | if len(s.outputReplies) != 0 { | |||
| 587 | + | t.Errorf("orphan replies: %d", len(s.outputReplies)) | |||
| 588 | + | } | |||
| 589 | + | }) { | |||
| 590 | + | t.Fatal("display stopped") | |||
| 591 | + | } | |||
| 592 | + | } | |||
M internal/adapters/wayland/outputs.go +8 -0
| @@ -2,6 +2,7 @@ package wayland | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "slices" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -135,6 +136,13 @@ func (s *Server) addOutput(p ports.OutputPlacement) { | |||
| 135 | 136 | // advertised, gone ones withdrawn (their layer surfaces are closed), and | |
| 136 | 137 | // changed ones resent. | |
| 137 | 138 | func (s *Server) setOutputs(c ports.SetOutputs) { | |
| 139 | + | changed := !slices.Equal(s.outputPlaces, c.Outputs) | |
| 140 | + | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | |
| 141 | + | defer func() { | |
| 142 | + | if changed { | |
| 143 | + | s.refreshOutputManagers() | |
| 144 | + | } | |
| 145 | + | }() | |
| 138 | 146 | s.focusedOutput = c.Focused | |
| 139 | 147 | want := map[string]ports.OutputPlacement{} | |
| 140 | 148 | for _, p := range c.Outputs { | |
| @@ -2,6 +2,7 @@ package wayland | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "math" | 4 | "math" | ||
| 5 | + | "slices" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/neferwl/internal/ports" | 8 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -135,6 +136,13 @@ func (s *Server) addOutput(p ports.OutputPlacement) { | |||||
| 135 | // advertised, gone ones withdrawn (their layer surfaces are closed), and | 136 | // advertised, gone ones withdrawn (their layer surfaces are closed), and | ||
| 136 | // changed ones resent. | 137 | // changed ones resent. | ||
| 137 | func (s *Server) setOutputs(c ports.SetOutputs) { | 138 | func (s *Server) setOutputs(c ports.SetOutputs) { | ||
| 139 | + | changed := !slices.Equal(s.outputPlaces, c.Outputs) | |||
| 140 | + | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | |||
| 141 | + | defer func() { | |||
| 142 | + | if changed { | |||
| 143 | + | s.refreshOutputManagers() | |||
| 144 | + | } | |||
| 145 | + | }() | |||
| 138 | s.focusedOutput = c.Focused | 146 | s.focusedOutput = c.Focused | ||
| 139 | want := map[string]ports.OutputPlacement{} | 147 | want := map[string]ports.OutputPlacement{} | ||
| 140 | for _, p := range c.Outputs { | 148 | for _, p := range c.Outputs { | ||
M internal/adapters/wayland/server.go +70 -9
| @@ -50,6 +50,9 @@ type Channels struct { | |||
| 50 | 50 | // OutputFormats are the outputs' direct scanout formats, offered in | |
| 51 | 51 | // dmabuf feedback to fullscreen surfaces. | |
| 52 | 52 | OutputFormats <-chan ports.OutputFormats | |
| 53 | + | OutputHeads <-chan ports.OutputHeads | |
| 54 | + | OutputApply chan<- ports.OutputApply | |
| 55 | + | OutputApplied <-chan ports.OutputApplied | |
| 53 | 56 | } | |
| 54 | 57 | type Server struct { | |
| 55 | 58 | nextCapture uint64 | |
| @@ -138,14 +141,20 @@ type Server struct { | |||
| 138 | 141 | shortcutWindows, idleWindows map[ports.WindowID]bool | |
| 139 | 142 | // idleNotes and powers are the idle notifications and output power | |
| 140 | 143 | // objects (idle.go); outputsOff the outputs core turned off. | |
| 141 | - | idleNotes []*idleNotification | |
| 142 | - | powers []*outputPower | |
| 143 | - | outputsOff map[string]bool | |
| 144 | - | contentNotify chan struct{} | |
| 145 | - | contentReady chan struct{} | |
| 146 | - | outputs []*output | |
| 147 | - | focusedOutput string | |
| 148 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 144 | + | idleNotes []*idleNotification | |
| 145 | + | powers []*outputPower | |
| 146 | + | outputsOff map[string]bool | |
| 147 | + | contentNotify chan struct{} | |
| 148 | + | contentReady chan struct{} | |
| 149 | + | outputs []*output | |
| 150 | + | outputManagers []*outputManager | |
| 151 | + | outputHeads ports.OutputHeads | |
| 152 | + | outputPlaces ports.Layout | |
| 153 | + | managementSerial uint32 | |
| 154 | + | nextOutputApply uint64 | |
| 155 | + | outputReplies map[uint64]*outputConfiguration | |
| 156 | + | focusedOutput string | |
| 157 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 149 | 158 | // cursorSurface is the wl_pointer.set_cursor surface in use. | |
| 150 | 159 | cursorSurface *surface | |
| 151 | 160 | // cursorMu guards only the latest cursor change for forwardCursors. | |
| @@ -198,6 +207,8 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 198 | 207 | } | |
| 199 | 208 | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 200 | 209 | s.captureReplies = map[uint64]func(ports.CaptureDone){} | |
| 210 | + | s.outputReplies = map[uint64]*outputConfiguration{} | |
| 211 | + | s.managementSerial = 1 | |
| 201 | 212 | s.captureSources = map[*server.Resource]*output{} | |
| 202 | 213 | s.captureSessions = map[*captureSession]struct{}{} | |
| 203 | 214 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| @@ -263,7 +274,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 263 | 274 | s.started = time.Now() | |
| 264 | 275 | s.ctx = ctx | |
| 265 | 276 | var wg sync.WaitGroup | |
| 266 | - | wg.Add(7) | |
| 277 | + | wg.Add(9) | |
| 267 | 278 | go func() { defer wg.Done(); s.forward(ctx) }() | |
| 268 | 279 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | |
| 269 | 280 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | |
| @@ -301,6 +312,8 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 301 | 312 | }() | |
| 302 | 313 | go func() { defer wg.Done(); s.pace(ctx) }() | |
| 303 | 314 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | |
| 315 | + | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | |
| 316 | + | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | |
| 304 | 317 | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | |
| 305 | 318 | if s.syncWait != nil { | |
| 306 | 319 | wg.Add(1) | |
| @@ -311,6 +324,54 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 311 | 324 | return err | |
| 312 | 325 | } | |
| 313 | 326 | ||
| 327 | + | // forwardOutputHeads delivers backend inventory on the display owner goroutine. | |
| 328 | + | func (s *Server) forwardOutputHeads(ctx context.Context) { | |
| 329 | + | for { | |
| 330 | + | select { | |
| 331 | + | case <-ctx.Done(): | |
| 332 | + | return | |
| 333 | + | case <-s.display.Stopped(): | |
| 334 | + | return | |
| 335 | + | case heads, ok := <-s.channels.OutputHeads: | |
| 336 | + | if !ok { | |
| 337 | + | return | |
| 338 | + | } | |
| 339 | + | if !s.display.Do(func() { s.setOutputHeads(heads) }) { | |
| 340 | + | return | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func (s *Server) forwardOutputApplied(ctx context.Context) { | |
| 347 | + | for { | |
| 348 | + | select { | |
| 349 | + | case <-ctx.Done(): | |
| 350 | + | return | |
| 351 | + | case <-s.display.Stopped(): | |
| 352 | + | return | |
| 353 | + | case result, ok := <-s.channels.OutputApplied: | |
| 354 | + | if !ok { | |
| 355 | + | return | |
| 356 | + | } | |
| 357 | + | if !s.display.Do(func() { | |
| 358 | + | c := s.outputReplies[result.ID] | |
| 359 | + | delete(s.outputReplies, result.ID) | |
| 360 | + | if c == nil || !c.res.Alive() { | |
| 361 | + | return | |
| 362 | + | } | |
| 363 | + | if result.Err != nil { | |
| 364 | + | c.res.SendFailed() | |
| 365 | + | } else { | |
| 366 | + | c.res.SendSucceeded() | |
| 367 | + | } | |
| 368 | + | }) { | |
| 369 | + | return | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | ||
| 314 | 375 | // damageHistory is how many contents a window's damage history covers: | |
| 315 | 376 | // enough for a renderer target a few frames old. | |
| 316 | 377 | const damageHistory = 4 | |
| @@ -50,6 +50,9 @@ type Channels struct { | |||||
| 50 | // OutputFormats are the outputs' direct scanout formats, offered in | 50 | // OutputFormats are the outputs' direct scanout formats, offered in | ||
| 51 | // dmabuf feedback to fullscreen surfaces. | 51 | // dmabuf feedback to fullscreen surfaces. | ||
| 52 | OutputFormats <-chan ports.OutputFormats | 52 | OutputFormats <-chan ports.OutputFormats | ||
| 53 | + | OutputHeads <-chan ports.OutputHeads | |||
| 54 | + | OutputApply chan<- ports.OutputApply | |||
| 55 | + | OutputApplied <-chan ports.OutputApplied | |||
| 53 | } | 56 | } | ||
| 54 | type Server struct { | 57 | type Server struct { | ||
| 55 | nextCapture uint64 | 58 | nextCapture uint64 | ||
| @@ -138,14 +141,20 @@ type Server struct { | |||||
| 138 | shortcutWindows, idleWindows map[ports.WindowID]bool | 141 | shortcutWindows, idleWindows map[ports.WindowID]bool | ||
| 139 | // idleNotes and powers are the idle notifications and output power | 142 | // idleNotes and powers are the idle notifications and output power | ||
| 140 | // objects (idle.go); outputsOff the outputs core turned off. | 143 | // objects (idle.go); outputsOff the outputs core turned off. | ||
| 141 | - | idleNotes []*idleNotification | 144 | + | idleNotes []*idleNotification |
| 142 | - | powers []*outputPower | 145 | + | powers []*outputPower |
| 143 | - | outputsOff map[string]bool | 146 | + | outputsOff map[string]bool |
| 144 | - | contentNotify chan struct{} | 147 | + | contentNotify chan struct{} |
| 145 | - | contentReady chan struct{} | 148 | + | contentReady chan struct{} |
| 146 | - | outputs []*output | 149 | + | outputs []*output |
| 147 | - | focusedOutput string | 150 | + | outputManagers []*outputManager |
| 148 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | 151 | + | outputHeads ports.OutputHeads |
| 152 | + | outputPlaces ports.Layout | |||
| 153 | + | managementSerial uint32 | |||
| 154 | + | nextOutputApply uint64 | |||
| 155 | + | outputReplies map[uint64]*outputConfiguration | |||
| 156 | + | focusedOutput string | |||
| 157 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |||
| 149 | // cursorSurface is the wl_pointer.set_cursor surface in use. | 158 | // cursorSurface is the wl_pointer.set_cursor surface in use. | ||
| 150 | cursorSurface *surface | 159 | cursorSurface *surface | ||
| 151 | // cursorMu guards only the latest cursor change for forwardCursors. | 160 | // cursorMu guards only the latest cursor change for forwardCursors. | ||
| @@ -198,6 +207,8 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 198 | } | 207 | } | ||
| 199 | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 208 | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), damage: map[ports.WindowID][]ports.SeqDamage{}, contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | ||
| 200 | s.captureReplies = map[uint64]func(ports.CaptureDone){} | 209 | s.captureReplies = map[uint64]func(ports.CaptureDone){} | ||
| 210 | + | s.outputReplies = map[uint64]*outputConfiguration{} | |||
| 211 | + | s.managementSerial = 1 | |||
| 201 | s.captureSources = map[*server.Resource]*output{} | 212 | s.captureSources = map[*server.Resource]*output{} | ||
| 202 | s.captureSessions = map[*captureSession]struct{}{} | 213 | s.captureSessions = map[*captureSession]struct{}{} | ||
| 203 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | 214 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | ||
| @@ -263,7 +274,7 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 263 | s.started = time.Now() | 274 | s.started = time.Now() | ||
| 264 | s.ctx = ctx | 275 | s.ctx = ctx | ||
| 265 | var wg sync.WaitGroup | 276 | var wg sync.WaitGroup | ||
| 266 | - | wg.Add(7) | 277 | + | wg.Add(9) |
| 267 | go func() { defer wg.Done(); s.forward(ctx) }() | 278 | go func() { defer wg.Done(); s.forward(ctx) }() | ||
| 268 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | 279 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | ||
| 269 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | 280 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | ||
| @@ -301,6 +312,8 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 301 | }() | 312 | }() | ||
| 302 | go func() { defer wg.Done(); s.pace(ctx) }() | 313 | go func() { defer wg.Done(); s.pace(ctx) }() | ||
| 303 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | 314 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | ||
| 315 | + | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | |||
| 316 | + | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | |||
| 304 | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | 317 | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | ||
| 305 | if s.syncWait != nil { | 318 | if s.syncWait != nil { | ||
| 306 | wg.Add(1) | 319 | wg.Add(1) | ||
| @@ -311,6 +324,54 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 311 | return err | 324 | return err | ||
| 312 | } | 325 | } | ||
| 313 | 326 | ||||
| 327 | + | // forwardOutputHeads delivers backend inventory on the display owner goroutine. | |||
| 328 | + | func (s *Server) forwardOutputHeads(ctx context.Context) { | |||
| 329 | + | for { | |||
| 330 | + | select { | |||
| 331 | + | case <-ctx.Done(): | |||
| 332 | + | return | |||
| 333 | + | case <-s.display.Stopped(): | |||
| 334 | + | return | |||
| 335 | + | case heads, ok := <-s.channels.OutputHeads: | |||
| 336 | + | if !ok { | |||
| 337 | + | return | |||
| 338 | + | } | |||
| 339 | + | if !s.display.Do(func() { s.setOutputHeads(heads) }) { | |||
| 340 | + | return | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func (s *Server) forwardOutputApplied(ctx context.Context) { | |||
| 347 | + | for { | |||
| 348 | + | select { | |||
| 349 | + | case <-ctx.Done(): | |||
| 350 | + | return | |||
| 351 | + | case <-s.display.Stopped(): | |||
| 352 | + | return | |||
| 353 | + | case result, ok := <-s.channels.OutputApplied: | |||
| 354 | + | if !ok { | |||
| 355 | + | return | |||
| 356 | + | } | |||
| 357 | + | if !s.display.Do(func() { | |||
| 358 | + | c := s.outputReplies[result.ID] | |||
| 359 | + | delete(s.outputReplies, result.ID) | |||
| 360 | + | if c == nil || !c.res.Alive() { | |||
| 361 | + | return | |||
| 362 | + | } | |||
| 363 | + | if result.Err != nil { | |||
| 364 | + | c.res.SendFailed() | |||
| 365 | + | } else { | |||
| 366 | + | c.res.SendSucceeded() | |||
| 367 | + | } | |||
| 368 | + | }) { | |||
| 369 | + | return | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | ||||
| 314 | // damageHistory is how many contents a window's damage history covers: | 375 | // damageHistory is how many contents a window's damage history covers: | ||
| 315 | // enough for a renderer target a few frames old. | 376 | // enough for a renderer target a few frames old. | ||
| 316 | const damageHistory = 4 | 377 | const damageHistory = 4 | ||
M internal/app/app.go +18 -6
| @@ -91,6 +91,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 91 | 91 | captures := make(chan ports.CaptureRequest, 32) | |
| 92 | 92 | captured := make(chan ports.CaptureDone, 64) | |
| 93 | 93 | outputFormats := make(chan ports.OutputFormats, 8) | |
| 94 | + | outputHeads := make(chan ports.OutputHeads, 8) | |
| 95 | + | inventory := make(chan ports.OutputHeads, 8) | |
| 96 | + | applyOutput := make(chan ports.OutputApply, 8) | |
| 97 | + | appliedOutput := make(chan ports.OutputApplied, 8) | |
| 98 | + | backendConfig := make(chan ports.Config, 8) | |
| 99 | + | backendApplied := make(chan error, 8) | |
| 94 | 100 | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | |
| 95 | 101 | c, err := core.New(opts.Config, ch) | |
| 96 | 102 | if err != nil { | |
| @@ -105,7 +111,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 105 | 111 | // Every output renders on the same GPU: its formats are the clients'. | |
| 106 | 112 | dmabuf := vulkan.Probe() | |
| 107 | 113 | log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf") | |
| 108 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats}, logging.For(ctx, "wayland")) | |
| 114 | + | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland")) | |
| 109 | 115 | if err != nil { | |
| 110 | 116 | km.Close() | |
| 111 | 117 | return err | |
| @@ -144,10 +150,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 144 | 150 | defer workers.Done() | |
| 145 | 151 | done <- config.Watch(ctx, path, watched, logging.For(ctx, "config")) | |
| 146 | 152 | }() | |
| 153 | + | filtered := make(chan ports.ConfigChanged, 8) | |
| 147 | 154 | workers.Add(1) | |
| 148 | 155 | go func() { | |
| 149 | 156 | defer workers.Done() | |
| 150 | - | relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config")) | |
| 157 | + | relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config")) | |
| 158 | + | }() | |
| 159 | + | workers.Add(1) | |
| 160 | + | go func() { | |
| 161 | + | defer workers.Done() | |
| 162 | + | relayOutputSettings(ctx, newOutputOverrides(opts.Config, hw == nil), filtered, inventory, applyOutput, configChanges, backendConfig, backendApplied, appliedOutput, logging.For(ctx, "app")) | |
| 151 | 163 | }() | |
| 152 | 164 | script := make(chan string) | |
| 153 | 165 | curs := newCursors() | |
| @@ -219,16 +231,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 219 | 231 | } | |
| 220 | 232 | if hw != nil { | |
| 221 | 233 | done <- safe("output", func() error { | |
| 222 | - | want := func() drm.Want { | |
| 223 | - | w := wantFromConfig(opts.Config) | |
| 234 | + | want := func(cfg ports.Config) drm.Want { | |
| 235 | + | w := wantFromConfig(cfg) | |
| 224 | 236 | w.Sampled = dmabuf.Formats | |
| 225 | 237 | return w | |
| 226 | 238 | } | |
| 227 | - | return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, curs, newRenderer, logging.For(ctx, "drm")) | |
| 239 | + | return hw.runOutputs(ctx, want, opts.Config, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, outputHeads, inventory, backendConfig, backendApplied, curs, newRenderer, logging.For(ctx, "drm")) | |
| 228 | 240 | }) | |
| 229 | 241 | return | |
| 230 | 242 | } | |
| 231 | - | done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, curs, newRenderer, logging.For(ctx, "render")) | |
| 243 | + | done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render")) | |
| 232 | 244 | }() | |
| 233 | 245 | ||
| 234 | 246 | if hw != nil { | |
| @@ -91,6 +91,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 91 | captures := make(chan ports.CaptureRequest, 32) | 91 | captures := make(chan ports.CaptureRequest, 32) | ||
| 92 | captured := make(chan ports.CaptureDone, 64) | 92 | captured := make(chan ports.CaptureDone, 64) | ||
| 93 | outputFormats := make(chan ports.OutputFormats, 8) | 93 | outputFormats := make(chan ports.OutputFormats, 8) | ||
| 94 | + | outputHeads := make(chan ports.OutputHeads, 8) | |||
| 95 | + | inventory := make(chan ports.OutputHeads, 8) | |||
| 96 | + | applyOutput := make(chan ports.OutputApply, 8) | |||
| 97 | + | appliedOutput := make(chan ports.OutputApplied, 8) | |||
| 98 | + | backendConfig := make(chan ports.Config, 8) | |||
| 99 | + | backendApplied := make(chan error, 8) | |||
| 94 | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | 100 | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | ||
| 95 | c, err := core.New(opts.Config, ch) | 101 | c, err := core.New(opts.Config, ch) | ||
| 96 | if err != nil { | 102 | if err != nil { | ||
| @@ -105,7 +111,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 105 | // Every output renders on the same GPU: its formats are the clients'. | 111 | // Every output renders on the same GPU: its formats are the clients'. | ||
| 106 | dmabuf := vulkan.Probe() | 112 | dmabuf := vulkan.Probe() | ||
| 107 | log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf") | 113 | log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf") | ||
| 108 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats}, logging.For(ctx, "wayland")) | 114 | + | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland")) |
| 109 | if err != nil { | 115 | if err != nil { | ||
| 110 | km.Close() | 116 | km.Close() | ||
| 111 | return err | 117 | return err | ||
| @@ -144,10 +150,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 144 | defer workers.Done() | 150 | defer workers.Done() | ||
| 145 | done <- config.Watch(ctx, path, watched, logging.For(ctx, "config")) | 151 | done <- config.Watch(ctx, path, watched, logging.For(ctx, "config")) | ||
| 146 | }() | 152 | }() | ||
| 153 | + | filtered := make(chan ports.ConfigChanged, 8) | |||
| 147 | workers.Add(1) | 154 | workers.Add(1) | ||
| 148 | go func() { | 155 | go func() { | ||
| 149 | defer workers.Done() | 156 | defer workers.Done() | ||
| 150 | - | relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config")) | 157 | + | relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config")) |
| 158 | + | }() | |||
| 159 | + | workers.Add(1) | |||
| 160 | + | go func() { | |||
| 161 | + | defer workers.Done() | |||
| 162 | + | relayOutputSettings(ctx, newOutputOverrides(opts.Config, hw == nil), filtered, inventory, applyOutput, configChanges, backendConfig, backendApplied, appliedOutput, logging.For(ctx, "app")) | |||
| 151 | }() | 163 | }() | ||
| 152 | script := make(chan string) | 164 | script := make(chan string) | ||
| 153 | curs := newCursors() | 165 | curs := newCursors() | ||
| @@ -219,16 +231,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 219 | } | 231 | } | ||
| 220 | if hw != nil { | 232 | if hw != nil { | ||
| 221 | done <- safe("output", func() error { | 233 | done <- safe("output", func() error { | ||
| 222 | - | want := func() drm.Want { | 234 | + | want := func(cfg ports.Config) drm.Want { |
| 223 | - | w := wantFromConfig(opts.Config) | 235 | + | w := wantFromConfig(cfg) |
| 224 | w.Sampled = dmabuf.Formats | 236 | w.Sampled = dmabuf.Formats | ||
| 225 | return w | 237 | return w | ||
| 226 | } | 238 | } | ||
| 227 | - | return hw.runOutputs(ctx, want, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, curs, newRenderer, logging.For(ctx, "drm")) | 239 | + | return hw.runOutputs(ctx, want, opts.Config, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputFormats, outputHeads, inventory, backendConfig, backendApplied, curs, newRenderer, logging.For(ctx, "drm")) |
| 228 | }) | 240 | }) | ||
| 229 | return | 241 | return | ||
| 230 | } | 242 | } | ||
| 231 | - | done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, curs, newRenderer, logging.For(ctx, "render")) | 243 | + | done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, cursorChanges, presented, captures, captured, outputHeads, inventory, curs, newRenderer, logging.For(ctx, "render")) |
| 232 | }() | 244 | }() | ||
| 233 | 245 | ||||
| 234 | if hw != nil { | 246 | if hw != nil { | ||
M internal/app/drm.go +171 -5
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "runtime/debug" | |
| 9 | 9 | "sort" | |
| 10 | 10 | "sync" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 13 | 14 | "github.com/bnema/neferwl/internal/adapters/drm" | |
| @@ -30,6 +31,26 @@ type drmCard struct { | |||
| 30 | 31 | fd int | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 34 | + | // A failed card read must not erase its heads from a multi-card inventory. | |
| 35 | + | func retainedHeads(previous, found []ports.OutputHead, err error) []ports.OutputHead { | |
| 36 | + | if err != nil { | |
| 37 | + | return previous | |
| 38 | + | } | |
| 39 | + | return found | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // A stopped instance is no longer enabled, even if the inventory read fails. | |
| 43 | + | func releasedHead(heads []ports.OutputHead, name string) []ports.OutputHead { | |
| 44 | + | heads = append([]ports.OutputHead(nil), heads...) | |
| 45 | + | for i := range heads { | |
| 46 | + | if heads[i].Info.Name == name { | |
| 47 | + | heads[i].Enabled = false | |
| 48 | + | heads[i].Current = nil | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | return heads | |
| 52 | + | } | |
| 53 | + | ||
| 33 | 54 | // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored. | |
| 34 | 55 | func safe(name string, fn func() error) (err error) { | |
| 35 | 56 | defer func() { | |
| @@ -114,7 +135,7 @@ func (b *drmBackend) close() { | |||
| 114 | 135 | // one flip reader per card, and a udev watcher that rescans connectors on | |
| 115 | 136 | // hotplug. Core learns about outputs through events. It returns when ctx | |
| 116 | 137 | // ends, after every output is closed. | |
| 117 | - | func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 138 | + | func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, configs <-chan ports.Config, applied chan<- error, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 118 | 139 | ctx, cancel := context.WithCancel(ctx) | |
| 119 | 140 | defer cancel() | |
| 120 | 141 | var readers sync.WaitGroup | |
| @@ -145,20 +166,71 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 145 | 166 | // stopping holds outputs asked to stop: true when they restart with a | |
| 146 | 167 | // new mode (core keeps the screen), false when they are gone. | |
| 147 | 168 | stopping := map[string]bool{} | |
| 169 | + | currentConfig := initial | |
| 170 | + | var currentHeads ports.OutputHeads | |
| 171 | + | lastHeads := map[*drmCard][]ports.OutputHead{} | |
| 172 | + | progress := &applyProgress{completed: map[<-chan error]bool{}} | |
| 173 | + | readySources := map[string]<-chan error{} | |
| 174 | + | stopReady := map[string]chan struct{}{} | |
| 175 | + | type readyResult struct { | |
| 176 | + | name string | |
| 177 | + | source <-chan error | |
| 178 | + | err error | |
| 179 | + | } | |
| 180 | + | ready := make(chan readyResult) | |
| 181 | + | deadline := make(chan uint64, 1) | |
| 182 | + | var timer *time.Timer | |
| 183 | + | var op uint64 | |
| 184 | + | complete := func(d applyDecision) { | |
| 185 | + | if !d.reply { | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | if timer != nil { | |
| 189 | + | timer.Stop() | |
| 190 | + | timer = nil | |
| 191 | + | } | |
| 192 | + | select { | |
| 193 | + | case applied <- d.err: | |
| 194 | + | case <-ctx.Done(): | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | publishInventory := func() { | |
| 198 | + | var inventory ports.OutputHeads | |
| 199 | + | for _, card := range b.cards { | |
| 200 | + | found, err := card.ConnectedHeads() | |
| 201 | + | if err != nil { | |
| 202 | + | log.Warn().Err(err).Str("card", card.Path()).Msg("inventory outputs") | |
| 203 | + | } | |
| 204 | + | lastHeads[card] = retainedHeads(lastHeads[card], found, err) | |
| 205 | + | inventory.Heads = append(inventory.Heads, lastHeads[card]...) | |
| 206 | + | } | |
| 207 | + | currentHeads = inventory | |
| 208 | + | select { | |
| 209 | + | case heads <- inventory: | |
| 210 | + | case <-ctx.Done(): | |
| 211 | + | } | |
| 212 | + | select { | |
| 213 | + | case report <- inventory: | |
| 214 | + | case <-ctx.Done(): | |
| 215 | + | } | |
| 216 | + | } | |
| 148 | 217 | scan := func() { | |
| 218 | + | var scanErrors []error | |
| 149 | 219 | for _, c := range b.cards { | |
| 150 | - | w := want() | |
| 220 | + | w := want(currentConfig) | |
| 151 | 221 | w.Device = c.Device() | |
| 152 | 222 | c.SetWant(w) | |
| 153 | 223 | added, removed, replaced, err := c.Scan() | |
| 154 | 224 | if err != nil { | |
| 155 | - | log.Warn().Err(err).Msg("scan connectors") | |
| 225 | + | log.Warn().Err(err).Str("card", c.Path()).Msg("scan connectors") | |
| 226 | + | scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err)) | |
| 156 | 227 | continue | |
| 157 | 228 | } | |
| 158 | 229 | stop := func(name string, restart bool) { | |
| 159 | 230 | if r := set.outs[name]; r != nil && cards[name] == c { | |
| 160 | 231 | if _, ok := stopping[name]; !ok { | |
| 161 | 232 | stopping[name] = restart | |
| 233 | + | progress.stopping(name) | |
| 162 | 234 | r.stop() | |
| 163 | 235 | } | |
| 164 | 236 | } | |
| @@ -181,6 +253,25 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 181 | 253 | continue | |
| 182 | 254 | } | |
| 183 | 255 | cards[name] = c | |
| 256 | + | readySources[name] = o.Ready() | |
| 257 | + | if progress.active && !want(currentConfig).Disabled[name] { | |
| 258 | + | progress.required[name] = true | |
| 259 | + | } | |
| 260 | + | source := readySources[name] | |
| 261 | + | stopped := make(chan struct{}) | |
| 262 | + | stopReady[name] = stopped | |
| 263 | + | go func() { | |
| 264 | + | select { | |
| 265 | + | case err := <-source: | |
| 266 | + | select { | |
| 267 | + | case ready <- readyResult{name, source, err}: | |
| 268 | + | case <-ctx.Done(): | |
| 269 | + | } | |
| 270 | + | case <-stopped: | |
| 271 | + | case <-ctx.Done(): | |
| 272 | + | } | |
| 273 | + | }() | |
| 274 | + | progress.started(name, source) | |
| 184 | 275 | if cur := o.Cursor(); cur != nil { | |
| 185 | 276 | curs.set(name, cur) | |
| 186 | 277 | } | |
| @@ -195,6 +286,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 195 | 286 | send(ports.OutputAdded{Info: o.Info()}) | |
| 196 | 287 | } | |
| 197 | 288 | } | |
| 289 | + | publishInventory() | |
| 290 | + | complete(progress.scanError(errors.Join(scanErrors...))) | |
| 198 | 291 | } | |
| 199 | 292 | scan() | |
| 200 | 293 | if len(set.outs) == 0 { | |
| @@ -210,6 +303,53 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 210 | 303 | case <-hotplug: | |
| 211 | 304 | log.Info().Msg("hotplug") | |
| 212 | 305 | scan() | |
| 306 | + | if progress.active { | |
| 307 | + | running := map[string]<-chan error{} | |
| 308 | + | for name := range set.outs { | |
| 309 | + | running[name] = readySources[name] | |
| 310 | + | } | |
| 311 | + | complete(progress.scanned(running)) | |
| 312 | + | } | |
| 313 | + | case currentConfig = <-configs: | |
| 314 | + | op++ | |
| 315 | + | if timer != nil { | |
| 316 | + | timer.Stop() | |
| 317 | + | timer = nil | |
| 318 | + | } | |
| 319 | + | required := map[string]bool{} | |
| 320 | + | // Desired enabled heads, not the inventory's current mode, require a first modeset. | |
| 321 | + | w := want(currentConfig) | |
| 322 | + | for _, h := range currentHeads.Heads { | |
| 323 | + | if !w.Disabled[h.Info.Name] { | |
| 324 | + | required[h.Info.Name] = true | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | for name := range set.outs { | |
| 328 | + | if !w.Disabled[name] { | |
| 329 | + | required[name] = true | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | progress.start(op, required, stopping) | |
| 333 | + | scan() | |
| 334 | + | running := map[string]<-chan error{} | |
| 335 | + | for name := range set.outs { | |
| 336 | + | running[name] = readySources[name] | |
| 337 | + | } | |
| 338 | + | d := progress.scanned(running) | |
| 339 | + | if progress.active { | |
| 340 | + | id := op | |
| 341 | + | timer = time.AfterFunc(5*time.Second, func() { | |
| 342 | + | select { | |
| 343 | + | case deadline <- id: | |
| 344 | + | case <-ctx.Done(): | |
| 345 | + | } | |
| 346 | + | }) | |
| 347 | + | } | |
| 348 | + | complete(d) | |
| 349 | + | case result := <-ready: | |
| 350 | + | complete(progress.readyEvent(result.name, result.source, result.err)) | |
| 351 | + | case id := <-deadline: | |
| 352 | + | complete(progress.timeout(id)) | |
| 213 | 353 | case s := <-scenes: | |
| 214 | 354 | set.scenes(s) | |
| 215 | 355 | case c := <-contents: | |
| @@ -226,8 +366,17 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 226 | 366 | restart := stopping[name] | |
| 227 | 367 | delete(stopping, name) | |
| 228 | 368 | curs.set(name, nil) | |
| 229 | - | cards[name].Release(name) | |
| 369 | + | card := cards[name] | |
| 370 | + | card.Release(name) | |
| 371 | + | lastHeads[card] = releasedHead(lastHeads[card], name) | |
| 230 | 372 | delete(cards, name) | |
| 373 | + | source := readySources[name] | |
| 374 | + | delete(readySources, name) | |
| 375 | + | if stopped := stopReady[name]; stopped != nil { | |
| 376 | + | close(stopped) | |
| 377 | + | delete(stopReady, name) | |
| 378 | + | } | |
| 379 | + | stopDecision := progress.stopped(name, source, restart, err) | |
| 231 | 380 | if err != nil { | |
| 232 | 381 | // A broken output (e.g. its renderer) must not take the | |
| 233 | 382 | // session down. It stays off until the next hotplug, so a | |
| @@ -235,15 +384,32 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||
| 235 | 384 | log.Error().Err(err).Str("connector", name).Msg("output stopped") | |
| 236 | 385 | restart = false | |
| 237 | 386 | } | |
| 387 | + | // Release changes the enabled state in ConnectedHeads. Do not | |
| 388 | + | // restart a failed or disabled output just to refresh inventory. | |
| 389 | + | if restart { | |
| 390 | + | scan() | |
| 391 | + | } else { | |
| 392 | + | publishInventory() | |
| 393 | + | } | |
| 394 | + | complete(stopDecision) | |
| 238 | 395 | if restart { | |
| 239 | 396 | // Same connector, new mode: core keeps its screen and | |
| 240 | 397 | // workspaces; OutputAdded updates the size. | |
| 241 | - | scan() | |
| 242 | 398 | if set.outs[name] != nil { | |
| 399 | + | running := map[string]<-chan error{} | |
| 400 | + | for n := range set.outs { | |
| 401 | + | running[n] = readySources[n] | |
| 402 | + | } | |
| 403 | + | complete(progress.scanned(running)) | |
| 243 | 404 | continue | |
| 244 | 405 | } | |
| 245 | 406 | } | |
| 246 | 407 | send(ports.OutputRemoved{Name: name}) | |
| 408 | + | running := map[string]<-chan error{} | |
| 409 | + | for n := range set.outs { | |
| 410 | + | running[n] = readySources[n] | |
| 411 | + | } | |
| 412 | + | complete(progress.scanned(running)) | |
| 247 | 413 | } | |
| 248 | 414 | } | |
| 249 | 415 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "runtime/debug" | 8 | "runtime/debug" | ||
| 9 | "sort" | 9 | "sort" | ||
| 10 | "sync" | 10 | "sync" | ||
| 11 | + | "time" | |||
| 11 | 12 | ||||
| 12 | "github.com/bnema/neferwl/internal/adapters/config" | 13 | "github.com/bnema/neferwl/internal/adapters/config" | ||
| 13 | "github.com/bnema/neferwl/internal/adapters/drm" | 14 | "github.com/bnema/neferwl/internal/adapters/drm" | ||
| @@ -30,6 +31,26 @@ type drmCard struct { | |||||
| 30 | fd int | 31 | fd int | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 34 | + | // A failed card read must not erase its heads from a multi-card inventory. | |||
| 35 | + | func retainedHeads(previous, found []ports.OutputHead, err error) []ports.OutputHead { | |||
| 36 | + | if err != nil { | |||
| 37 | + | return previous | |||
| 38 | + | } | |||
| 39 | + | return found | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // A stopped instance is no longer enabled, even if the inventory read fails. | |||
| 43 | + | func releasedHead(heads []ports.OutputHead, name string) []ports.OutputHead { | |||
| 44 | + | heads = append([]ports.OutputHead(nil), heads...) | |||
| 45 | + | for i := range heads { | |||
| 46 | + | if heads[i].Info.Name == name { | |||
| 47 | + | heads[i].Enabled = false | |||
| 48 | + | heads[i].Current = nil | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | return heads | |||
| 52 | + | } | |||
| 53 | + | ||||
| 33 | // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored. | 54 | // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored. | ||
| 34 | func safe(name string, fn func() error) (err error) { | 55 | func safe(name string, fn func() error) (err error) { | ||
| 35 | defer func() { | 56 | defer func() { | ||
| @@ -114,7 +135,7 @@ func (b *drmBackend) close() { | |||||
| 114 | // one flip reader per card, and a udev watcher that rescans connectors on | 135 | // one flip reader per card, and a udev watcher that rescans connectors on | ||
| 115 | // hotplug. Core learns about outputs through events. It returns when ctx | 136 | // hotplug. Core learns about outputs through events. It returns when ctx | ||
| 116 | // ends, after every output is closed. | 137 | // ends, after every output is closed. | ||
| 117 | - | func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | 138 | + | func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm.Want, initial ports.Config, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, formats chan<- ports.OutputFormats, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, configs <-chan ports.Config, applied chan<- error, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { |
| 118 | ctx, cancel := context.WithCancel(ctx) | 139 | ctx, cancel := context.WithCancel(ctx) | ||
| 119 | defer cancel() | 140 | defer cancel() | ||
| 120 | var readers sync.WaitGroup | 141 | var readers sync.WaitGroup | ||
| @@ -145,20 +166,71 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||||
| 145 | // stopping holds outputs asked to stop: true when they restart with a | 166 | // stopping holds outputs asked to stop: true when they restart with a | ||
| 146 | // new mode (core keeps the screen), false when they are gone. | 167 | // new mode (core keeps the screen), false when they are gone. | ||
| 147 | stopping := map[string]bool{} | 168 | stopping := map[string]bool{} | ||
| 169 | + | currentConfig := initial | |||
| 170 | + | var currentHeads ports.OutputHeads | |||
| 171 | + | lastHeads := map[*drmCard][]ports.OutputHead{} | |||
| 172 | + | progress := &applyProgress{completed: map[<-chan error]bool{}} | |||
| 173 | + | readySources := map[string]<-chan error{} | |||
| 174 | + | stopReady := map[string]chan struct{}{} | |||
| 175 | + | type readyResult struct { | |||
| 176 | + | name string | |||
| 177 | + | source <-chan error | |||
| 178 | + | err error | |||
| 179 | + | } | |||
| 180 | + | ready := make(chan readyResult) | |||
| 181 | + | deadline := make(chan uint64, 1) | |||
| 182 | + | var timer *time.Timer | |||
| 183 | + | var op uint64 | |||
| 184 | + | complete := func(d applyDecision) { | |||
| 185 | + | if !d.reply { | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | if timer != nil { | |||
| 189 | + | timer.Stop() | |||
| 190 | + | timer = nil | |||
| 191 | + | } | |||
| 192 | + | select { | |||
| 193 | + | case applied <- d.err: | |||
| 194 | + | case <-ctx.Done(): | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | publishInventory := func() { | |||
| 198 | + | var inventory ports.OutputHeads | |||
| 199 | + | for _, card := range b.cards { | |||
| 200 | + | found, err := card.ConnectedHeads() | |||
| 201 | + | if err != nil { | |||
| 202 | + | log.Warn().Err(err).Str("card", card.Path()).Msg("inventory outputs") | |||
| 203 | + | } | |||
| 204 | + | lastHeads[card] = retainedHeads(lastHeads[card], found, err) | |||
| 205 | + | inventory.Heads = append(inventory.Heads, lastHeads[card]...) | |||
| 206 | + | } | |||
| 207 | + | currentHeads = inventory | |||
| 208 | + | select { | |||
| 209 | + | case heads <- inventory: | |||
| 210 | + | case <-ctx.Done(): | |||
| 211 | + | } | |||
| 212 | + | select { | |||
| 213 | + | case report <- inventory: | |||
| 214 | + | case <-ctx.Done(): | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 148 | scan := func() { | 217 | scan := func() { | ||
| 218 | + | var scanErrors []error | |||
| 149 | for _, c := range b.cards { | 219 | for _, c := range b.cards { | ||
| 150 | - | w := want() | 220 | + | w := want(currentConfig) |
| 151 | w.Device = c.Device() | 221 | w.Device = c.Device() | ||
| 152 | c.SetWant(w) | 222 | c.SetWant(w) | ||
| 153 | added, removed, replaced, err := c.Scan() | 223 | added, removed, replaced, err := c.Scan() | ||
| 154 | if err != nil { | 224 | if err != nil { | ||
| 155 | - | log.Warn().Err(err).Msg("scan connectors") | 225 | + | log.Warn().Err(err).Str("card", c.Path()).Msg("scan connectors") |
| 226 | + | scanErrors = append(scanErrors, fmt.Errorf("scan %s: %w", c.Path(), err)) | |||
| 156 | continue | 227 | continue | ||
| 157 | } | 228 | } | ||
| 158 | stop := func(name string, restart bool) { | 229 | stop := func(name string, restart bool) { | ||
| 159 | if r := set.outs[name]; r != nil && cards[name] == c { | 230 | if r := set.outs[name]; r != nil && cards[name] == c { | ||
| 160 | if _, ok := stopping[name]; !ok { | 231 | if _, ok := stopping[name]; !ok { | ||
| 161 | stopping[name] = restart | 232 | stopping[name] = restart | ||
| 233 | + | progress.stopping(name) | |||
| 162 | r.stop() | 234 | r.stop() | ||
| 163 | } | 235 | } | ||
| 164 | } | 236 | } | ||
| @@ -181,6 +253,25 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||||
| 181 | continue | 253 | continue | ||
| 182 | } | 254 | } | ||
| 183 | cards[name] = c | 255 | cards[name] = c | ||
| 256 | + | readySources[name] = o.Ready() | |||
| 257 | + | if progress.active && !want(currentConfig).Disabled[name] { | |||
| 258 | + | progress.required[name] = true | |||
| 259 | + | } | |||
| 260 | + | source := readySources[name] | |||
| 261 | + | stopped := make(chan struct{}) | |||
| 262 | + | stopReady[name] = stopped | |||
| 263 | + | go func() { | |||
| 264 | + | select { | |||
| 265 | + | case err := <-source: | |||
| 266 | + | select { | |||
| 267 | + | case ready <- readyResult{name, source, err}: | |||
| 268 | + | case <-ctx.Done(): | |||
| 269 | + | } | |||
| 270 | + | case <-stopped: | |||
| 271 | + | case <-ctx.Done(): | |||
| 272 | + | } | |||
| 273 | + | }() | |||
| 274 | + | progress.started(name, source) | |||
| 184 | if cur := o.Cursor(); cur != nil { | 275 | if cur := o.Cursor(); cur != nil { | ||
| 185 | curs.set(name, cur) | 276 | curs.set(name, cur) | ||
| 186 | } | 277 | } | ||
| @@ -195,6 +286,8 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||||
| 195 | send(ports.OutputAdded{Info: o.Info()}) | 286 | send(ports.OutputAdded{Info: o.Info()}) | ||
| 196 | } | 287 | } | ||
| 197 | } | 288 | } | ||
| 289 | + | publishInventory() | |||
| 290 | + | complete(progress.scanError(errors.Join(scanErrors...))) | |||
| 198 | } | 291 | } | ||
| 199 | scan() | 292 | scan() | ||
| 200 | if len(set.outs) == 0 { | 293 | if len(set.outs) == 0 { | ||
| @@ -210,6 +303,53 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||||
| 210 | case <-hotplug: | 303 | case <-hotplug: | ||
| 211 | log.Info().Msg("hotplug") | 304 | log.Info().Msg("hotplug") | ||
| 212 | scan() | 305 | scan() | ||
| 306 | + | if progress.active { | |||
| 307 | + | running := map[string]<-chan error{} | |||
| 308 | + | for name := range set.outs { | |||
| 309 | + | running[name] = readySources[name] | |||
| 310 | + | } | |||
| 311 | + | complete(progress.scanned(running)) | |||
| 312 | + | } | |||
| 313 | + | case currentConfig = <-configs: | |||
| 314 | + | op++ | |||
| 315 | + | if timer != nil { | |||
| 316 | + | timer.Stop() | |||
| 317 | + | timer = nil | |||
| 318 | + | } | |||
| 319 | + | required := map[string]bool{} | |||
| 320 | + | // Desired enabled heads, not the inventory's current mode, require a first modeset. | |||
| 321 | + | w := want(currentConfig) | |||
| 322 | + | for _, h := range currentHeads.Heads { | |||
| 323 | + | if !w.Disabled[h.Info.Name] { | |||
| 324 | + | required[h.Info.Name] = true | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | for name := range set.outs { | |||
| 328 | + | if !w.Disabled[name] { | |||
| 329 | + | required[name] = true | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | progress.start(op, required, stopping) | |||
| 333 | + | scan() | |||
| 334 | + | running := map[string]<-chan error{} | |||
| 335 | + | for name := range set.outs { | |||
| 336 | + | running[name] = readySources[name] | |||
| 337 | + | } | |||
| 338 | + | d := progress.scanned(running) | |||
| 339 | + | if progress.active { | |||
| 340 | + | id := op | |||
| 341 | + | timer = time.AfterFunc(5*time.Second, func() { | |||
| 342 | + | select { | |||
| 343 | + | case deadline <- id: | |||
| 344 | + | case <-ctx.Done(): | |||
| 345 | + | } | |||
| 346 | + | }) | |||
| 347 | + | } | |||
| 348 | + | complete(d) | |||
| 349 | + | case result := <-ready: | |||
| 350 | + | complete(progress.readyEvent(result.name, result.source, result.err)) | |||
| 351 | + | case id := <-deadline: | |||
| 352 | + | complete(progress.timeout(id)) | |||
| 213 | case s := <-scenes: | 353 | case s := <-scenes: | ||
| 214 | set.scenes(s) | 354 | set.scenes(s) | ||
| 215 | case c := <-contents: | 355 | case c := <-contents: | ||
| @@ -226,8 +366,17 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||||
| 226 | restart := stopping[name] | 366 | restart := stopping[name] | ||
| 227 | delete(stopping, name) | 367 | delete(stopping, name) | ||
| 228 | curs.set(name, nil) | 368 | curs.set(name, nil) | ||
| 229 | - | cards[name].Release(name) | 369 | + | card := cards[name] |
| 370 | + | card.Release(name) | |||
| 371 | + | lastHeads[card] = releasedHead(lastHeads[card], name) | |||
| 230 | delete(cards, name) | 372 | delete(cards, name) | ||
| 373 | + | source := readySources[name] | |||
| 374 | + | delete(readySources, name) | |||
| 375 | + | if stopped := stopReady[name]; stopped != nil { | |||
| 376 | + | close(stopped) | |||
| 377 | + | delete(stopReady, name) | |||
| 378 | + | } | |||
| 379 | + | stopDecision := progress.stopped(name, source, restart, err) | |||
| 231 | if err != nil { | 380 | if err != nil { | ||
| 232 | // A broken output (e.g. its renderer) must not take the | 381 | // A broken output (e.g. its renderer) must not take the | ||
| 233 | // session down. It stays off until the next hotplug, so a | 382 | // session down. It stays off until the next hotplug, so a | ||
| @@ -235,15 +384,32 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, event | |||||
| 235 | log.Error().Err(err).Str("connector", name).Msg("output stopped") | 384 | log.Error().Err(err).Str("connector", name).Msg("output stopped") | ||
| 236 | restart = false | 385 | restart = false | ||
| 237 | } | 386 | } | ||
| 387 | + | // Release changes the enabled state in ConnectedHeads. Do not | |||
| 388 | + | // restart a failed or disabled output just to refresh inventory. | |||
| 389 | + | if restart { | |||
| 390 | + | scan() | |||
| 391 | + | } else { | |||
| 392 | + | publishInventory() | |||
| 393 | + | } | |||
| 394 | + | complete(stopDecision) | |||
| 238 | if restart { | 395 | if restart { | ||
| 239 | // Same connector, new mode: core keeps its screen and | 396 | // Same connector, new mode: core keeps its screen and | ||
| 240 | // workspaces; OutputAdded updates the size. | 397 | // workspaces; OutputAdded updates the size. | ||
| 241 | - | scan() | |||
| 242 | if set.outs[name] != nil { | 398 | if set.outs[name] != nil { | ||
| 399 | + | running := map[string]<-chan error{} | |||
| 400 | + | for n := range set.outs { | |||
| 401 | + | running[n] = readySources[n] | |||
| 402 | + | } | |||
| 403 | + | complete(progress.scanned(running)) | |||
| 243 | continue | 404 | continue | ||
| 244 | } | 405 | } | ||
| 245 | } | 406 | } | ||
| 246 | send(ports.OutputRemoved{Name: name}) | 407 | send(ports.OutputRemoved{Name: name}) | ||
| 408 | + | running := map[string]<-chan error{} | |||
| 409 | + | for n := range set.outs { | |||
| 410 | + | running[n] = readySources[n] | |||
| 411 | + | } | |||
| 412 | + | complete(progress.scanned(running)) | |||
| 247 | } | 413 | } | ||
| 248 | } | 414 | } | ||
| 249 | } | 415 | } | ||
A internal/app/drm_inventory_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRetainedHeadsOnCardReadError(t *testing.T) { | |
| 11 | + | first := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true}} | |
| 12 | + | other := []ports.OutputHead{{Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true}} | |
| 13 | + | cached := retainedHeads(nil, first, nil) | |
| 14 | + | cached = retainedHeads(cached, nil, errors.New("resources unavailable")) | |
| 15 | + | combined := append(append([]ports.OutputHead{}, cached...), other...) | |
| 16 | + | if len(combined) != 2 || combined[0].Info.Name != "DP-1" || combined[1].Info.Name != "HDMI-1" { | |
| 17 | + | t.Fatalf("failed card lost its inventory: %+v", combined) | |
| 18 | + | } | |
| 19 | + | if got := retainedHeads(cached, nil, nil); len(got) != 0 { | |
| 20 | + | t.Fatalf("successful empty inventory should clear cache: %+v", got) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestReleasedHeadOnInventoryError(t *testing.T) { | |
| 25 | + | mode := ports.OutputMode{Width: 1920, Height: 1080} | |
| 26 | + | previous := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}, {Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true, Current: &mode}} | |
| 27 | + | // Both a disabled stop and an unsuccessful restart release the old instance. | |
| 28 | + | for _, restart := range []bool{false, true} { | |
| 29 | + | t.Run(map[bool]string{false: "disabled", true: "restart"}[restart], func(t *testing.T) { | |
| 30 | + | heads := releasedHead(append([]ports.OutputHead(nil), previous...), "DP-1") | |
| 31 | + | heads = retainedHeads(heads, nil, errors.New("inventory unavailable")) | |
| 32 | + | if heads[0].Enabled || heads[0].Current != nil || !heads[1].Enabled { | |
| 33 | + | t.Fatalf("released connector inventory: %+v", heads) | |
| 34 | + | } | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRetainedHeadsOnCardReadError(t *testing.T) { | |||
| 11 | + | first := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true}} | |||
| 12 | + | other := []ports.OutputHead{{Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true}} | |||
| 13 | + | cached := retainedHeads(nil, first, nil) | |||
| 14 | + | cached = retainedHeads(cached, nil, errors.New("resources unavailable")) | |||
| 15 | + | combined := append(append([]ports.OutputHead{}, cached...), other...) | |||
| 16 | + | if len(combined) != 2 || combined[0].Info.Name != "DP-1" || combined[1].Info.Name != "HDMI-1" { | |||
| 17 | + | t.Fatalf("failed card lost its inventory: %+v", combined) | |||
| 18 | + | } | |||
| 19 | + | if got := retainedHeads(cached, nil, nil); len(got) != 0 { | |||
| 20 | + | t.Fatalf("successful empty inventory should clear cache: %+v", got) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestReleasedHeadOnInventoryError(t *testing.T) { | |||
| 25 | + | mode := ports.OutputMode{Width: 1920, Height: 1080} | |||
| 26 | + | previous := []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}, {Info: ports.OutputInfo{Name: "HDMI-1"}, Enabled: true, Current: &mode}} | |||
| 27 | + | // Both a disabled stop and an unsuccessful restart release the old instance. | |||
| 28 | + | for _, restart := range []bool{false, true} { | |||
| 29 | + | t.Run(map[bool]string{false: "disabled", true: "restart"}[restart], func(t *testing.T) { | |||
| 30 | + | heads := releasedHead(append([]ports.OutputHead(nil), previous...), "DP-1") | |||
| 31 | + | heads = retainedHeads(heads, nil, errors.New("inventory unavailable")) | |||
| 32 | + | if heads[0].Enabled || heads[0].Current != nil || !heads[1].Enabled { | |||
| 33 | + | t.Fatalf("released connector inventory: %+v", heads) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M internal/app/headless.go +14 -1
| @@ -13,10 +13,13 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | |
| 15 | 15 | // With several outputs, screenshots go to a subdirectory per output. | |
| 16 | - | func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 16 | + | func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | |
| 17 | 17 | set := newOutputSet(ctx, captured) | |
| 18 | + | inventory := ports.OutputHeads{} | |
| 18 | 19 | for i, size := range sizes { | |
| 19 | 20 | name := fmt.Sprintf("HEADLESS-%d", i+1) | |
| 21 | + | mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true} | |
| 22 | + | inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}) | |
| 20 | 23 | dir := shots | |
| 21 | 24 | if dir != "" && len(sizes) > 1 { | |
| 22 | 25 | dir = filepath.Join(shots, name) | |
| @@ -37,6 +40,16 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan< | |||
| 37 | 40 | return set.wait() | |
| 38 | 41 | } | |
| 39 | 42 | } | |
| 43 | + | select { | |
| 44 | + | case heads <- inventory: | |
| 45 | + | case <-ctx.Done(): | |
| 46 | + | return set.wait() | |
| 47 | + | } | |
| 48 | + | select { | |
| 49 | + | case report <- inventory: | |
| 50 | + | case <-ctx.Done(): | |
| 51 | + | return set.wait() | |
| 52 | + | } | |
| 40 | 53 | // The pointer starts centred on the first output, like libinput's. | |
| 41 | 54 | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2) | |
| 42 | 55 | for { | |
| @@ -13,10 +13,13 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | 14 | // runHeadless drives one virtual output per size, named HEADLESS-1, -2, ... | ||
| 15 | // With several outputs, screenshots go to a subdirectory per output. | 15 | // With several outputs, screenshots go to a subdirectory per output. | ||
| 16 | - | func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { | 16 | + | func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, cursorChanges <-chan ports.CursorChange, presented chan<- ports.OutputPresented, captures <-chan ports.CaptureRequest, captured chan<- ports.CaptureDone, heads chan<- ports.OutputHeads, report chan<- ports.OutputHeads, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error { |
| 17 | set := newOutputSet(ctx, captured) | 17 | set := newOutputSet(ctx, captured) | ||
| 18 | + | inventory := ports.OutputHeads{} | |||
| 18 | for i, size := range sizes { | 19 | for i, size := range sizes { | ||
| 19 | name := fmt.Sprintf("HEADLESS-%d", i+1) | 20 | name := fmt.Sprintf("HEADLESS-%d", i+1) | ||
| 21 | + | mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true} | |||
| 22 | + | inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true}) | |||
| 20 | dir := shots | 23 | dir := shots | ||
| 21 | if dir != "" && len(sizes) > 1 { | 24 | if dir != "" && len(sizes) > 1 { | ||
| 22 | dir = filepath.Join(shots, name) | 25 | dir = filepath.Join(shots, name) | ||
| @@ -37,6 +40,16 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan< | |||||
| 37 | return set.wait() | 40 | return set.wait() | ||
| 38 | } | 41 | } | ||
| 39 | } | 42 | } | ||
| 43 | + | select { | |||
| 44 | + | case heads <- inventory: | |||
| 45 | + | case <-ctx.Done(): | |||
| 46 | + | return set.wait() | |||
| 47 | + | } | |||
| 48 | + | select { | |||
| 49 | + | case report <- inventory: | |||
| 50 | + | case <-ctx.Done(): | |||
| 51 | + | return set.wait() | |||
| 52 | + | } | |||
| 40 | // The pointer starts centred on the first output, like libinput's. | 53 | // The pointer starts centred on the first output, like libinput's. | ||
| 41 | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2) | 54 | curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2) | ||
| 42 | for { | 55 | for { | ||
A internal/app/outputmanagement.go +241 -0
| @@ -0,0 +1,241 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "image" | |
| 7 | + | "math" | |
| 8 | + | "slices" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // outputOverrides owns runtime-only output settings. It is used only by the | |
| 15 | + | // app's config/apply relay goroutine; the file configuration is never mutated. | |
| 16 | + | type outputOverrides struct { | |
| 17 | + | file ports.Config | |
| 18 | + | overrides map[string]ports.OutputConfig | |
| 19 | + | heads ports.OutputHeads | |
| 20 | + | headless bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newOutputOverrides(file ports.Config, headless bool) *outputOverrides { | |
| 24 | + | return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}} | |
| 25 | + | } | |
| 26 | + | func (o *outputOverrides) effective() ports.Config { | |
| 27 | + | cfg := o.file | |
| 28 | + | cfg.Outputs = slices.Clone(o.file.Outputs) | |
| 29 | + | // Preserve file order and backend connection order; map iteration would | |
| 30 | + | // make automatic placement change unpredictably. | |
| 31 | + | for _, head := range o.heads.Heads { | |
| 32 | + | entry, ok := o.overrides[head.Info.Name] | |
| 33 | + | if !ok { | |
| 34 | + | continue | |
| 35 | + | } | |
| 36 | + | index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name }) | |
| 37 | + | if index < 0 { | |
| 38 | + | cfg.Outputs = append(cfg.Outputs, entry) | |
| 39 | + | } else { | |
| 40 | + | cfg.Outputs[index] = entry | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | return cfg | |
| 44 | + | } | |
| 45 | + | func (o *outputOverrides) reload(file ports.Config) ports.Config { | |
| 46 | + | o.file = file | |
| 47 | + | clear(o.overrides) | |
| 48 | + | return o.effective() | |
| 49 | + | } | |
| 50 | + | func (o *outputOverrides) validate(req ports.OutputApply) error { | |
| 51 | + | if len(req.Heads) != len(o.heads.Heads) { | |
| 52 | + | return fmt.Errorf("incomplete head configuration") | |
| 53 | + | } | |
| 54 | + | seen := map[string]bool{} | |
| 55 | + | for _, change := range req.Heads { | |
| 56 | + | if seen[change.Name] { | |
| 57 | + | return fmt.Errorf("duplicate head %s", change.Name) | |
| 58 | + | } | |
| 59 | + | seen[change.Name] = true | |
| 60 | + | index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name }) | |
| 61 | + | if index < 0 { | |
| 62 | + | return fmt.Errorf("unknown head %s", change.Name) | |
| 63 | + | } | |
| 64 | + | current := o.heads.Heads[index] | |
| 65 | + | if change.Transform != 0 || change.AdaptiveSync != nil || change.CustomMode { | |
| 66 | + | return fmt.Errorf("unsupported output setting on %s", change.Name) | |
| 67 | + | } | |
| 68 | + | if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) { | |
| 69 | + | return fmt.Errorf("invalid scale on %s", change.Name) | |
| 70 | + | } | |
| 71 | + | if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) { | |
| 72 | + | return fmt.Errorf("headless mode and enabled state are fixed") | |
| 73 | + | } | |
| 74 | + | if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool { | |
| 75 | + | return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli | |
| 76 | + | }) { | |
| 77 | + | return fmt.Errorf("unknown mode on %s", change.Name) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) { | |
| 83 | + | if err := o.validate(req); err != nil { | |
| 84 | + | return ports.Config{}, err | |
| 85 | + | } | |
| 86 | + | if req.Test { | |
| 87 | + | return o.effective(), nil | |
| 88 | + | } | |
| 89 | + | for _, h := range req.Heads { | |
| 90 | + | entry, ok := o.overrides[h.Name] | |
| 91 | + | if !ok { | |
| 92 | + | index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name }) | |
| 93 | + | if index >= 0 { | |
| 94 | + | entry = o.file.Outputs[index] | |
| 95 | + | } else { | |
| 96 | + | entry.Name = h.Name | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | entry.Off = !h.Enabled | |
| 100 | + | if h.Scale > 0 { | |
| 101 | + | entry.Scale = h.Scale | |
| 102 | + | } | |
| 103 | + | if h.Pos != nil { | |
| 104 | + | entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y} | |
| 105 | + | } | |
| 106 | + | if h.Mode != nil { | |
| 107 | + | entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000) | |
| 108 | + | } | |
| 109 | + | o.overrides[h.Name] = entry | |
| 110 | + | } | |
| 111 | + | return o.effective(), nil | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // relayOutputSettings serializes reloads, inventory, and protocol requests. | |
| 115 | + | // The app owns the only writer to overrides; core receives effective settings. | |
| 116 | + | func relayOutputSettings(ctx context.Context, state *outputOverrides, reloads <-chan ports.ConfigChanged, inventory <-chan ports.OutputHeads, apply <-chan ports.OutputApply, configs chan<- ports.ConfigChanged, backend chan<- ports.Config, backendDone <-chan error, replies chan<- ports.OutputApplied, log zerowrap.Logger) { | |
| 117 | + | type operation struct { | |
| 118 | + | cfg ports.Config | |
| 119 | + | req *ports.OutputApply | |
| 120 | + | previous map[string]ports.OutputConfig | |
| 121 | + | rollback bool | |
| 122 | + | failure error | |
| 123 | + | } | |
| 124 | + | var queue []operation | |
| 125 | + | var active *operation | |
| 126 | + | var sending chan<- ports.Config | |
| 127 | + | var next ports.Config | |
| 128 | + | var configSend chan<- ports.ConfigChanged | |
| 129 | + | var pendingConfig ports.ConfigChanged | |
| 130 | + | var pendingReplies []ports.OutputApplied | |
| 131 | + | publish := func(cfg ports.Config) { | |
| 132 | + | pendingConfig = ports.ConfigChanged{Config: cfg} | |
| 133 | + | configSend = configs | |
| 134 | + | } | |
| 135 | + | reply := func(id uint64, err error) { | |
| 136 | + | if err != nil { | |
| 137 | + | log.Warn().Err(err).Msg("output configuration failed") | |
| 138 | + | } | |
| 139 | + | pendingReplies = append(pendingReplies, ports.OutputApplied{ID: id, Err: err}) | |
| 140 | + | } | |
| 141 | + | enqueue := func(op operation) { | |
| 142 | + | if state.headless { | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | queue = append(queue, op) | |
| 146 | + | } | |
| 147 | + | for { | |
| 148 | + | if active == nil && len(queue) > 0 { | |
| 149 | + | op := queue[0] | |
| 150 | + | active = &op | |
| 151 | + | queue = queue[1:] | |
| 152 | + | next = active.cfg | |
| 153 | + | sending = backend | |
| 154 | + | } | |
| 155 | + | var replySend chan<- ports.OutputApplied | |
| 156 | + | var nextReply ports.OutputApplied | |
| 157 | + | if len(pendingReplies) > 0 { | |
| 158 | + | replySend = replies | |
| 159 | + | nextReply = pendingReplies[0] | |
| 160 | + | } | |
| 161 | + | select { | |
| 162 | + | case <-ctx.Done(): | |
| 163 | + | return | |
| 164 | + | case configSend <- pendingConfig: | |
| 165 | + | configSend = nil | |
| 166 | + | case replySend <- nextReply: | |
| 167 | + | pendingReplies[0] = ports.OutputApplied{} | |
| 168 | + | pendingReplies = pendingReplies[1:] | |
| 169 | + | case sending <- next: | |
| 170 | + | sending = nil | |
| 171 | + | case ev := <-reloads: | |
| 172 | + | cfg := state.reload(ev.Config) | |
| 173 | + | publish(cfg) | |
| 174 | + | // The reload supersedes runtime overrides. Old backend completion cannot | |
| 175 | + | // roll back the new file configuration. | |
| 176 | + | if active != nil && active.req != nil { | |
| 177 | + | reply(active.req.ID, fmt.Errorf("output apply superseded by reload")) | |
| 178 | + | active.req = nil | |
| 179 | + | } | |
| 180 | + | for i := range queue { | |
| 181 | + | if queue[i].req != nil { | |
| 182 | + | reply(queue[i].req.ID, fmt.Errorf("output apply superseded by reload")) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | queue = nil // Only the latest reload needs backend work. | |
| 186 | + | if active != nil && sending != nil { | |
| 187 | + | // The backend has not accepted the active config yet. | |
| 188 | + | active.cfg = cfg | |
| 189 | + | next = cfg | |
| 190 | + | } else { | |
| 191 | + | enqueue(operation{cfg: cfg}) | |
| 192 | + | } | |
| 193 | + | case heads := <-inventory: | |
| 194 | + | state.heads = heads | |
| 195 | + | case req := <-apply: | |
| 196 | + | if active != nil || len(queue) > 0 { | |
| 197 | + | reply(req.ID, fmt.Errorf("output apply already pending")) | |
| 198 | + | continue | |
| 199 | + | } | |
| 200 | + | previous := make(map[string]ports.OutputConfig, len(state.overrides)) | |
| 201 | + | for name, entry := range state.overrides { | |
| 202 | + | previous[name] = entry | |
| 203 | + | } | |
| 204 | + | cfg, err := state.apply(req) | |
| 205 | + | if err != nil || req.Test { | |
| 206 | + | reply(req.ID, err) | |
| 207 | + | continue | |
| 208 | + | } | |
| 209 | + | publish(cfg) | |
| 210 | + | if state.headless { | |
| 211 | + | reply(req.ID, nil) | |
| 212 | + | } else { | |
| 213 | + | enqueue(operation{cfg: cfg, req: &req, previous: previous}) | |
| 214 | + | } | |
| 215 | + | case err := <-backendDone: | |
| 216 | + | if active == nil || sending != nil { | |
| 217 | + | continue | |
| 218 | + | } | |
| 219 | + | op := active | |
| 220 | + | active = nil | |
| 221 | + | if op.req == nil { | |
| 222 | + | continue | |
| 223 | + | } | |
| 224 | + | if op.rollback { | |
| 225 | + | reply(op.req.ID, op.failure) | |
| 226 | + | continue | |
| 227 | + | } | |
| 228 | + | if err != nil { | |
| 229 | + | state.overrides = op.previous | |
| 230 | + | cfg := state.effective() | |
| 231 | + | publish(cfg) | |
| 232 | + | enqueue(operation{cfg: cfg, req: op.req, rollback: true, failure: err}) | |
| 233 | + | continue | |
| 234 | + | } | |
| 235 | + | for _, h := range op.req.Heads { | |
| 236 | + | log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied") | |
| 237 | + | } | |
| 238 | + | reply(op.req.ID, nil) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | } | |
| @@ -0,0 +1,241 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "image" | |||
| 7 | + | "math" | |||
| 8 | + | "slices" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // outputOverrides owns runtime-only output settings. It is used only by the | |||
| 15 | + | // app's config/apply relay goroutine; the file configuration is never mutated. | |||
| 16 | + | type outputOverrides struct { | |||
| 17 | + | file ports.Config | |||
| 18 | + | overrides map[string]ports.OutputConfig | |||
| 19 | + | heads ports.OutputHeads | |||
| 20 | + | headless bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func newOutputOverrides(file ports.Config, headless bool) *outputOverrides { | |||
| 24 | + | return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}} | |||
| 25 | + | } | |||
| 26 | + | func (o *outputOverrides) effective() ports.Config { | |||
| 27 | + | cfg := o.file | |||
| 28 | + | cfg.Outputs = slices.Clone(o.file.Outputs) | |||
| 29 | + | // Preserve file order and backend connection order; map iteration would | |||
| 30 | + | // make automatic placement change unpredictably. | |||
| 31 | + | for _, head := range o.heads.Heads { | |||
| 32 | + | entry, ok := o.overrides[head.Info.Name] | |||
| 33 | + | if !ok { | |||
| 34 | + | continue | |||
| 35 | + | } | |||
| 36 | + | index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name }) | |||
| 37 | + | if index < 0 { | |||
| 38 | + | cfg.Outputs = append(cfg.Outputs, entry) | |||
| 39 | + | } else { | |||
| 40 | + | cfg.Outputs[index] = entry | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | return cfg | |||
| 44 | + | } | |||
| 45 | + | func (o *outputOverrides) reload(file ports.Config) ports.Config { | |||
| 46 | + | o.file = file | |||
| 47 | + | clear(o.overrides) | |||
| 48 | + | return o.effective() | |||
| 49 | + | } | |||
| 50 | + | func (o *outputOverrides) validate(req ports.OutputApply) error { | |||
| 51 | + | if len(req.Heads) != len(o.heads.Heads) { | |||
| 52 | + | return fmt.Errorf("incomplete head configuration") | |||
| 53 | + | } | |||
| 54 | + | seen := map[string]bool{} | |||
| 55 | + | for _, change := range req.Heads { | |||
| 56 | + | if seen[change.Name] { | |||
| 57 | + | return fmt.Errorf("duplicate head %s", change.Name) | |||
| 58 | + | } | |||
| 59 | + | seen[change.Name] = true | |||
| 60 | + | index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name }) | |||
| 61 | + | if index < 0 { | |||
| 62 | + | return fmt.Errorf("unknown head %s", change.Name) | |||
| 63 | + | } | |||
| 64 | + | current := o.heads.Heads[index] | |||
| 65 | + | if change.Transform != 0 || change.AdaptiveSync != nil || change.CustomMode { | |||
| 66 | + | return fmt.Errorf("unsupported output setting on %s", change.Name) | |||
| 67 | + | } | |||
| 68 | + | if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) { | |||
| 69 | + | return fmt.Errorf("invalid scale on %s", change.Name) | |||
| 70 | + | } | |||
| 71 | + | if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) { | |||
| 72 | + | return fmt.Errorf("headless mode and enabled state are fixed") | |||
| 73 | + | } | |||
| 74 | + | if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool { | |||
| 75 | + | return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli | |||
| 76 | + | }) { | |||
| 77 | + | return fmt.Errorf("unknown mode on %s", change.Name) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) { | |||
| 83 | + | if err := o.validate(req); err != nil { | |||
| 84 | + | return ports.Config{}, err | |||
| 85 | + | } | |||
| 86 | + | if req.Test { | |||
| 87 | + | return o.effective(), nil | |||
| 88 | + | } | |||
| 89 | + | for _, h := range req.Heads { | |||
| 90 | + | entry, ok := o.overrides[h.Name] | |||
| 91 | + | if !ok { | |||
| 92 | + | index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name }) | |||
| 93 | + | if index >= 0 { | |||
| 94 | + | entry = o.file.Outputs[index] | |||
| 95 | + | } else { | |||
| 96 | + | entry.Name = h.Name | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | entry.Off = !h.Enabled | |||
| 100 | + | if h.Scale > 0 { | |||
| 101 | + | entry.Scale = h.Scale | |||
| 102 | + | } | |||
| 103 | + | if h.Pos != nil { | |||
| 104 | + | entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y} | |||
| 105 | + | } | |||
| 106 | + | if h.Mode != nil { | |||
| 107 | + | entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000) | |||
| 108 | + | } | |||
| 109 | + | o.overrides[h.Name] = entry | |||
| 110 | + | } | |||
| 111 | + | return o.effective(), nil | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // relayOutputSettings serializes reloads, inventory, and protocol requests. | |||
| 115 | + | // The app owns the only writer to overrides; core receives effective settings. | |||
| 116 | + | func relayOutputSettings(ctx context.Context, state *outputOverrides, reloads <-chan ports.ConfigChanged, inventory <-chan ports.OutputHeads, apply <-chan ports.OutputApply, configs chan<- ports.ConfigChanged, backend chan<- ports.Config, backendDone <-chan error, replies chan<- ports.OutputApplied, log zerowrap.Logger) { | |||
| 117 | + | type operation struct { | |||
| 118 | + | cfg ports.Config | |||
| 119 | + | req *ports.OutputApply | |||
| 120 | + | previous map[string]ports.OutputConfig | |||
| 121 | + | rollback bool | |||
| 122 | + | failure error | |||
| 123 | + | } | |||
| 124 | + | var queue []operation | |||
| 125 | + | var active *operation | |||
| 126 | + | var sending chan<- ports.Config | |||
| 127 | + | var next ports.Config | |||
| 128 | + | var configSend chan<- ports.ConfigChanged | |||
| 129 | + | var pendingConfig ports.ConfigChanged | |||
| 130 | + | var pendingReplies []ports.OutputApplied | |||
| 131 | + | publish := func(cfg ports.Config) { | |||
| 132 | + | pendingConfig = ports.ConfigChanged{Config: cfg} | |||
| 133 | + | configSend = configs | |||
| 134 | + | } | |||
| 135 | + | reply := func(id uint64, err error) { | |||
| 136 | + | if err != nil { | |||
| 137 | + | log.Warn().Err(err).Msg("output configuration failed") | |||
| 138 | + | } | |||
| 139 | + | pendingReplies = append(pendingReplies, ports.OutputApplied{ID: id, Err: err}) | |||
| 140 | + | } | |||
| 141 | + | enqueue := func(op operation) { | |||
| 142 | + | if state.headless { | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | queue = append(queue, op) | |||
| 146 | + | } | |||
| 147 | + | for { | |||
| 148 | + | if active == nil && len(queue) > 0 { | |||
| 149 | + | op := queue[0] | |||
| 150 | + | active = &op | |||
| 151 | + | queue = queue[1:] | |||
| 152 | + | next = active.cfg | |||
| 153 | + | sending = backend | |||
| 154 | + | } | |||
| 155 | + | var replySend chan<- ports.OutputApplied | |||
| 156 | + | var nextReply ports.OutputApplied | |||
| 157 | + | if len(pendingReplies) > 0 { | |||
| 158 | + | replySend = replies | |||
| 159 | + | nextReply = pendingReplies[0] | |||
| 160 | + | } | |||
| 161 | + | select { | |||
| 162 | + | case <-ctx.Done(): | |||
| 163 | + | return | |||
| 164 | + | case configSend <- pendingConfig: | |||
| 165 | + | configSend = nil | |||
| 166 | + | case replySend <- nextReply: | |||
| 167 | + | pendingReplies[0] = ports.OutputApplied{} | |||
| 168 | + | pendingReplies = pendingReplies[1:] | |||
| 169 | + | case sending <- next: | |||
| 170 | + | sending = nil | |||
| 171 | + | case ev := <-reloads: | |||
| 172 | + | cfg := state.reload(ev.Config) | |||
| 173 | + | publish(cfg) | |||
| 174 | + | // The reload supersedes runtime overrides. Old backend completion cannot | |||
| 175 | + | // roll back the new file configuration. | |||
| 176 | + | if active != nil && active.req != nil { | |||
| 177 | + | reply(active.req.ID, fmt.Errorf("output apply superseded by reload")) | |||
| 178 | + | active.req = nil | |||
| 179 | + | } | |||
| 180 | + | for i := range queue { | |||
| 181 | + | if queue[i].req != nil { | |||
| 182 | + | reply(queue[i].req.ID, fmt.Errorf("output apply superseded by reload")) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | queue = nil // Only the latest reload needs backend work. | |||
| 186 | + | if active != nil && sending != nil { | |||
| 187 | + | // The backend has not accepted the active config yet. | |||
| 188 | + | active.cfg = cfg | |||
| 189 | + | next = cfg | |||
| 190 | + | } else { | |||
| 191 | + | enqueue(operation{cfg: cfg}) | |||
| 192 | + | } | |||
| 193 | + | case heads := <-inventory: | |||
| 194 | + | state.heads = heads | |||
| 195 | + | case req := <-apply: | |||
| 196 | + | if active != nil || len(queue) > 0 { | |||
| 197 | + | reply(req.ID, fmt.Errorf("output apply already pending")) | |||
| 198 | + | continue | |||
| 199 | + | } | |||
| 200 | + | previous := make(map[string]ports.OutputConfig, len(state.overrides)) | |||
| 201 | + | for name, entry := range state.overrides { | |||
| 202 | + | previous[name] = entry | |||
| 203 | + | } | |||
| 204 | + | cfg, err := state.apply(req) | |||
| 205 | + | if err != nil || req.Test { | |||
| 206 | + | reply(req.ID, err) | |||
| 207 | + | continue | |||
| 208 | + | } | |||
| 209 | + | publish(cfg) | |||
| 210 | + | if state.headless { | |||
| 211 | + | reply(req.ID, nil) | |||
| 212 | + | } else { | |||
| 213 | + | enqueue(operation{cfg: cfg, req: &req, previous: previous}) | |||
| 214 | + | } | |||
| 215 | + | case err := <-backendDone: | |||
| 216 | + | if active == nil || sending != nil { | |||
| 217 | + | continue | |||
| 218 | + | } | |||
| 219 | + | op := active | |||
| 220 | + | active = nil | |||
| 221 | + | if op.req == nil { | |||
| 222 | + | continue | |||
| 223 | + | } | |||
| 224 | + | if op.rollback { | |||
| 225 | + | reply(op.req.ID, op.failure) | |||
| 226 | + | continue | |||
| 227 | + | } | |||
| 228 | + | if err != nil { | |||
| 229 | + | state.overrides = op.previous | |||
| 230 | + | cfg := state.effective() | |||
| 231 | + | publish(cfg) | |||
| 232 | + | enqueue(operation{cfg: cfg, req: op.req, rollback: true, failure: err}) | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | for _, h := range op.req.Heads { | |||
| 236 | + | log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied") | |||
| 237 | + | } | |||
| 238 | + | reply(op.req.ID, nil) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | } | |||
A internal/app/outputmanagement_test.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "image" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/logging" | |
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestOutputSettingsRelay(t *testing.T) { | |
| 15 | + | for _, tc := range []struct { | |
| 16 | + | name string | |
| 17 | + | failure error | |
| 18 | + | }{ | |
| 19 | + | {"ready", nil}, | |
| 20 | + | {"ready error rollback", errors.New("modeset failed")}, | |
| 21 | + | {"timeout rollback", errTimeout}, | |
| 22 | + | } { | |
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |
| 24 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 25 | + | defer cancel() | |
| 26 | + | state := newOutputOverrides(ports.Config{}, false) | |
| 27 | + | reloads := make(chan ports.ConfigChanged) | |
| 28 | + | inventory := make(chan ports.OutputHeads) | |
| 29 | + | applies := make(chan ports.OutputApply) | |
| 30 | + | configs := make(chan ports.ConfigChanged, 4) | |
| 31 | + | backend := make(chan ports.Config, 4) | |
| 32 | + | backendDone := make(chan error) | |
| 33 | + | replies := make(chan ports.OutputApplied) | |
| 34 | + | done := make(chan struct{}) | |
| 35 | + | go func() { | |
| 36 | + | defer close(done) | |
| 37 | + | relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app")) | |
| 38 | + | }() | |
| 39 | + | defer func() { cancel(); <-done }() | |
| 40 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |
| 41 | + | inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}} | |
| 42 | + | applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}} | |
| 43 | + | next := <-configs | |
| 44 | + | if next.Config.Outputs[0].Pos.X != 100 { | |
| 45 | + | t.Fatalf("next: %+v", next) | |
| 46 | + | } | |
| 47 | + | <-backend | |
| 48 | + | select { | |
| 49 | + | case <-replies: | |
| 50 | + | t.Fatal("replied before backend ready") | |
| 51 | + | default: | |
| 52 | + | } | |
| 53 | + | if tc.failure != nil { | |
| 54 | + | backendDone <- tc.failure | |
| 55 | + | rollback := <-configs | |
| 56 | + | if len(rollback.Config.Outputs) != 0 { | |
| 57 | + | t.Fatalf("rollback: %+v", rollback) | |
| 58 | + | } | |
| 59 | + | <-backend | |
| 60 | + | backendDone <- nil | |
| 61 | + | } else { | |
| 62 | + | backendDone <- nil | |
| 63 | + | } | |
| 64 | + | select { | |
| 65 | + | case r := <-replies: | |
| 66 | + | if !errors.Is(r.Err, tc.failure) || (r.Err != nil) != (tc.failure != nil) { | |
| 67 | + | t.Fatalf("result: %+v, want %v", r, tc.failure) | |
| 68 | + | } | |
| 69 | + | case <-time.After(2 * time.Second): | |
| 70 | + | t.Fatal("missing reply") | |
| 71 | + | } | |
| 72 | + | if tc.failure != nil && len(state.effective().Outputs) != 0 { | |
| 73 | + | t.Fatal("override survived failure") | |
| 74 | + | } | |
| 75 | + | reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}} | |
| 76 | + | reloaded := <-configs | |
| 77 | + | if reloaded.Config.Outputs[0].Pos != nil || reloaded.Config.Outputs[0].Scale != 1.25 { | |
| 78 | + | t.Fatalf("reload: %+v", reloaded) | |
| 79 | + | } | |
| 80 | + | <-backend | |
| 81 | + | backendDone <- nil | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestOutputSettingsRelayBlockedConsumers(t *testing.T) { | |
| 87 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 88 | + | state := newOutputOverrides(ports.Config{}, false) | |
| 89 | + | reloads := make(chan ports.ConfigChanged) | |
| 90 | + | inventory := make(chan ports.OutputHeads) | |
| 91 | + | applies := make(chan ports.OutputApply) | |
| 92 | + | configs := make(chan ports.ConfigChanged) | |
| 93 | + | backend := make(chan ports.Config) | |
| 94 | + | backendDone := make(chan error) | |
| 95 | + | replies := make(chan ports.OutputApplied) | |
| 96 | + | done := make(chan struct{}) | |
| 97 | + | go func() { | |
| 98 | + | defer close(done) | |
| 99 | + | relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app")) | |
| 100 | + | }() | |
| 101 | + | defer func() { cancel(); <-done }() | |
| 102 | + | ||
| 103 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |
| 104 | + | select { | |
| 105 | + | case inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}: | |
| 106 | + | case <-time.After(time.Second): | |
| 107 | + | t.Fatal("inventory blocked") | |
| 108 | + | } | |
| 109 | + | select { | |
| 110 | + | case applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}: | |
| 111 | + | case <-time.After(time.Second): | |
| 112 | + | t.Fatal("apply blocked") | |
| 113 | + | } | |
| 114 | + | // Neither config consumer nor backend nor Wayland is reading. Reload must | |
| 115 | + | // still supersede the apply and subsequent requests must still be answered. | |
| 116 | + | select { | |
| 117 | + | case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}: | |
| 118 | + | case <-time.After(time.Second): | |
| 119 | + | t.Fatal("reload blocked by unread config or backend") | |
| 120 | + | } | |
| 121 | + | select { | |
| 122 | + | case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}}: | |
| 123 | + | case <-time.After(time.Second): | |
| 124 | + | t.Fatal("second reload blocked by unread config or backend") | |
| 125 | + | } | |
| 126 | + | for _, id := range []uint64{2, 3} { | |
| 127 | + | select { | |
| 128 | + | case applies <- ports.OutputApply{ID: id}: | |
| 129 | + | case <-time.After(time.Second): | |
| 130 | + | t.Fatalf("request %d blocked by unread reply", id) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | for _, id := range []uint64{1, 2, 3} { | |
| 134 | + | select { | |
| 135 | + | case result := <-replies: | |
| 136 | + | if result.ID != id || result.Err == nil { | |
| 137 | + | t.Fatalf("reply %d: %+v", id, result) | |
| 138 | + | } | |
| 139 | + | case <-time.After(time.Second): | |
| 140 | + | t.Fatalf("missing reply %d", id) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | select { | |
| 144 | + | case cfg := <-configs: | |
| 145 | + | if len(cfg.Config.Outputs) != 1 || cfg.Config.Outputs[0].Scale != 2 || cfg.Config.Outputs[0].Pos != nil { | |
| 146 | + | t.Fatalf("latest config: %+v", cfg) | |
| 147 | + | } | |
| 148 | + | case <-time.After(time.Second): | |
| 149 | + | t.Fatal("missing latest config") | |
| 150 | + | } | |
| 151 | + | select { | |
| 152 | + | case cfg := <-backend: | |
| 153 | + | if len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil { | |
| 154 | + | t.Fatalf("latest backend config: %+v", cfg) | |
| 155 | + | } | |
| 156 | + | case <-time.After(time.Second): | |
| 157 | + | t.Fatal("missing latest backend config") | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) { | |
| 162 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |
| 163 | + | state := newOutputOverrides(ports.Config{}, true) | |
| 164 | + | state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}} | |
| 165 | + | if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil { | |
| 166 | + | t.Fatal("headless disable accepted") | |
| 167 | + | } | |
| 168 | + | state.headless = false | |
| 169 | + | cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}) | |
| 170 | + | if err != nil || !cfg.Outputs[0].Off { | |
| 171 | + | t.Fatalf("disable: %+v, %v", cfg, err) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestReloadDuringPendingApply(t *testing.T) { | |
| 176 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 177 | + | defer cancel() | |
| 178 | + | state := newOutputOverrides(ports.Config{}, false) | |
| 179 | + | reloads := make(chan ports.ConfigChanged) | |
| 180 | + | inventory := make(chan ports.OutputHeads) | |
| 181 | + | applies := make(chan ports.OutputApply) | |
| 182 | + | configs := make(chan ports.ConfigChanged, 4) | |
| 183 | + | backend := make(chan ports.Config, 4) | |
| 184 | + | backendDone := make(chan error) | |
| 185 | + | replies := make(chan ports.OutputApplied, 2) | |
| 186 | + | done := make(chan struct{}) | |
| 187 | + | go func() { | |
| 188 | + | defer close(done) | |
| 189 | + | relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app")) | |
| 190 | + | }() | |
| 191 | + | defer func() { cancel(); <-done }() | |
| 192 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |
| 193 | + | inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}} | |
| 194 | + | applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}} | |
| 195 | + | <-configs | |
| 196 | + | <-backend | |
| 197 | + | reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}} | |
| 198 | + | select { | |
| 199 | + | case cfg := <-configs: | |
| 200 | + | if cfg.Config.Outputs[0].Pos != nil { | |
| 201 | + | t.Fatal("override survived reload") | |
| 202 | + | } | |
| 203 | + | case <-time.After(time.Second): | |
| 204 | + | t.Fatal("reload blocked by backend") | |
| 205 | + | } | |
| 206 | + | if r := <-replies; r.ID != 1 || r.Err == nil { | |
| 207 | + | t.Fatalf("superseded reply: %+v", r) | |
| 208 | + | } | |
| 209 | + | backendDone <- errors.New("old apply failed") | |
| 210 | + | <-backend | |
| 211 | + | backendDone <- nil | |
| 212 | + | } | |
| @@ -0,0 +1,212 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "image" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestOutputSettingsRelay(t *testing.T) { | |||
| 15 | + | for _, tc := range []struct { | |||
| 16 | + | name string | |||
| 17 | + | failure error | |||
| 18 | + | }{ | |||
| 19 | + | {"ready", nil}, | |||
| 20 | + | {"ready error rollback", errors.New("modeset failed")}, | |||
| 21 | + | {"timeout rollback", errTimeout}, | |||
| 22 | + | } { | |||
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 24 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 25 | + | defer cancel() | |||
| 26 | + | state := newOutputOverrides(ports.Config{}, false) | |||
| 27 | + | reloads := make(chan ports.ConfigChanged) | |||
| 28 | + | inventory := make(chan ports.OutputHeads) | |||
| 29 | + | applies := make(chan ports.OutputApply) | |||
| 30 | + | configs := make(chan ports.ConfigChanged, 4) | |||
| 31 | + | backend := make(chan ports.Config, 4) | |||
| 32 | + | backendDone := make(chan error) | |||
| 33 | + | replies := make(chan ports.OutputApplied) | |||
| 34 | + | done := make(chan struct{}) | |||
| 35 | + | go func() { | |||
| 36 | + | defer close(done) | |||
| 37 | + | relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app")) | |||
| 38 | + | }() | |||
| 39 | + | defer func() { cancel(); <-done }() | |||
| 40 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |||
| 41 | + | inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}} | |||
| 42 | + | applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}} | |||
| 43 | + | next := <-configs | |||
| 44 | + | if next.Config.Outputs[0].Pos.X != 100 { | |||
| 45 | + | t.Fatalf("next: %+v", next) | |||
| 46 | + | } | |||
| 47 | + | <-backend | |||
| 48 | + | select { | |||
| 49 | + | case <-replies: | |||
| 50 | + | t.Fatal("replied before backend ready") | |||
| 51 | + | default: | |||
| 52 | + | } | |||
| 53 | + | if tc.failure != nil { | |||
| 54 | + | backendDone <- tc.failure | |||
| 55 | + | rollback := <-configs | |||
| 56 | + | if len(rollback.Config.Outputs) != 0 { | |||
| 57 | + | t.Fatalf("rollback: %+v", rollback) | |||
| 58 | + | } | |||
| 59 | + | <-backend | |||
| 60 | + | backendDone <- nil | |||
| 61 | + | } else { | |||
| 62 | + | backendDone <- nil | |||
| 63 | + | } | |||
| 64 | + | select { | |||
| 65 | + | case r := <-replies: | |||
| 66 | + | if !errors.Is(r.Err, tc.failure) || (r.Err != nil) != (tc.failure != nil) { | |||
| 67 | + | t.Fatalf("result: %+v, want %v", r, tc.failure) | |||
| 68 | + | } | |||
| 69 | + | case <-time.After(2 * time.Second): | |||
| 70 | + | t.Fatal("missing reply") | |||
| 71 | + | } | |||
| 72 | + | if tc.failure != nil && len(state.effective().Outputs) != 0 { | |||
| 73 | + | t.Fatal("override survived failure") | |||
| 74 | + | } | |||
| 75 | + | reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}}} | |||
| 76 | + | reloaded := <-configs | |||
| 77 | + | if reloaded.Config.Outputs[0].Pos != nil || reloaded.Config.Outputs[0].Scale != 1.25 { | |||
| 78 | + | t.Fatalf("reload: %+v", reloaded) | |||
| 79 | + | } | |||
| 80 | + | <-backend | |||
| 81 | + | backendDone <- nil | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestOutputSettingsRelayBlockedConsumers(t *testing.T) { | |||
| 87 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 88 | + | state := newOutputOverrides(ports.Config{}, false) | |||
| 89 | + | reloads := make(chan ports.ConfigChanged) | |||
| 90 | + | inventory := make(chan ports.OutputHeads) | |||
| 91 | + | applies := make(chan ports.OutputApply) | |||
| 92 | + | configs := make(chan ports.ConfigChanged) | |||
| 93 | + | backend := make(chan ports.Config) | |||
| 94 | + | backendDone := make(chan error) | |||
| 95 | + | replies := make(chan ports.OutputApplied) | |||
| 96 | + | done := make(chan struct{}) | |||
| 97 | + | go func() { | |||
| 98 | + | defer close(done) | |||
| 99 | + | relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app")) | |||
| 100 | + | }() | |||
| 101 | + | defer func() { cancel(); <-done }() | |||
| 102 | + | ||||
| 103 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |||
| 104 | + | select { | |||
| 105 | + | case inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}}: | |||
| 106 | + | case <-time.After(time.Second): | |||
| 107 | + | t.Fatal("inventory blocked") | |||
| 108 | + | } | |||
| 109 | + | select { | |||
| 110 | + | case applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}}: | |||
| 111 | + | case <-time.After(time.Second): | |||
| 112 | + | t.Fatal("apply blocked") | |||
| 113 | + | } | |||
| 114 | + | // Neither config consumer nor backend nor Wayland is reading. Reload must | |||
| 115 | + | // still supersede the apply and subsequent requests must still be answered. | |||
| 116 | + | select { | |||
| 117 | + | case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}}: | |||
| 118 | + | case <-time.After(time.Second): | |||
| 119 | + | t.Fatal("reload blocked by unread config or backend") | |||
| 120 | + | } | |||
| 121 | + | select { | |||
| 122 | + | case reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}}: | |||
| 123 | + | case <-time.After(time.Second): | |||
| 124 | + | t.Fatal("second reload blocked by unread config or backend") | |||
| 125 | + | } | |||
| 126 | + | for _, id := range []uint64{2, 3} { | |||
| 127 | + | select { | |||
| 128 | + | case applies <- ports.OutputApply{ID: id}: | |||
| 129 | + | case <-time.After(time.Second): | |||
| 130 | + | t.Fatalf("request %d blocked by unread reply", id) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | for _, id := range []uint64{1, 2, 3} { | |||
| 134 | + | select { | |||
| 135 | + | case result := <-replies: | |||
| 136 | + | if result.ID != id || result.Err == nil { | |||
| 137 | + | t.Fatalf("reply %d: %+v", id, result) | |||
| 138 | + | } | |||
| 139 | + | case <-time.After(time.Second): | |||
| 140 | + | t.Fatalf("missing reply %d", id) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | select { | |||
| 144 | + | case cfg := <-configs: | |||
| 145 | + | if len(cfg.Config.Outputs) != 1 || cfg.Config.Outputs[0].Scale != 2 || cfg.Config.Outputs[0].Pos != nil { | |||
| 146 | + | t.Fatalf("latest config: %+v", cfg) | |||
| 147 | + | } | |||
| 148 | + | case <-time.After(time.Second): | |||
| 149 | + | t.Fatal("missing latest config") | |||
| 150 | + | } | |||
| 151 | + | select { | |||
| 152 | + | case cfg := <-backend: | |||
| 153 | + | if len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil { | |||
| 154 | + | t.Fatalf("latest backend config: %+v", cfg) | |||
| 155 | + | } | |||
| 156 | + | case <-time.After(time.Second): | |||
| 157 | + | t.Fatal("missing latest backend config") | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) { | |||
| 162 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |||
| 163 | + | state := newOutputOverrides(ports.Config{}, true) | |||
| 164 | + | state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}} | |||
| 165 | + | if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil { | |||
| 166 | + | t.Fatal("headless disable accepted") | |||
| 167 | + | } | |||
| 168 | + | state.headless = false | |||
| 169 | + | cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}) | |||
| 170 | + | if err != nil || !cfg.Outputs[0].Off { | |||
| 171 | + | t.Fatalf("disable: %+v, %v", cfg, err) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestReloadDuringPendingApply(t *testing.T) { | |||
| 176 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 177 | + | defer cancel() | |||
| 178 | + | state := newOutputOverrides(ports.Config{}, false) | |||
| 179 | + | reloads := make(chan ports.ConfigChanged) | |||
| 180 | + | inventory := make(chan ports.OutputHeads) | |||
| 181 | + | applies := make(chan ports.OutputApply) | |||
| 182 | + | configs := make(chan ports.ConfigChanged, 4) | |||
| 183 | + | backend := make(chan ports.Config, 4) | |||
| 184 | + | backendDone := make(chan error) | |||
| 185 | + | replies := make(chan ports.OutputApplied, 2) | |||
| 186 | + | done := make(chan struct{}) | |||
| 187 | + | go func() { | |||
| 188 | + | defer close(done) | |||
| 189 | + | relayOutputSettings(ctx, state, reloads, inventory, applies, configs, backend, backendDone, replies, logging.For(ctx, "app")) | |||
| 190 | + | }() | |||
| 191 | + | defer func() { cancel(); <-done }() | |||
| 192 | + | mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000} | |||
| 193 | + | inventory <- ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode}}} | |||
| 194 | + | applies <- ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}}} | |||
| 195 | + | <-configs | |||
| 196 | + | <-backend | |||
| 197 | + | reloads <- ports.ConfigChanged{Config: ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}} | |||
| 198 | + | select { | |||
| 199 | + | case cfg := <-configs: | |||
| 200 | + | if cfg.Config.Outputs[0].Pos != nil { | |||
| 201 | + | t.Fatal("override survived reload") | |||
| 202 | + | } | |||
| 203 | + | case <-time.After(time.Second): | |||
| 204 | + | t.Fatal("reload blocked by backend") | |||
| 205 | + | } | |||
| 206 | + | if r := <-replies; r.ID != 1 || r.Err == nil { | |||
| 207 | + | t.Fatalf("superseded reply: %+v", r) | |||
| 208 | + | } | |||
| 209 | + | backendDone <- errors.New("old apply failed") | |||
| 210 | + | <-backend | |||
| 211 | + | backendDone <- nil | |||
| 212 | + | } | |||
A internal/app/pendingapply.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // applyProgress belongs exclusively to the output loop. An instance is identified | |
| 9 | + | // by its Ready channel, which is distinct for each start of a connector. | |
| 10 | + | type applyProgress struct { | |
| 11 | + | id uint64 | |
| 12 | + | active bool | |
| 13 | + | required map[string]bool | |
| 14 | + | waiting map[string]bool // stopped instances awaiting replacement or removal | |
| 15 | + | ready map[string]<-chan error | |
| 16 | + | completed map[<-chan error]bool | |
| 17 | + | err error | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type applyDecision struct { | |
| 21 | + | reply bool | |
| 22 | + | err error | |
| 23 | + | rollback bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision { | |
| 27 | + | p.id, p.active, p.err = id, true, nil | |
| 28 | + | p.required = required | |
| 29 | + | p.waiting = map[string]bool{} | |
| 30 | + | p.ready = map[string]<-chan error{} | |
| 31 | + | for name := range stopping { | |
| 32 | + | p.waiting[name] = true | |
| 33 | + | } | |
| 34 | + | return applyDecision{} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (p *applyProgress) stopping(name string) { | |
| 38 | + | if p.active { | |
| 39 | + | p.waiting[name] = true | |
| 40 | + | delete(p.ready, name) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (p *applyProgress) started(name string, instance <-chan error) { | |
| 45 | + | if p.active && p.required[name] && !p.completed[instance] && !p.waiting[name] { | |
| 46 | + | p.ready[name] = instance | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (p *applyProgress) readyEvent(name string, instance <-chan error, err error) applyDecision { | |
| 51 | + | if p.completed == nil { | |
| 52 | + | p.completed = map[<-chan error]bool{} | |
| 53 | + | } | |
| 54 | + | if err == nil { | |
| 55 | + | p.completed[instance] = true | |
| 56 | + | } | |
| 57 | + | if p.active && p.ready[name] == instance { | |
| 58 | + | delete(p.ready, name) | |
| 59 | + | if err != nil { | |
| 60 | + | return p.fail(fmt.Errorf("%s: %w", name, err)) | |
| 61 | + | } | |
| 62 | + | } else if p.active && err != nil && p.required[name] && !p.waiting[name] { | |
| 63 | + | return p.fail(fmt.Errorf("%s: %w", name, err)) | |
| 64 | + | } | |
| 65 | + | return p.complete() | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (p *applyProgress) stopped(name string, instance <-chan error, restart bool, err error) applyDecision { | |
| 69 | + | delete(p.completed, instance) | |
| 70 | + | if !p.active { | |
| 71 | + | return applyDecision{} | |
| 72 | + | } | |
| 73 | + | if p.ready[name] == instance { | |
| 74 | + | delete(p.ready, name) | |
| 75 | + | } | |
| 76 | + | if err != nil { | |
| 77 | + | return p.fail(fmt.Errorf("%s stopped: %w", name, err)) | |
| 78 | + | } | |
| 79 | + | if restart && p.required[name] { | |
| 80 | + | p.waiting[name] = false | |
| 81 | + | return applyDecision{} | |
| 82 | + | } else { | |
| 83 | + | delete(p.waiting, name) | |
| 84 | + | if p.required[name] { | |
| 85 | + | return p.fail(fmt.Errorf("%s stopped before ready", name)) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | return p.complete() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (p *applyProgress) scanned(running map[string]<-chan error) applyDecision { | |
| 92 | + | if !p.active { | |
| 93 | + | return applyDecision{} | |
| 94 | + | } | |
| 95 | + | for name := range p.required { | |
| 96 | + | instance := running[name] | |
| 97 | + | if instance == nil { | |
| 98 | + | return p.fail(fmt.Errorf("%s failed to start", name)) | |
| 99 | + | } | |
| 100 | + | if stopping, ok := p.waiting[name]; ok { | |
| 101 | + | if stopping { | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | delete(p.waiting, name) | |
| 105 | + | } | |
| 106 | + | if !p.completed[instance] { | |
| 107 | + | p.ready[name] = instance | |
| 108 | + | } else { | |
| 109 | + | delete(p.ready, name) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return p.complete() | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // scanError fails the active operation even if an old output was already ready. | |
| 116 | + | func (p *applyProgress) scanError(err error) applyDecision { | |
| 117 | + | if !p.active || err == nil { | |
| 118 | + | return applyDecision{} | |
| 119 | + | } | |
| 120 | + | return p.fail(err) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (p *applyProgress) timeout(id uint64) applyDecision { | |
| 124 | + | if !p.active || id != p.id { | |
| 125 | + | return applyDecision{} | |
| 126 | + | } | |
| 127 | + | return p.fail(errTimeout) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (p *applyProgress) fail(err error) applyDecision { | |
| 131 | + | p.err = errors.Join(p.err, err) | |
| 132 | + | p.active = false | |
| 133 | + | return applyDecision{reply: true, err: p.err, rollback: true} | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (p *applyProgress) complete() applyDecision { | |
| 137 | + | if !p.active || len(p.waiting) != 0 || len(p.ready) != 0 { | |
| 138 | + | return applyDecision{} | |
| 139 | + | } | |
| 140 | + | p.active = false | |
| 141 | + | return applyDecision{reply: true, err: p.err, rollback: p.err != nil} | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // applyProgress belongs exclusively to the output loop. An instance is identified | |||
| 9 | + | // by its Ready channel, which is distinct for each start of a connector. | |||
| 10 | + | type applyProgress struct { | |||
| 11 | + | id uint64 | |||
| 12 | + | active bool | |||
| 13 | + | required map[string]bool | |||
| 14 | + | waiting map[string]bool // stopped instances awaiting replacement or removal | |||
| 15 | + | ready map[string]<-chan error | |||
| 16 | + | completed map[<-chan error]bool | |||
| 17 | + | err error | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type applyDecision struct { | |||
| 21 | + | reply bool | |||
| 22 | + | err error | |||
| 23 | + | rollback bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (p *applyProgress) start(id uint64, required map[string]bool, stopping map[string]bool) applyDecision { | |||
| 27 | + | p.id, p.active, p.err = id, true, nil | |||
| 28 | + | p.required = required | |||
| 29 | + | p.waiting = map[string]bool{} | |||
| 30 | + | p.ready = map[string]<-chan error{} | |||
| 31 | + | for name := range stopping { | |||
| 32 | + | p.waiting[name] = true | |||
| 33 | + | } | |||
| 34 | + | return applyDecision{} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (p *applyProgress) stopping(name string) { | |||
| 38 | + | if p.active { | |||
| 39 | + | p.waiting[name] = true | |||
| 40 | + | delete(p.ready, name) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (p *applyProgress) started(name string, instance <-chan error) { | |||
| 45 | + | if p.active && p.required[name] && !p.completed[instance] && !p.waiting[name] { | |||
| 46 | + | p.ready[name] = instance | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (p *applyProgress) readyEvent(name string, instance <-chan error, err error) applyDecision { | |||
| 51 | + | if p.completed == nil { | |||
| 52 | + | p.completed = map[<-chan error]bool{} | |||
| 53 | + | } | |||
| 54 | + | if err == nil { | |||
| 55 | + | p.completed[instance] = true | |||
| 56 | + | } | |||
| 57 | + | if p.active && p.ready[name] == instance { | |||
| 58 | + | delete(p.ready, name) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return p.fail(fmt.Errorf("%s: %w", name, err)) | |||
| 61 | + | } | |||
| 62 | + | } else if p.active && err != nil && p.required[name] && !p.waiting[name] { | |||
| 63 | + | return p.fail(fmt.Errorf("%s: %w", name, err)) | |||
| 64 | + | } | |||
| 65 | + | return p.complete() | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (p *applyProgress) stopped(name string, instance <-chan error, restart bool, err error) applyDecision { | |||
| 69 | + | delete(p.completed, instance) | |||
| 70 | + | if !p.active { | |||
| 71 | + | return applyDecision{} | |||
| 72 | + | } | |||
| 73 | + | if p.ready[name] == instance { | |||
| 74 | + | delete(p.ready, name) | |||
| 75 | + | } | |||
| 76 | + | if err != nil { | |||
| 77 | + | return p.fail(fmt.Errorf("%s stopped: %w", name, err)) | |||
| 78 | + | } | |||
| 79 | + | if restart && p.required[name] { | |||
| 80 | + | p.waiting[name] = false | |||
| 81 | + | return applyDecision{} | |||
| 82 | + | } else { | |||
| 83 | + | delete(p.waiting, name) | |||
| 84 | + | if p.required[name] { | |||
| 85 | + | return p.fail(fmt.Errorf("%s stopped before ready", name)) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | return p.complete() | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (p *applyProgress) scanned(running map[string]<-chan error) applyDecision { | |||
| 92 | + | if !p.active { | |||
| 93 | + | return applyDecision{} | |||
| 94 | + | } | |||
| 95 | + | for name := range p.required { | |||
| 96 | + | instance := running[name] | |||
| 97 | + | if instance == nil { | |||
| 98 | + | return p.fail(fmt.Errorf("%s failed to start", name)) | |||
| 99 | + | } | |||
| 100 | + | if stopping, ok := p.waiting[name]; ok { | |||
| 101 | + | if stopping { | |||
| 102 | + | continue | |||
| 103 | + | } | |||
| 104 | + | delete(p.waiting, name) | |||
| 105 | + | } | |||
| 106 | + | if !p.completed[instance] { | |||
| 107 | + | p.ready[name] = instance | |||
| 108 | + | } else { | |||
| 109 | + | delete(p.ready, name) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return p.complete() | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // scanError fails the active operation even if an old output was already ready. | |||
| 116 | + | func (p *applyProgress) scanError(err error) applyDecision { | |||
| 117 | + | if !p.active || err == nil { | |||
| 118 | + | return applyDecision{} | |||
| 119 | + | } | |||
| 120 | + | return p.fail(err) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (p *applyProgress) timeout(id uint64) applyDecision { | |||
| 124 | + | if !p.active || id != p.id { | |||
| 125 | + | return applyDecision{} | |||
| 126 | + | } | |||
| 127 | + | return p.fail(errTimeout) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (p *applyProgress) fail(err error) applyDecision { | |||
| 131 | + | p.err = errors.Join(p.err, err) | |||
| 132 | + | p.active = false | |||
| 133 | + | return applyDecision{reply: true, err: p.err, rollback: true} | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (p *applyProgress) complete() applyDecision { | |||
| 137 | + | if !p.active || len(p.waiting) != 0 || len(p.ready) != 0 { | |||
| 138 | + | return applyDecision{} | |||
| 139 | + | } | |||
| 140 | + | p.active = false | |||
| 141 | + | return applyDecision{reply: true, err: p.err, rollback: p.err != nil} | |||
| 142 | + | } | |||
A internal/app/pendingapply_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestApplyProgress(t *testing.T) { | |
| 9 | + | boom := errors.New("modeset") | |
| 10 | + | for _, tc := range []struct { | |
| 11 | + | name string | |
| 12 | + | run func(*applyProgress, <-chan error, <-chan error) applyDecision | |
| 13 | + | failure bool | |
| 14 | + | }{ | |
| 15 | + | {"success", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 16 | + | p.start(1, map[string]bool{"A": true}, nil) | |
| 17 | + | p.scanned(map[string]<-chan error{"A": a}) | |
| 18 | + | return p.readyEvent("A", a, nil) | |
| 19 | + | }, false}, | |
| 20 | + | {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 21 | + | p.completed = map[<-chan error]bool{a: true} | |
| 22 | + | p.start(1, map[string]bool{"A": true}, nil) | |
| 23 | + | d := p.scanError(boom) | |
| 24 | + | if later := p.scanned(map[string]<-chan error{"A": a}); later.reply { | |
| 25 | + | t.Fatalf("old ready output completed failed apply: %+v", later) | |
| 26 | + | } | |
| 27 | + | return d | |
| 28 | + | }, true}, | |
| 29 | + | {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 30 | + | p.start(1, map[string]bool{"A": true}, nil) | |
| 31 | + | p.scanned(map[string]<-chan error{"A": a}) | |
| 32 | + | return p.readyEvent("A", a, boom) | |
| 33 | + | }, true}, | |
| 34 | + | {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 35 | + | p.start(1, map[string]bool{"A": true}, nil) | |
| 36 | + | p.scanned(map[string]<-chan error{"A": a}) | |
| 37 | + | return p.timeout(1) | |
| 38 | + | }, true}, | |
| 39 | + | {"restart replacement missing", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 40 | + | p.start(1, map[string]bool{"A": true}, map[string]bool{"A": true}) | |
| 41 | + | p.stopped("A", a, true, nil) | |
| 42 | + | return p.scanned(nil) | |
| 43 | + | }, true}, | |
| 44 | + | {"unchanged not ready", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 45 | + | p.start(1, map[string]bool{"A": true}, nil) | |
| 46 | + | return p.scanned(map[string]<-chan error{"A": a}) | |
| 47 | + | }, false}, | |
| 48 | + | {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision { | |
| 49 | + | p.start(1, map[string]bool{"A": true, "B": true}, nil) | |
| 50 | + | p.scanned(map[string]<-chan error{"A": a, "B": b}) | |
| 51 | + | p.readyEvent("A", a, nil) | |
| 52 | + | return p.readyEvent("B", b, nil) | |
| 53 | + | }, false}, | |
| 54 | + | {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 55 | + | p.start(1, map[string]bool{"A": true}, nil) | |
| 56 | + | p.start(2, map[string]bool{"A": true}, nil) | |
| 57 | + | p.scanned(map[string]<-chan error{"A": a}) | |
| 58 | + | p.timeout(1) | |
| 59 | + | return p.readyEvent("A", a, nil) | |
| 60 | + | }, false}, | |
| 61 | + | {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision { | |
| 62 | + | p.start(1, nil, map[string]bool{"A": false}) | |
| 63 | + | return p.stopped("A", a, false, nil) | |
| 64 | + | }, false}, | |
| 65 | + | } { | |
| 66 | + | t.Run(tc.name, func(t *testing.T) { | |
| 67 | + | p := &applyProgress{} | |
| 68 | + | a, b := make(chan error), make(chan error) | |
| 69 | + | d := tc.run(p, a, b) | |
| 70 | + | if tc.name == "unchanged not ready" { | |
| 71 | + | if d.reply || !p.active { | |
| 72 | + | t.Fatalf("premature: %+v", d) | |
| 73 | + | } | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | if !d.reply || (d.err != nil) != tc.failure || d.rollback != tc.failure { | |
| 77 | + | t.Fatalf("decision: %+v", d) | |
| 78 | + | } | |
| 79 | + | if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) { | |
| 80 | + | t.Fatalf("scan error lost: %v", d.err) | |
| 81 | + | } | |
| 82 | + | }) | |
| 83 | + | } | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestApplyProgress(t *testing.T) { | |||
| 9 | + | boom := errors.New("modeset") | |||
| 10 | + | for _, tc := range []struct { | |||
| 11 | + | name string | |||
| 12 | + | run func(*applyProgress, <-chan error, <-chan error) applyDecision | |||
| 13 | + | failure bool | |||
| 14 | + | }{ | |||
| 15 | + | {"success", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 16 | + | p.start(1, map[string]bool{"A": true}, nil) | |||
| 17 | + | p.scanned(map[string]<-chan error{"A": a}) | |||
| 18 | + | return p.readyEvent("A", a, nil) | |||
| 19 | + | }, false}, | |||
| 20 | + | {"scan error with previously ready output", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 21 | + | p.completed = map[<-chan error]bool{a: true} | |||
| 22 | + | p.start(1, map[string]bool{"A": true}, nil) | |||
| 23 | + | d := p.scanError(boom) | |||
| 24 | + | if later := p.scanned(map[string]<-chan error{"A": a}); later.reply { | |||
| 25 | + | t.Fatalf("old ready output completed failed apply: %+v", later) | |||
| 26 | + | } | |||
| 27 | + | return d | |||
| 28 | + | }, true}, | |||
| 29 | + | {"ready error rollback", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 30 | + | p.start(1, map[string]bool{"A": true}, nil) | |||
| 31 | + | p.scanned(map[string]<-chan error{"A": a}) | |||
| 32 | + | return p.readyEvent("A", a, boom) | |||
| 33 | + | }, true}, | |||
| 34 | + | {"timeout", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 35 | + | p.start(1, map[string]bool{"A": true}, nil) | |||
| 36 | + | p.scanned(map[string]<-chan error{"A": a}) | |||
| 37 | + | return p.timeout(1) | |||
| 38 | + | }, true}, | |||
| 39 | + | {"restart replacement missing", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 40 | + | p.start(1, map[string]bool{"A": true}, map[string]bool{"A": true}) | |||
| 41 | + | p.stopped("A", a, true, nil) | |||
| 42 | + | return p.scanned(nil) | |||
| 43 | + | }, true}, | |||
| 44 | + | {"unchanged not ready", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 45 | + | p.start(1, map[string]bool{"A": true}, nil) | |||
| 46 | + | return p.scanned(map[string]<-chan error{"A": a}) | |||
| 47 | + | }, false}, | |||
| 48 | + | {"hotplug during apply", func(p *applyProgress, a, b <-chan error) applyDecision { | |||
| 49 | + | p.start(1, map[string]bool{"A": true, "B": true}, nil) | |||
| 50 | + | p.scanned(map[string]<-chan error{"A": a, "B": b}) | |||
| 51 | + | p.readyEvent("A", a, nil) | |||
| 52 | + | return p.readyEvent("B", b, nil) | |||
| 53 | + | }, false}, | |||
| 54 | + | {"stale timeout", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 55 | + | p.start(1, map[string]bool{"A": true}, nil) | |||
| 56 | + | p.start(2, map[string]bool{"A": true}, nil) | |||
| 57 | + | p.scanned(map[string]<-chan error{"A": a}) | |||
| 58 | + | p.timeout(1) | |||
| 59 | + | return p.readyEvent("A", a, nil) | |||
| 60 | + | }, false}, | |||
| 61 | + | {"disabled head stop", func(p *applyProgress, a, _ <-chan error) applyDecision { | |||
| 62 | + | p.start(1, nil, map[string]bool{"A": false}) | |||
| 63 | + | return p.stopped("A", a, false, nil) | |||
| 64 | + | }, false}, | |||
| 65 | + | } { | |||
| 66 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 67 | + | p := &applyProgress{} | |||
| 68 | + | a, b := make(chan error), make(chan error) | |||
| 69 | + | d := tc.run(p, a, b) | |||
| 70 | + | if tc.name == "unchanged not ready" { | |||
| 71 | + | if d.reply || !p.active { | |||
| 72 | + | t.Fatalf("premature: %+v", d) | |||
| 73 | + | } | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | if !d.reply || (d.err != nil) != tc.failure || d.rollback != tc.failure { | |||
| 77 | + | t.Fatalf("decision: %+v", d) | |||
| 78 | + | } | |||
| 79 | + | if tc.name == "scan error with previously ready output" && !errors.Is(d.err, boom) { | |||
| 80 | + | t.Fatalf("scan error lost: %v", d.err) | |||
| 81 | + | } | |||
| 82 | + | }) | |||
| 83 | + | } | |||
| 84 | + | } | |||
M internal/core/core.go +1 -0
| @@ -508,6 +508,7 @@ func (c *Core) resolveConstraint() { | |||
| 508 | 508 | } | |
| 509 | 509 | r := c.clientRect(p) | |
| 510 | 510 | r.X += s.x | |
| 511 | + | r.Y += s.y | |
| 511 | 512 | if w := c.constrained.Rect; w.W > 0 && w.H > 0 { | |
| 512 | 513 | // The region is clipped to the window. | |
| 513 | 514 | x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y) | |
| @@ -508,6 +508,7 @@ func (c *Core) resolveConstraint() { | |||||
| 508 | } | 508 | } | ||
| 509 | r := c.clientRect(p) | 509 | r := c.clientRect(p) | ||
| 510 | r.X += s.x | 510 | r.X += s.x | ||
| 511 | + | r.Y += s.y | |||
| 511 | if w := c.constrained.Rect; w.W > 0 && w.H > 0 { | 512 | if w := c.constrained.Rect; w.W > 0 && w.H > 0 { | ||
| 512 | // The region is clipped to the window. | 513 | // The region is clipped to the window. | ||
| 513 | x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y) | 514 | x0, y0 := max(r.X, r.X+w.X), max(r.Y, r.Y+w.Y) | ||
M internal/core/screens.go +24 -4
| @@ -14,7 +14,7 @@ type screen struct { | |||
| 14 | 14 | mon *Monitor | |
| 15 | 15 | scale, cfgScale float64 | |
| 16 | 16 | primary bool | |
| 17 | - | x int // logical left edge in the global layout | |
| 17 | + | x, y int // logical origin in the global layout | |
| 18 | 18 | off bool // turned off by a client (output power management) | |
| 19 | 19 | layers []ports.LayerSurface | |
| 20 | 20 | placed []ports.SceneLayer | |
| @@ -24,7 +24,7 @@ func (s *screen) name() string { return s.info.Name } | |||
| 24 | 24 | ||
| 25 | 25 | func (s *screen) placement() ports.OutputPlacement { | |
| 26 | 26 | o := s.mon.Output() | |
| 27 | - | return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary} | |
| 27 | + | return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary} | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | // setScale resizes the logical output; layer placement follows. | |
| @@ -55,10 +55,30 @@ func (c *Core) order() { | |||
| 55 | 55 | focused := c.cur() | |
| 56 | 56 | slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) }) | |
| 57 | 57 | c.focusScreen = slices.Index(c.screens, focused) | |
| 58 | + | // Explicitly placed outputs reserve their position first; auto-placed | |
| 59 | + | // outputs follow the rightmost explicit edge in configuration order. | |
| 58 | 60 | x := 0 | |
| 59 | 61 | for _, s := range c.screens { | |
| 60 | - | s.x = x | |
| 61 | - | x += s.mon.Output().W | |
| 62 | + | for _, o := range c.cfg.Outputs { | |
| 63 | + | if o.Name == s.name() && o.Pos != nil { | |
| 64 | + | s.x, s.y = o.Pos.X, o.Pos.Y | |
| 65 | + | x = max(x, s.x+s.mon.Output().W) | |
| 66 | + | break | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | for _, s := range c.screens { | |
| 71 | + | explicit := false | |
| 72 | + | for _, o := range c.cfg.Outputs { | |
| 73 | + | if o.Name == s.name() && o.Pos != nil { | |
| 74 | + | explicit = true | |
| 75 | + | break | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | if !explicit { | |
| 79 | + | s.x, s.y = x, 0 | |
| 80 | + | x += s.mon.Output().W | |
| 81 | + | } | |
| 62 | 82 | } | |
| 63 | 83 | } | |
| 64 | 84 | ||
| @@ -14,7 +14,7 @@ type screen struct { | |||||
| 14 | mon *Monitor | 14 | mon *Monitor | ||
| 15 | scale, cfgScale float64 | 15 | scale, cfgScale float64 | ||
| 16 | primary bool | 16 | primary bool | ||
| 17 | - | x int // logical left edge in the global layout | 17 | + | x, y int // logical origin in the global layout |
| 18 | off bool // turned off by a client (output power management) | 18 | off bool // turned off by a client (output power management) | ||
| 19 | layers []ports.LayerSurface | 19 | layers []ports.LayerSurface | ||
| 20 | placed []ports.SceneLayer | 20 | placed []ports.SceneLayer | ||
| @@ -24,7 +24,7 @@ func (s *screen) name() string { return s.info.Name } | |||||
| 24 | 24 | ||||
| 25 | func (s *screen) placement() ports.OutputPlacement { | 25 | func (s *screen) placement() ports.OutputPlacement { | ||
| 26 | o := s.mon.Output() | 26 | o := s.mon.Output() | ||
| 27 | - | return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary} | 27 | + | return ports.OutputPlacement{Info: s.info, X: s.x, Y: s.y, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary} |
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | // setScale resizes the logical output; layer placement follows. | 30 | // setScale resizes the logical output; layer placement follows. | ||
| @@ -55,10 +55,30 @@ func (c *Core) order() { | |||||
| 55 | focused := c.cur() | 55 | focused := c.cur() | ||
| 56 | slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) }) | 56 | slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) }) | ||
| 57 | c.focusScreen = slices.Index(c.screens, focused) | 57 | c.focusScreen = slices.Index(c.screens, focused) | ||
| 58 | + | // Explicitly placed outputs reserve their position first; auto-placed | |||
| 59 | + | // outputs follow the rightmost explicit edge in configuration order. | |||
| 58 | x := 0 | 60 | x := 0 | ||
| 59 | for _, s := range c.screens { | 61 | for _, s := range c.screens { | ||
| 60 | - | s.x = x | 62 | + | for _, o := range c.cfg.Outputs { |
| 61 | - | x += s.mon.Output().W | 63 | + | if o.Name == s.name() && o.Pos != nil { |
| 64 | + | s.x, s.y = o.Pos.X, o.Pos.Y | |||
| 65 | + | x = max(x, s.x+s.mon.Output().W) | |||
| 66 | + | break | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | for _, s := range c.screens { | |||
| 71 | + | explicit := false | |||
| 72 | + | for _, o := range c.cfg.Outputs { | |||
| 73 | + | if o.Name == s.name() && o.Pos != nil { | |||
| 74 | + | explicit = true | |||
| 75 | + | break | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | if !explicit { | |||
| 79 | + | s.x, s.y = x, 0 | |||
| 80 | + | x += s.mon.Output().W | |||
| 81 | + | } | |||
| 62 | } | 82 | } | ||
| 63 | } | 83 | } | ||
| 64 | 84 | ||||
M internal/core/screens_test.go +33 -0
| @@ -2,6 +2,7 @@ package core_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "image" | |
| 5 | 6 | "reflect" | |
| 6 | 7 | "slices" | |
| 7 | 8 | "testing" | |
| @@ -189,6 +190,23 @@ func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | |||
| 189 | 190 | } | |
| 190 | 191 | } | |
| 191 | 192 | ||
| 193 | + | func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) { | |
| 194 | + | r := startMulti(t, func(c *ports.Config) { | |
| 195 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}} | |
| 196 | + | }, left, right) | |
| 197 | + | out := lastOutputs(t, r.commands) | |
| 198 | + | if len(out.Outputs) != 2 || out.Outputs[0].X != 500 || out.Outputs[0].Y != 0 || out.Outputs[1].X != 100 || out.Outputs[1].Y != 40 { | |
| 199 | + | t.Fatalf("unexpected positions: %+v", out.Outputs) | |
| 200 | + | } | |
| 201 | + | r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12} | |
| 202 | + | r.reload <- ports.ConfigChanged{Config: r.cfg} | |
| 203 | + | _ = receive(t, r.scenes) | |
| 204 | + | out = lastOutputs(t, r.commands) | |
| 205 | + | if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 { | |
| 206 | + | t.Fatalf("unexpected positions after reload: %+v", out.Outputs) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 192 | 210 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | |
| 193 | 211 | r := startMulti(t, nil, left, right) | |
| 194 | 212 | r.mapWindow(t, 1) | |
| @@ -665,3 +683,18 @@ func TestExpelOntoFixedFullscreenFloats(t *testing.T) { | |||
| 665 | 683 | t.Fatal(got, focused) | |
| 666 | 684 | } | |
| 667 | 685 | } | |
| 686 | + | ||
| 687 | + | func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) { | |
| 688 | + | r := startMulti(t, func(c *ports.Config) { | |
| 689 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}} | |
| 690 | + | }, left, right) | |
| 691 | + | out := lastOutputs(t, r.commands) | |
| 692 | + | if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" { | |
| 693 | + | t.Fatalf("ordering: %+v", out.Outputs) | |
| 694 | + | } | |
| 695 | + | for _, o := range out.Outputs { | |
| 696 | + | if o.X != 20 || o.Y != -120 { | |
| 697 | + | t.Fatalf("explicit position: %+v", o) | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | } | |
| @@ -2,6 +2,7 @@ package core_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "image" | |||
| 5 | "reflect" | 6 | "reflect" | ||
| 6 | "slices" | 7 | "slices" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -189,6 +190,23 @@ func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | |||||
| 189 | } | 190 | } | ||
| 190 | } | 191 | } | ||
| 191 | 192 | ||||
| 193 | + | func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) { | |||
| 194 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 195 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Pos: &image.Point{X: 100, Y: 40}}} | |||
| 196 | + | }, left, right) | |||
| 197 | + | out := lastOutputs(t, r.commands) | |||
| 198 | + | if len(out.Outputs) != 2 || out.Outputs[0].X != 500 || out.Outputs[0].Y != 0 || out.Outputs[1].X != 100 || out.Outputs[1].Y != 40 { | |||
| 199 | + | t.Fatalf("unexpected positions: %+v", out.Outputs) | |||
| 200 | + | } | |||
| 201 | + | r.cfg.Outputs[1].Pos = &image.Point{X: -200, Y: 12} | |||
| 202 | + | r.reload <- ports.ConfigChanged{Config: r.cfg} | |||
| 203 | + | _ = receive(t, r.scenes) | |||
| 204 | + | out = lastOutputs(t, r.commands) | |||
| 205 | + | if out.Outputs[0].X != 200 || out.Outputs[1].X != -200 || out.Outputs[1].Y != 12 { | |||
| 206 | + | t.Fatalf("unexpected positions after reload: %+v", out.Outputs) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 192 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | 210 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | ||
| 193 | r := startMulti(t, nil, left, right) | 211 | r := startMulti(t, nil, left, right) | ||
| 194 | r.mapWindow(t, 1) | 212 | r.mapWindow(t, 1) | ||
| @@ -665,3 +683,18 @@ func TestExpelOntoFixedFullscreenFloats(t *testing.T) { | |||||
| 665 | t.Fatal(got, focused) | 683 | t.Fatal(got, focused) | ||
| 666 | } | 684 | } | ||
| 667 | } | 685 | } | ||
| 686 | + | ||||
| 687 | + | func TestExplicitOverlappingPositionsAndNegativeY(t *testing.T) { | |||
| 688 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 689 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1", Pos: &image.Point{X: 20, Y: -120}}, {Name: "DP-2", Pos: &image.Point{X: 20, Y: -120}}} | |||
| 690 | + | }, left, right) | |||
| 691 | + | out := lastOutputs(t, r.commands) | |||
| 692 | + | if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-1" || out.Outputs[1].Info.Name != "DP-2" { | |||
| 693 | + | t.Fatalf("ordering: %+v", out.Outputs) | |||
| 694 | + | } | |||
| 695 | + | for _, o := range out.Outputs { | |||
| 696 | + | if o.X != 20 || o.Y != -120 { | |||
| 697 | + | t.Fatalf("explicit position: %+v", o) | |||
| 698 | + | } | |||
| 699 | + | } | |||
| 700 | + | } | |||
M internal/ports/config.go +8 -4
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | + | import "image" | |
| 4 | + | ||
| 3 | 5 | // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits. | |
| 4 | 6 | const DefaultSDRBrightness = 203 | |
| 5 | 7 | ||
| @@ -14,10 +16,12 @@ const DefaultSDRBrightness = 203 | |||
| 14 | 16 | // ScaleOnly marks an entry set only by output.<name> subkeys: it | |
| 15 | 17 | // does not select the connector. | |
| 16 | 18 | type OutputConfig struct { | |
| 17 | - | Name string | |
| 18 | - | Mode string | |
| 19 | - | Off bool | |
| 20 | - | Scale float64 | |
| 19 | + | Name string | |
| 20 | + | Mode string | |
| 21 | + | Off bool | |
| 22 | + | Scale float64 | |
| 23 | + | // Pos is an explicit logical placement set at runtime; nil uses automatic layout. | |
| 24 | + | Pos *image.Point | |
| 21 | 25 | Primary bool | |
| 22 | 26 | HDR bool // opt-in to HDR10 on capable outputs | |
| 23 | 27 | SDRBrightness int // nits; default DefaultSDRBrightness | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | + | import "image" | |||
| 4 | + | ||||
| 3 | // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits. | 5 | // DefaultSDRBrightness is the BT.2408 SDR reference white in HDR, in nits. | ||
| 4 | const DefaultSDRBrightness = 203 | 6 | const DefaultSDRBrightness = 203 | ||
| 5 | 7 | ||||
| @@ -14,10 +16,12 @@ const DefaultSDRBrightness = 203 | |||||
| 14 | // ScaleOnly marks an entry set only by output.<name> subkeys: it | 16 | // ScaleOnly marks an entry set only by output.<name> subkeys: it | ||
| 15 | // does not select the connector. | 17 | // does not select the connector. | ||
| 16 | type OutputConfig struct { | 18 | type OutputConfig struct { | ||
| 17 | - | Name string | 19 | + | Name string |
| 18 | - | Mode string | 20 | + | Mode string |
| 19 | - | Off bool | 21 | + | Off bool |
| 20 | - | Scale float64 | 22 | + | Scale float64 |
| 23 | + | // Pos is an explicit logical placement set at runtime; nil uses automatic layout. | |||
| 24 | + | Pos *image.Point | |||
| 21 | Primary bool | 25 | Primary bool | ||
| 22 | HDR bool // opt-in to HDR10 on capable outputs | 26 | HDR bool // opt-in to HDR10 on capable outputs | ||
| 23 | SDRBrightness int // nits; default DefaultSDRBrightness | 27 | SDRBrightness int // nits; default DefaultSDRBrightness | ||
M internal/ports/messages.go +43 -0
| @@ -358,6 +358,49 @@ func (i OutputInfo) Key() string { | |||
| 358 | 358 | return i.Make + " " + i.Model + " " + i.Serial | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | + | // OutputMode describes a connected connector's physical mode. | |
| 362 | + | type OutputMode struct { | |
| 363 | + | Width, Height, RefreshMilli int | |
| 364 | + | Preferred bool | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // OutputHead describes a connected connector even when it is disabled. | |
| 368 | + | // Current is the active mode; it is nil for disabled or not-yet-running heads. | |
| 369 | + | type OutputHead struct { | |
| 370 | + | Info OutputInfo | |
| 371 | + | Modes []OutputMode | |
| 372 | + | Current *OutputMode | |
| 373 | + | Enabled bool | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // OutputHeads is a complete backend inventory, delivered to Wayland after a scan. | |
| 377 | + | type OutputHeads struct{ Heads []OutputHead } | |
| 378 | + | ||
| 379 | + | // HeadChange is the complete desired state of one head in a configuration. | |
| 380 | + | type HeadChange struct { | |
| 381 | + | Name string | |
| 382 | + | Enabled bool | |
| 383 | + | Mode *OutputMode | |
| 384 | + | CustomMode bool | |
| 385 | + | Pos *image.Point | |
| 386 | + | Scale float64 | |
| 387 | + | Transform int | |
| 388 | + | AdaptiveSync *bool | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // OutputApply asks the app owner to validate or apply a runtime configuration. | |
| 392 | + | type OutputApply struct { | |
| 393 | + | ID uint64 | |
| 394 | + | Test bool | |
| 395 | + | Heads []HeadChange | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // OutputApplied reports completion (or failure) of a runtime configuration. | |
| 399 | + | type OutputApplied struct { | |
| 400 | + | ID uint64 | |
| 401 | + | Err error | |
| 402 | + | } | |
| 403 | + | ||
| 361 | 404 | // OutputAdded carries output → core a new display, or a new mode for a known | |
| 362 | 405 | // connector. | |
| 363 | 406 | type OutputAdded struct{ Info OutputInfo } | |
| @@ -358,6 +358,49 @@ func (i OutputInfo) Key() string { | |||||
| 358 | return i.Make + " " + i.Model + " " + i.Serial | 358 | return i.Make + " " + i.Model + " " + i.Serial | ||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
| 361 | + | // OutputMode describes a connected connector's physical mode. | |||
| 362 | + | type OutputMode struct { | |||
| 363 | + | Width, Height, RefreshMilli int | |||
| 364 | + | Preferred bool | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // OutputHead describes a connected connector even when it is disabled. | |||
| 368 | + | // Current is the active mode; it is nil for disabled or not-yet-running heads. | |||
| 369 | + | type OutputHead struct { | |||
| 370 | + | Info OutputInfo | |||
| 371 | + | Modes []OutputMode | |||
| 372 | + | Current *OutputMode | |||
| 373 | + | Enabled bool | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // OutputHeads is a complete backend inventory, delivered to Wayland after a scan. | |||
| 377 | + | type OutputHeads struct{ Heads []OutputHead } | |||
| 378 | + | ||||
| 379 | + | // HeadChange is the complete desired state of one head in a configuration. | |||
| 380 | + | type HeadChange struct { | |||
| 381 | + | Name string | |||
| 382 | + | Enabled bool | |||
| 383 | + | Mode *OutputMode | |||
| 384 | + | CustomMode bool | |||
| 385 | + | Pos *image.Point | |||
| 386 | + | Scale float64 | |||
| 387 | + | Transform int | |||
| 388 | + | AdaptiveSync *bool | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // OutputApply asks the app owner to validate or apply a runtime configuration. | |||
| 392 | + | type OutputApply struct { | |||
| 393 | + | ID uint64 | |||
| 394 | + | Test bool | |||
| 395 | + | Heads []HeadChange | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // OutputApplied reports completion (or failure) of a runtime configuration. | |||
| 399 | + | type OutputApplied struct { | |||
| 400 | + | ID uint64 | |||
| 401 | + | Err error | |||
| 402 | + | } | |||
| 403 | + | ||||
| 361 | // OutputAdded carries output → core a new display, or a new mode for a known | 404 | // OutputAdded carries output → core a new display, or a new mode for a known | ||
| 362 | // connector. | 405 | // connector. | ||
| 363 | type OutputAdded struct{ Info OutputInfo } | 406 | type OutputAdded struct{ Info OutputInfo } | ||