feat(app): wire hint mode when hint.enable is on
- commit
- d7c466227b11082346a1ebcb76b03e44713cc977
- author
- brice · · 2026-10-19 16:03 UTC
- committer
- brice · · 2026-10-19 17:36 UTC
- parent
- 463ec0a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +311 -9
M .hexcheck.yaml +1 -1
| @@ -25,7 +25,7 @@ allow: | |||
| 25 | 25 | ruleSettings: | |
| 26 | 26 | missing-generated-mock-for-port: | |
| 27 | 27 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | |
| 28 | - | excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go] | |
| 28 | + | excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go, internal/ports/hint_messages.go] | |
| 29 | 29 | suspicious-business-logic-in-adapter: | |
| 30 | 30 | # app is the composition root; starting core and wiring channels is its job. | |
| 31 | 31 | excludePaths: [internal/app/app.go, internal/app/session.go] | |
| @@ -25,7 +25,7 @@ allow: | |||||
| 25 | ruleSettings: | 25 | ruleSettings: | ||
| 26 | missing-generated-mock-for-port: | 26 | missing-generated-mock-for-port: | ||
| 27 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | 27 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | ||
| 28 | - | excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go] | 28 | + | excludePaths: [internal/ports/client_events.go, internal/ports/client_commands.go, internal/ports/input_events.go, internal/ports/output_events.go, internal/ports/lease.go, internal/ports/session_lock_messages.go, internal/ports/screencast_messages.go, internal/ports/session_backend.go, internal/ports/hint_messages.go] |
| 29 | suspicious-business-logic-in-adapter: | 29 | suspicious-business-logic-in-adapter: | ||
| 30 | # app is the composition root; starting core and wiring channels is its job. | 30 | # app is the composition root; starting core and wiring channels is its job. | ||
| 31 | excludePaths: [internal/app/app.go, internal/app/session.go] | 31 | excludePaths: [internal/app/app.go, internal/app/session.go] | ||
M .mockery.yml +19 -0
| @@ -111,6 +111,25 @@ packages: | |||
| 111 | 111 | dir: internal/adapters/statefile | |
| 112 | 112 | pkgname: statefile | |
| 113 | 113 | filename: proc_tree_mock_test.go | |
| 114 | + | github.com/bnema/neferwl/internal/adapters/hints: | |
| 115 | + | interfaces: | |
| 116 | + | targetSource: | |
| 117 | + | config: | |
| 118 | + | dir: internal/adapters/hints | |
| 119 | + | pkgname: hints | |
| 120 | + | filename: target_source_mock_test.go | |
| 121 | + | github.com/bnema/neferwl/internal/adapters/atspi: | |
| 122 | + | interfaces: | |
| 123 | + | transport: | |
| 124 | + | config: | |
| 125 | + | dir: internal/adapters/atspi | |
| 126 | + | pkgname: atspi | |
| 127 | + | filename: transport_mock_test.go | |
| 128 | + | connector: | |
| 129 | + | config: | |
| 130 | + | dir: internal/adapters/atspi | |
| 131 | + | pkgname: atspi | |
| 132 | + | filename: connector_mock_test.go | |
| 114 | 133 | github.com/bnema/neferwl/internal/adapters/screencast: | |
| 115 | 134 | interfaces: | |
| 116 | 135 | bus: | |
| @@ -111,6 +111,25 @@ packages: | |||||
| 111 | dir: internal/adapters/statefile | 111 | dir: internal/adapters/statefile | ||
| 112 | pkgname: statefile | 112 | pkgname: statefile | ||
| 113 | filename: proc_tree_mock_test.go | 113 | filename: proc_tree_mock_test.go | ||
| 114 | + | github.com/bnema/neferwl/internal/adapters/hints: | |||
| 115 | + | interfaces: | |||
| 116 | + | targetSource: | |||
| 117 | + | config: | |||
| 118 | + | dir: internal/adapters/hints | |||
| 119 | + | pkgname: hints | |||
| 120 | + | filename: target_source_mock_test.go | |||
| 121 | + | github.com/bnema/neferwl/internal/adapters/atspi: | |||
| 122 | + | interfaces: | |||
| 123 | + | transport: | |||
| 124 | + | config: | |||
| 125 | + | dir: internal/adapters/atspi | |||
| 126 | + | pkgname: atspi | |||
| 127 | + | filename: transport_mock_test.go | |||
| 128 | + | connector: | |||
| 129 | + | config: | |||
| 130 | + | dir: internal/adapters/atspi | |||
| 131 | + | pkgname: atspi | |||
| 132 | + | filename: connector_mock_test.go | |||
| 114 | github.com/bnema/neferwl/internal/adapters/screencast: | 133 | github.com/bnema/neferwl/internal/adapters/screencast: | ||
| 115 | interfaces: | 134 | interfaces: | ||
| 116 | bus: | 135 | bus: | ||
M Makefile +1 -1
| @@ -61,7 +61,7 @@ arch: adapter-imports | |||
| 61 | 61 | # Adapters stay independent: production code may import only the shared | |
| 62 | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | |
| 63 | 63 | # headless) run; the others are leaf helpers. | |
| 64 | - | ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb | |
| 64 | + | ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb | |
| 65 | 65 | adapter-imports: | |
| 66 | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | |
| 67 | 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | |
| @@ -61,7 +61,7 @@ arch: adapter-imports | |||||
| 61 | # Adapters stay independent: production code may import only the shared | 61 | # Adapters stay independent: production code may import only the shared | ||
| 62 | # adapter libraries below. outputkit holds what both output backends (drm, | 62 | # adapter libraries below. outputkit holds what both output backends (drm, | ||
| 63 | # headless) run; the others are leaf helpers. | 63 | # headless) run; the others are leaf helpers. | ||
| 64 | - | ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb | 64 | + | ADAPTER_LIBS := fdwait|busretry|hintlabel|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb |
| 65 | adapter-imports: | 65 | adapter-imports: | ||
| 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | 66 | @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \ | ||
| 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | 67 | bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \ | ||
M internal/app/app.go +7 -0
| @@ -84,6 +84,7 @@ const busRetry = 2 * time.Second | |||
| 84 | 84 | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |
| 85 | 85 | kb := cur.Keyboard | |
| 86 | 86 | devCfg := cur.InputDevicesConfig | |
| 87 | + | hintOn := cur.Hint.Enable | |
| 87 | 88 | for { | |
| 88 | 89 | var ev ports.ConfigChanged | |
| 89 | 90 | select { | |
| @@ -91,6 +92,12 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 91 | 92 | return | |
| 92 | 93 | case ev = <-in: | |
| 93 | 94 | } | |
| 95 | + | // hint.enable is read at startup: nothing of hint mode starts or | |
| 96 | + | // stops on a reload. | |
| 97 | + | if ev.Config.Hint.Enable != hintOn { | |
| 98 | + | log.Info().Bool("running", hintOn).Msg("hint.enable changes apply after restart") | |
| 99 | + | ev.Config.Hint.Enable = hintOn | |
| 100 | + | } | |
| 94 | 101 | next := ev.Config.Keyboard | |
| 95 | 102 | layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options | |
| 96 | 103 | repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay | |
| @@ -84,6 +84,7 @@ const busRetry = 2 * time.Second | |||||
| 84 | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | 84 | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, deviceConfigs chan ports.InputDevicesConfig, curs *cursors, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | ||
| 85 | kb := cur.Keyboard | 85 | kb := cur.Keyboard | ||
| 86 | devCfg := cur.InputDevicesConfig | 86 | devCfg := cur.InputDevicesConfig | ||
| 87 | + | hintOn := cur.Hint.Enable | |||
| 87 | for { | 88 | for { | ||
| 88 | var ev ports.ConfigChanged | 89 | var ev ports.ConfigChanged | ||
| 89 | select { | 90 | select { | ||
| @@ -91,6 +92,12 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||||
| 91 | return | 92 | return | ||
| 92 | case ev = <-in: | 93 | case ev = <-in: | ||
| 93 | } | 94 | } | ||
| 95 | + | // hint.enable is read at startup: nothing of hint mode starts or | |||
| 96 | + | // stops on a reload. | |||
| 97 | + | if ev.Config.Hint.Enable != hintOn { | |||
| 98 | + | log.Info().Bool("running", hintOn).Msg("hint.enable changes apply after restart") | |||
| 99 | + | ev.Config.Hint.Enable = hintOn | |||
| 100 | + | } | |||
| 94 | next := ev.Config.Keyboard | 101 | next := ev.Config.Keyboard | ||
| 95 | layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options | 102 | layoutChanged := next.Layout != kb.Layout || next.Variant != kb.Variant || next.Options != kb.Options | ||
| 96 | repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay | 103 | repeatChanged := next.RepeatRate != kb.RepeatRate || next.RepeatDelay != kb.RepeatDelay | ||
A internal/app/captured.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // routeCaptured demultiplexes capture replies from the outputs: replies whose | |
| 11 | + | // ID carries ports.CaptureIDInternal go to internal, all others to wayland. | |
| 12 | + | // Both sends give way to ctx. A nil internal means no internal consumer is | |
| 13 | + | // wired: internal replies are dropped (logged at debug) instead of blocking | |
| 14 | + | // forever, since nothing would ever receive them. It returns when ctx is done | |
| 15 | + | // or in is closed. | |
| 16 | + | func routeCaptured(ctx context.Context, in <-chan ports.CaptureDone, wayland, internal chan<- ports.CaptureDone) { | |
| 17 | + | log := logging.For(ctx, "app") | |
| 18 | + | for { | |
| 19 | + | select { | |
| 20 | + | case <-ctx.Done(): | |
| 21 | + | return | |
| 22 | + | case done, ok := <-in: | |
| 23 | + | if !ok { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | dst := wayland | |
| 27 | + | if done.ID&ports.CaptureIDInternal != 0 { | |
| 28 | + | if internal == nil { | |
| 29 | + | log.Debug().Uint64("id", done.ID).Msg("internal capture reply dropped: no consumer") | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | dst = internal | |
| 33 | + | } | |
| 34 | + | select { | |
| 35 | + | case dst <- done: | |
| 36 | + | case <-ctx.Done(): | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // routeCaptured demultiplexes capture replies from the outputs: replies whose | |||
| 11 | + | // ID carries ports.CaptureIDInternal go to internal, all others to wayland. | |||
| 12 | + | // Both sends give way to ctx. A nil internal means no internal consumer is | |||
| 13 | + | // wired: internal replies are dropped (logged at debug) instead of blocking | |||
| 14 | + | // forever, since nothing would ever receive them. It returns when ctx is done | |||
| 15 | + | // or in is closed. | |||
| 16 | + | func routeCaptured(ctx context.Context, in <-chan ports.CaptureDone, wayland, internal chan<- ports.CaptureDone) { | |||
| 17 | + | log := logging.For(ctx, "app") | |||
| 18 | + | for { | |||
| 19 | + | select { | |||
| 20 | + | case <-ctx.Done(): | |||
| 21 | + | return | |||
| 22 | + | case done, ok := <-in: | |||
| 23 | + | if !ok { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | dst := wayland | |||
| 27 | + | if done.ID&ports.CaptureIDInternal != 0 { | |||
| 28 | + | if internal == nil { | |||
| 29 | + | log.Debug().Uint64("id", done.ID).Msg("internal capture reply dropped: no consumer") | |||
| 30 | + | continue | |||
| 31 | + | } | |||
| 32 | + | dst = internal | |||
| 33 | + | } | |||
| 34 | + | select { | |||
| 35 | + | case dst <- done: | |||
| 36 | + | case <-ctx.Done(): | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
A internal/app/captured_test.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCapturedDemux(t *testing.T) { | |
| 14 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 15 | + | defer cancel() | |
| 16 | + | in := make(chan ports.CaptureDone, 4) | |
| 17 | + | wl := make(chan ports.CaptureDone, 4) | |
| 18 | + | internal := make(chan ports.CaptureDone, 4) | |
| 19 | + | exited := make(chan struct{}) | |
| 20 | + | go func() { routeCaptured(ctx, in, wl, internal); close(exited) }() | |
| 21 | + | ||
| 22 | + | in <- ports.CaptureDone{ID: 7} | |
| 23 | + | in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 3} | |
| 24 | + | require.Equal(t, uint64(7), recvDone(t, wl).ID) | |
| 25 | + | require.Equal(t, ports.CaptureIDInternal|3, recvDone(t, internal).ID) | |
| 26 | + | require.Empty(t, wl) | |
| 27 | + | require.Empty(t, internal) | |
| 28 | + | ||
| 29 | + | cancel() | |
| 30 | + | select { | |
| 31 | + | case <-exited: | |
| 32 | + | case <-time.After(2 * time.Second): | |
| 33 | + | t.Fatal("routeCaptured did not return on cancel") | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestCapturedDemuxCancelWhileBlocked(t *testing.T) { | |
| 38 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 39 | + | in := make(chan ports.CaptureDone, 1) | |
| 40 | + | wl := make(chan ports.CaptureDone) // never read | |
| 41 | + | exited := make(chan struct{}) | |
| 42 | + | go func() { routeCaptured(ctx, in, wl, nil); close(exited) }() | |
| 43 | + | in <- ports.CaptureDone{ID: 1} | |
| 44 | + | cancel() | |
| 45 | + | select { | |
| 46 | + | case <-exited: | |
| 47 | + | case <-time.After(2 * time.Second): | |
| 48 | + | t.Fatal("routeCaptured blocked on a full destination after cancel") | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestCapturedDemuxDropsInternalWithoutConsumer(t *testing.T) { | |
| 53 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | + | defer cancel() | |
| 55 | + | in := make(chan ports.CaptureDone, 2) | |
| 56 | + | wl := make(chan ports.CaptureDone, 2) | |
| 57 | + | go routeCaptured(ctx, in, wl, nil) | |
| 58 | + | in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 1} | |
| 59 | + | in <- ports.CaptureDone{ID: 2} | |
| 60 | + | require.Equal(t, uint64(2), recvDone(t, wl).ID) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func recvDone(t *testing.T, ch <-chan ports.CaptureDone) ports.CaptureDone { | |
| 64 | + | t.Helper() | |
| 65 | + | select { | |
| 66 | + | case d := <-ch: | |
| 67 | + | return d | |
| 68 | + | case <-time.After(2 * time.Second): | |
| 69 | + | t.Fatal("no capture reply") | |
| 70 | + | return ports.CaptureDone{} | |
| 71 | + | } | |
| 72 | + | } | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCapturedDemux(t *testing.T) { | |||
| 14 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 15 | + | defer cancel() | |||
| 16 | + | in := make(chan ports.CaptureDone, 4) | |||
| 17 | + | wl := make(chan ports.CaptureDone, 4) | |||
| 18 | + | internal := make(chan ports.CaptureDone, 4) | |||
| 19 | + | exited := make(chan struct{}) | |||
| 20 | + | go func() { routeCaptured(ctx, in, wl, internal); close(exited) }() | |||
| 21 | + | ||||
| 22 | + | in <- ports.CaptureDone{ID: 7} | |||
| 23 | + | in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 3} | |||
| 24 | + | require.Equal(t, uint64(7), recvDone(t, wl).ID) | |||
| 25 | + | require.Equal(t, ports.CaptureIDInternal|3, recvDone(t, internal).ID) | |||
| 26 | + | require.Empty(t, wl) | |||
| 27 | + | require.Empty(t, internal) | |||
| 28 | + | ||||
| 29 | + | cancel() | |||
| 30 | + | select { | |||
| 31 | + | case <-exited: | |||
| 32 | + | case <-time.After(2 * time.Second): | |||
| 33 | + | t.Fatal("routeCaptured did not return on cancel") | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestCapturedDemuxCancelWhileBlocked(t *testing.T) { | |||
| 38 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 39 | + | in := make(chan ports.CaptureDone, 1) | |||
| 40 | + | wl := make(chan ports.CaptureDone) // never read | |||
| 41 | + | exited := make(chan struct{}) | |||
| 42 | + | go func() { routeCaptured(ctx, in, wl, nil); close(exited) }() | |||
| 43 | + | in <- ports.CaptureDone{ID: 1} | |||
| 44 | + | cancel() | |||
| 45 | + | select { | |||
| 46 | + | case <-exited: | |||
| 47 | + | case <-time.After(2 * time.Second): | |||
| 48 | + | t.Fatal("routeCaptured blocked on a full destination after cancel") | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestCapturedDemuxDropsInternalWithoutConsumer(t *testing.T) { | |||
| 53 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 54 | + | defer cancel() | |||
| 55 | + | in := make(chan ports.CaptureDone, 2) | |||
| 56 | + | wl := make(chan ports.CaptureDone, 2) | |||
| 57 | + | go routeCaptured(ctx, in, wl, nil) | |||
| 58 | + | in <- ports.CaptureDone{ID: ports.CaptureIDInternal | 1} | |||
| 59 | + | in <- ports.CaptureDone{ID: 2} | |||
| 60 | + | require.Equal(t, uint64(2), recvDone(t, wl).ID) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func recvDone(t *testing.T, ch <-chan ports.CaptureDone) ports.CaptureDone { | |||
| 64 | + | t.Helper() | |||
| 65 | + | select { | |||
| 66 | + | case d := <-ch: | |||
| 67 | + | return d | |||
| 68 | + | case <-time.After(2 * time.Second): | |||
| 69 | + | t.Fatal("no capture reply") | |||
| 70 | + | return ports.CaptureDone{} | |||
| 71 | + | } | |||
| 72 | + | } | |||
A internal/app/hintdebug.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/atspi" | |
| 9 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |
| 10 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // hintOptions builds the hint worker options: the accessibility source, and, | |
| 14 | + | // when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames, | |
| 15 | + | // $XDG_STATE_HOME/neferwl/hints. The accessibility bus is only contacted | |
| 16 | + | // by the first scan; without it the worker reads windows with OCR only. | |
| 17 | + | func hintOptions(ctx context.Context) hints.Options { | |
| 18 | + | opts := hintDump(ctx) | |
| 19 | + | // The source connects on its first scan, and again after a failure. The | |
| 20 | + | // hints worker owns it and closes it when it returns. | |
| 21 | + | src := atspi.New() | |
| 22 | + | opts.Source = src | |
| 23 | + | return opts | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // hintDump reads NEFERWL_HINT_DEBUG once. | |
| 27 | + | func hintDump(ctx context.Context) hints.Options { | |
| 28 | + | if os.Getenv("NEFERWL_HINT_DEBUG") != "1" { | |
| 29 | + | return hints.Options{} | |
| 30 | + | } | |
| 31 | + | log := logging.For(ctx, "hints") | |
| 32 | + | state := os.Getenv("XDG_STATE_HOME") | |
| 33 | + | if state == "" { | |
| 34 | + | home, err := os.UserHomeDir() | |
| 35 | + | if err != nil { | |
| 36 | + | log.Warn().Err(err).Msg("hint debug dumps disabled") | |
| 37 | + | return hints.Options{} | |
| 38 | + | } | |
| 39 | + | state = filepath.Join(home, ".local", "state") | |
| 40 | + | } | |
| 41 | + | dir := filepath.Join(state, "neferwl", "hints") | |
| 42 | + | log.Warn().Str("dir", dir).Msg("hint debug dumps enabled") | |
| 43 | + | return hints.Options{DumpDir: dir} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // hintDisabledNote returns the once-only notice for a hint bind pressed while | |
| 47 | + | // hint mode is off. The logger is made now; nothing else of hint mode exists. | |
| 48 | + | func hintDisabledNote(ctx context.Context) func() { | |
| 49 | + | log := logging.For(ctx, "core") | |
| 50 | + | return func() { log.Info().Msg("hint mode is disabled; set hint.enable = on and restart") } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/atspi" | |||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // hintOptions builds the hint worker options: the accessibility source, and, | |||
| 14 | + | // when NEFERWL_HINT_DEBUG is "1", the dump directory of scanned frames, | |||
| 15 | + | // $XDG_STATE_HOME/neferwl/hints. The accessibility bus is only contacted | |||
| 16 | + | // by the first scan; without it the worker reads windows with OCR only. | |||
| 17 | + | func hintOptions(ctx context.Context) hints.Options { | |||
| 18 | + | opts := hintDump(ctx) | |||
| 19 | + | // The source connects on its first scan, and again after a failure. The | |||
| 20 | + | // hints worker owns it and closes it when it returns. | |||
| 21 | + | src := atspi.New() | |||
| 22 | + | opts.Source = src | |||
| 23 | + | return opts | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // hintDump reads NEFERWL_HINT_DEBUG once. | |||
| 27 | + | func hintDump(ctx context.Context) hints.Options { | |||
| 28 | + | if os.Getenv("NEFERWL_HINT_DEBUG") != "1" { | |||
| 29 | + | return hints.Options{} | |||
| 30 | + | } | |||
| 31 | + | log := logging.For(ctx, "hints") | |||
| 32 | + | state := os.Getenv("XDG_STATE_HOME") | |||
| 33 | + | if state == "" { | |||
| 34 | + | home, err := os.UserHomeDir() | |||
| 35 | + | if err != nil { | |||
| 36 | + | log.Warn().Err(err).Msg("hint debug dumps disabled") | |||
| 37 | + | return hints.Options{} | |||
| 38 | + | } | |||
| 39 | + | state = filepath.Join(home, ".local", "state") | |||
| 40 | + | } | |||
| 41 | + | dir := filepath.Join(state, "neferwl", "hints") | |||
| 42 | + | log.Warn().Str("dir", dir).Msg("hint debug dumps enabled") | |||
| 43 | + | return hints.Options{DumpDir: dir} | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // hintDisabledNote returns the once-only notice for a hint bind pressed while | |||
| 47 | + | // hint mode is off. The logger is made now; nothing else of hint mode exists. | |||
| 48 | + | func hintDisabledNote(ctx context.Context) func() { | |||
| 49 | + | log := logging.For(ctx, "core") | |||
| 50 | + | return func() { log.Info().Msg("hint mode is disabled; set hint.enable = on and restart") } | |||
| 51 | + | } | |||
A internal/app/hintoptin_test.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "runtime" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/clock" | |
| 12 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 13 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 14 | + | "github.com/bnema/neferwl/internal/adapters/xkb" | |
| 15 | + | "github.com/bnema/neferwl/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestHintPipesOffByDefault(t *testing.T) { | |
| 19 | + | require.False(t, config.Defaults().Hint.Enable) | |
| 20 | + | off := newPipes(false, false) | |
| 21 | + | require.Nil(t, off.hintScans) | |
| 22 | + | require.Nil(t, off.hintResults) | |
| 23 | + | require.Nil(t, off.hintCaptured) | |
| 24 | + | // No demultiplexing: the outputs reply on the wayland channel itself. | |
| 25 | + | require.Equal(t, off.captured, off.capturedAll) | |
| 26 | + | ch := off.core() | |
| 27 | + | require.Nil(t, ch.HintScans) | |
| 28 | + | require.Nil(t, ch.Hints) | |
| 29 | + | on := newPipes(false, true) | |
| 30 | + | require.NotNil(t, on.hintScans) | |
| 31 | + | require.NotNil(t, on.hintResults) | |
| 32 | + | require.NotNil(t, on.hintCaptured) | |
| 33 | + | require.NotEqual(t, on.captured, on.capturedAll) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestHintOffStartsNoHintGoroutine(t *testing.T) { | |
| 37 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 38 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 39 | + | defer cancel() | |
| 40 | + | done := make(chan error, 1) | |
| 41 | + | go func() { | |
| 42 | + | done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Timeout: 800 * time.Millisecond}) | |
| 43 | + | }() | |
| 44 | + | time.Sleep(400 * time.Millisecond) | |
| 45 | + | buf := make([]byte, 1<<20) | |
| 46 | + | dump := string(buf[:runtime.Stack(buf, true)]) | |
| 47 | + | require.NotContains(t, dump, "adapters/hints.") | |
| 48 | + | require.NotContains(t, dump, "adapters/atspi.") | |
| 49 | + | require.NotContains(t, dump, "routeCaptured") | |
| 50 | + | select { | |
| 51 | + | case <-done: | |
| 52 | + | case <-time.After(3 * time.Second): | |
| 53 | + | t.Fatal("session did not stop") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRelayConfigKeepsStartupHintEnable(t *testing.T) { | |
| 58 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 59 | + | defer cancel() | |
| 60 | + | cur := config.Defaults() | |
| 61 | + | in := make(chan ports.ConfigChanged, 1) | |
| 62 | + | out := make(chan ports.ConfigChanged, 1) | |
| 63 | + | go relayConfig(ctx, cur, in, out, make(chan *xkb.Keymap, 1), make(chan ports.InputDevicesConfig, 1), newCursors(clock.System{}, 0), make(chan ports.ClientCommand, 1), logging.For(ctx, "config")) | |
| 64 | + | next := config.Defaults() | |
| 65 | + | next.Hint.Enable = true | |
| 66 | + | in <- ports.ConfigChanged{Config: next} | |
| 67 | + | require.False(t, (<-out).Config.Hint.Enable) | |
| 68 | + | } | |
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "runtime" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/clock" | |||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 13 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 14 | + | "github.com/bnema/neferwl/internal/adapters/xkb" | |||
| 15 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestHintPipesOffByDefault(t *testing.T) { | |||
| 19 | + | require.False(t, config.Defaults().Hint.Enable) | |||
| 20 | + | off := newPipes(false, false) | |||
| 21 | + | require.Nil(t, off.hintScans) | |||
| 22 | + | require.Nil(t, off.hintResults) | |||
| 23 | + | require.Nil(t, off.hintCaptured) | |||
| 24 | + | // No demultiplexing: the outputs reply on the wayland channel itself. | |||
| 25 | + | require.Equal(t, off.captured, off.capturedAll) | |||
| 26 | + | ch := off.core() | |||
| 27 | + | require.Nil(t, ch.HintScans) | |||
| 28 | + | require.Nil(t, ch.Hints) | |||
| 29 | + | on := newPipes(false, true) | |||
| 30 | + | require.NotNil(t, on.hintScans) | |||
| 31 | + | require.NotNil(t, on.hintResults) | |||
| 32 | + | require.NotNil(t, on.hintCaptured) | |||
| 33 | + | require.NotEqual(t, on.captured, on.capturedAll) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestHintOffStartsNoHintGoroutine(t *testing.T) { | |||
| 37 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 38 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 39 | + | defer cancel() | |||
| 40 | + | done := make(chan error, 1) | |||
| 41 | + | go func() { | |||
| 42 | + | done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Timeout: 800 * time.Millisecond}) | |||
| 43 | + | }() | |||
| 44 | + | time.Sleep(400 * time.Millisecond) | |||
| 45 | + | buf := make([]byte, 1<<20) | |||
| 46 | + | dump := string(buf[:runtime.Stack(buf, true)]) | |||
| 47 | + | require.NotContains(t, dump, "adapters/hints.") | |||
| 48 | + | require.NotContains(t, dump, "adapters/atspi.") | |||
| 49 | + | require.NotContains(t, dump, "routeCaptured") | |||
| 50 | + | select { | |||
| 51 | + | case <-done: | |||
| 52 | + | case <-time.After(3 * time.Second): | |||
| 53 | + | t.Fatal("session did not stop") | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestRelayConfigKeepsStartupHintEnable(t *testing.T) { | |||
| 58 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 59 | + | defer cancel() | |||
| 60 | + | cur := config.Defaults() | |||
| 61 | + | in := make(chan ports.ConfigChanged, 1) | |||
| 62 | + | out := make(chan ports.ConfigChanged, 1) | |||
| 63 | + | go relayConfig(ctx, cur, in, out, make(chan *xkb.Keymap, 1), make(chan ports.InputDevicesConfig, 1), newCursors(clock.System{}, 0), make(chan ports.ClientCommand, 1), logging.For(ctx, "config")) | |||
| 64 | + | next := config.Defaults() | |||
| 65 | + | next.Hint.Enable = true | |||
| 66 | + | in <- ports.ConfigChanged{Config: next} | |||
| 67 | + | require.False(t, (<-out).Config.Hint.Enable) | |||
| 68 | + | } | |||
M internal/app/session.go +17 -2
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/neferwl/internal/adapters/config" | |
| 15 | 15 | "github.com/bnema/neferwl/internal/adapters/drm" | |
| 16 | 16 | "github.com/bnema/neferwl/internal/adapters/headlessinput" | |
| 17 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |
| 17 | 18 | "github.com/bnema/neferwl/internal/adapters/launcher" | |
| 18 | 19 | "github.com/bnema/neferwl/internal/adapters/libinput" | |
| 19 | 20 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| @@ -51,6 +52,7 @@ type session struct { | |||
| 51 | 52 | km *xkb.Keymap | |
| 52 | 53 | core *core.Core | |
| 53 | 54 | dmabuf ports.DMABufSupport | |
| 55 | + | hintOpts hints.Options | |
| 54 | 56 | wsIDs *workspaceid.IDs | |
| 55 | 57 | captureAllow *captureallow.Store | |
| 56 | 58 | cast *screencast.Service // nil without the D-Bus services | |
| @@ -90,16 +92,20 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 90 | 92 | return s, err | |
| 91 | 93 | } | |
| 92 | 94 | } | |
| 93 | - | s.p = newPipes(s.hw != nil) | |
| 95 | + | s.p = newPipes(s.hw != nil, opts.Config.Hint.Enable) | |
| 94 | 96 | // core() carries channels only; the gate, clock and terminal flag are | |
| 95 | 97 | // explicit core.Options. | |
| 96 | - | if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil { | |
| 98 | + | if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal, HintDisabled: hintDisabledNote(ctx)}); err != nil { | |
| 97 | 99 | return s, err | |
| 98 | 100 | } | |
| 99 | 101 | if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil { | |
| 100 | 102 | return s, err | |
| 101 | 103 | } | |
| 102 | 104 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | |
| 105 | + | // Hint mode off: no accessibility connection, no debug env, nothing. | |
| 106 | + | if opts.Config.Hint.Enable { | |
| 107 | + | s.hintOpts = hintOptions(ctx) | |
| 108 | + | } | |
| 103 | 109 | // Every output renders on the same GPU: its formats are the clients'. | |
| 104 | 110 | s.dmabuf = vulkan.Probe() | |
| 105 | 111 | log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf") | |
| @@ -340,6 +346,15 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||
| 340 | 346 | log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes") | |
| 341 | 347 | } | |
| 342 | 348 | }() | |
| 349 | + | if p.hintScans != nil { | |
| 350 | + | workers.Add(2) | |
| 351 | + | go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }() | |
| 352 | + | go func() { | |
| 353 | + | defer workers.Done() | |
| 354 | + | // Run only returns when ctx is done: nothing to report. | |
| 355 | + | _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts) | |
| 356 | + | }() | |
| 357 | + | } | |
| 343 | 358 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | |
| 344 | 359 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 345 | 360 | workers.Add(1) | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/bnema/neferwl/internal/adapters/config" | 14 | "github.com/bnema/neferwl/internal/adapters/config" | ||
| 15 | "github.com/bnema/neferwl/internal/adapters/drm" | 15 | "github.com/bnema/neferwl/internal/adapters/drm" | ||
| 16 | "github.com/bnema/neferwl/internal/adapters/headlessinput" | 16 | "github.com/bnema/neferwl/internal/adapters/headlessinput" | ||
| 17 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |||
| 17 | "github.com/bnema/neferwl/internal/adapters/launcher" | 18 | "github.com/bnema/neferwl/internal/adapters/launcher" | ||
| 18 | "github.com/bnema/neferwl/internal/adapters/libinput" | 19 | "github.com/bnema/neferwl/internal/adapters/libinput" | ||
| 19 | "github.com/bnema/neferwl/internal/adapters/logging" | 20 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| @@ -51,6 +52,7 @@ type session struct { | |||||
| 51 | km *xkb.Keymap | 52 | km *xkb.Keymap | ||
| 52 | core *core.Core | 53 | core *core.Core | ||
| 53 | dmabuf ports.DMABufSupport | 54 | dmabuf ports.DMABufSupport | ||
| 55 | + | hintOpts hints.Options | |||
| 54 | wsIDs *workspaceid.IDs | 56 | wsIDs *workspaceid.IDs | ||
| 55 | captureAllow *captureallow.Store | 57 | captureAllow *captureallow.Store | ||
| 56 | cast *screencast.Service // nil without the D-Bus services | 58 | cast *screencast.Service // nil without the D-Bus services | ||
| @@ -90,16 +92,20 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 90 | return s, err | 92 | return s, err | ||
| 91 | } | 93 | } | ||
| 92 | } | 94 | } | ||
| 93 | - | s.p = newPipes(s.hw != nil) | 95 | + | s.p = newPipes(s.hw != nil, opts.Config.Hint.Enable) |
| 94 | // core() carries channels only; the gate, clock and terminal flag are | 96 | // core() carries channels only; the gate, clock and terminal flag are | ||
| 95 | // explicit core.Options. | 97 | // explicit core.Options. | ||
| 96 | - | if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil { | 98 | + | if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal, HintDisabled: hintDisabledNote(ctx)}); err != nil { |
| 97 | return s, err | 99 | return s, err | ||
| 98 | } | 100 | } | ||
| 99 | if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil { | 101 | if s.km, err = xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}); err != nil { | ||
| 100 | return s, err | 102 | return s, err | ||
| 101 | } | 103 | } | ||
| 102 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | 104 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | ||
| 105 | + | // Hint mode off: no accessibility connection, no debug env, nothing. | |||
| 106 | + | if opts.Config.Hint.Enable { | |||
| 107 | + | s.hintOpts = hintOptions(ctx) | |||
| 108 | + | } | |||
| 103 | // Every output renders on the same GPU: its formats are the clients'. | 109 | // Every output renders on the same GPU: its formats are the clients'. | ||
| 104 | s.dmabuf = vulkan.Probe() | 110 | s.dmabuf = vulkan.Probe() | ||
| 105 | log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf") | 111 | log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf") | ||
| @@ -340,6 +346,15 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||||
| 340 | log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes") | 346 | log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes") | ||
| 341 | } | 347 | } | ||
| 342 | }() | 348 | }() | ||
| 349 | + | if p.hintScans != nil { | |||
| 350 | + | workers.Add(2) | |||
| 351 | + | go func() { defer workers.Done(); routeCaptured(ctx, p.capturedAll, p.captured, p.internalCaptured()) }() | |||
| 352 | + | go func() { | |||
| 353 | + | defer workers.Done() | |||
| 354 | + | // Run only returns when ctx is done: nothing to report. | |||
| 355 | + | _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts) | |||
| 356 | + | }() | |||
| 357 | + | } | |||
| 343 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | 358 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | ||
| 344 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | 359 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | ||
| 345 | workers.Add(1) | 360 | workers.Add(1) | ||
M internal/app/wiring.go +34 -5
| @@ -46,10 +46,21 @@ type pipes struct { | |||
| 46 | 46 | // move running slides. | |
| 47 | 47 | frames chan ports.OutputFrame | |
| 48 | 48 | ||
| 49 | + | // Hint mode: core asks the hints adapter for a scan and gets the result. | |
| 50 | + | hintScans chan ports.HintScanRequest | |
| 51 | + | hintResults chan ports.HintScanResult | |
| 52 | + | ||
| 49 | 53 | captures chan ports.CaptureRequest | |
| 50 | - | // Every admitted capture owns one reply slot until wayland consumes it. | |
| 51 | - | // Keep this capacity tied to admission so output routing never waits. | |
| 52 | - | captured chan ports.CaptureDone | |
| 54 | + | // Every admitted capture owns one reply slot until routeCaptured takes | |
| 55 | + | // it. Keep this capacity tied to admission so output routing never waits. | |
| 56 | + | // With hint mode on, the outputs reply on capturedAll and routeCaptured splits the replies by | |
| 57 | + | // ports.CaptureIDInternal into captured (the wayland consumer) and | |
| 58 | + | // hintCaptured (internal consumers). With hint mode off, capturedAll is | |
| 59 | + | // captured itself and there is no routeCaptured. | |
| 60 | + | capturedAll chan ports.CaptureDone | |
| 61 | + | captured chan ports.CaptureDone | |
| 62 | + | // hintCaptured carries internal capture replies to the hints adapter. | |
| 63 | + | hintCaptured chan ports.CaptureDone | |
| 53 | 64 | ||
| 54 | 65 | outputFormats chan ports.OutputFormats | |
| 55 | 66 | outputHeads chan ports.OutputHeads | |
| @@ -69,7 +80,10 @@ type pipes struct { | |||
| 69 | 80 | castCommands chan ports.CastCommand | |
| 70 | 81 | } | |
| 71 | 82 | ||
| 72 | - | func newPipes(realSession bool) *pipes { | |
| 83 | + | // newPipes makes the channels. The hint channels exist only with hint set | |
| 84 | + | // (hint.enable = on): without them core's hint action is a no-op and nothing | |
| 85 | + | // of hint mode is allocated. | |
| 86 | + | func newPipes(realSession, hint bool) *pipes { | |
| 73 | 87 | p := &pipes{ | |
| 74 | 88 | securityChanges: make(chan ports.SecurityState, 8), | |
| 75 | 89 | securityEvents: make(chan ports.SecurityBackendEvent, 64), | |
| @@ -112,6 +126,15 @@ func newPipes(realSession bool) *pipes { | |||
| 112 | 126 | ||
| 113 | 127 | castCommands: make(chan ports.CastCommand, 64), | |
| 114 | 128 | } | |
| 129 | + | // Without hint mode every reply is for the wayland consumer: the outputs | |
| 130 | + | // reply on its channel directly and nothing demultiplexes. | |
| 131 | + | p.capturedAll = p.captured | |
| 132 | + | if hint { | |
| 133 | + | p.capturedAll = make(chan ports.CaptureDone, ports.MaxCaptureInflight) | |
| 134 | + | p.hintScans = make(chan ports.HintScanRequest, 1) | |
| 135 | + | p.hintResults = make(chan ports.HintScanResult, 1) | |
| 136 | + | p.hintCaptured = make(chan ports.CaptureDone, 1) | |
| 137 | + | } | |
| 115 | 138 | if realSession { | |
| 116 | 139 | p.scales = make(chan ports.ScaleChanged, 8) | |
| 117 | 140 | } | |
| @@ -136,9 +159,15 @@ func (p *pipes) core() core.Channels { | |||
| 136 | 159 | ConfigErrors: p.configErrors, | |
| 137 | 160 | Scales: p.scales, | |
| 138 | 161 | Frames: p.frames, | |
| 162 | + | HintScans: p.hintScans, | |
| 163 | + | Hints: p.hintResults, | |
| 139 | 164 | } | |
| 140 | 165 | } | |
| 141 | 166 | ||
| 167 | + | // internalCaptured is the channel internal capture replies are routed to: the | |
| 168 | + | // hints adapter reads it. | |
| 169 | + | func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured } | |
| 170 | + | ||
| 142 | 171 | // wayland is the wayland server's side. | |
| 143 | 172 | func (p *pipes) wayland() wayland.Channels { | |
| 144 | 173 | return wayland.Channels{ | |
| @@ -175,7 +204,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta | |||
| 175 | 204 | content: content, | |
| 176 | 205 | frames: p.frames, | |
| 177 | 206 | captures: p.captures, | |
| 178 | - | captured: p.captured, | |
| 207 | + | captured: p.capturedAll, | |
| 179 | 208 | formats: p.outputFormats, | |
| 180 | 209 | heads: p.outputHeads, | |
| 181 | 210 | leaseRequests: p.leaseRequests, | |
| @@ -46,10 +46,21 @@ type pipes struct { | |||||
| 46 | // move running slides. | 46 | // move running slides. | ||
| 47 | frames chan ports.OutputFrame | 47 | frames chan ports.OutputFrame | ||
| 48 | 48 | ||||
| 49 | + | // Hint mode: core asks the hints adapter for a scan and gets the result. | |||
| 50 | + | hintScans chan ports.HintScanRequest | |||
| 51 | + | hintResults chan ports.HintScanResult | |||
| 52 | + | ||||
| 49 | captures chan ports.CaptureRequest | 53 | captures chan ports.CaptureRequest | ||
| 50 | - | // Every admitted capture owns one reply slot until wayland consumes it. | 54 | + | // Every admitted capture owns one reply slot until routeCaptured takes |
| 51 | - | // Keep this capacity tied to admission so output routing never waits. | 55 | + | // it. Keep this capacity tied to admission so output routing never waits. |
| 52 | - | captured chan ports.CaptureDone | 56 | + | // With hint mode on, the outputs reply on capturedAll and routeCaptured splits the replies by |
| 57 | + | // ports.CaptureIDInternal into captured (the wayland consumer) and | |||
| 58 | + | // hintCaptured (internal consumers). With hint mode off, capturedAll is | |||
| 59 | + | // captured itself and there is no routeCaptured. | |||
| 60 | + | capturedAll chan ports.CaptureDone | |||
| 61 | + | captured chan ports.CaptureDone | |||
| 62 | + | // hintCaptured carries internal capture replies to the hints adapter. | |||
| 63 | + | hintCaptured chan ports.CaptureDone | |||
| 53 | 64 | ||||
| 54 | outputFormats chan ports.OutputFormats | 65 | outputFormats chan ports.OutputFormats | ||
| 55 | outputHeads chan ports.OutputHeads | 66 | outputHeads chan ports.OutputHeads | ||
| @@ -69,7 +80,10 @@ type pipes struct { | |||||
| 69 | castCommands chan ports.CastCommand | 80 | castCommands chan ports.CastCommand | ||
| 70 | } | 81 | } | ||
| 71 | 82 | ||||
| 72 | - | func newPipes(realSession bool) *pipes { | 83 | + | // newPipes makes the channels. The hint channels exist only with hint set |
| 84 | + | // (hint.enable = on): without them core's hint action is a no-op and nothing | |||
| 85 | + | // of hint mode is allocated. | |||
| 86 | + | func newPipes(realSession, hint bool) *pipes { | |||
| 73 | p := &pipes{ | 87 | p := &pipes{ | ||
| 74 | securityChanges: make(chan ports.SecurityState, 8), | 88 | securityChanges: make(chan ports.SecurityState, 8), | ||
| 75 | securityEvents: make(chan ports.SecurityBackendEvent, 64), | 89 | securityEvents: make(chan ports.SecurityBackendEvent, 64), | ||
| @@ -112,6 +126,15 @@ func newPipes(realSession bool) *pipes { | |||||
| 112 | 126 | ||||
| 113 | castCommands: make(chan ports.CastCommand, 64), | 127 | castCommands: make(chan ports.CastCommand, 64), | ||
| 114 | } | 128 | } | ||
| 129 | + | // Without hint mode every reply is for the wayland consumer: the outputs | |||
| 130 | + | // reply on its channel directly and nothing demultiplexes. | |||
| 131 | + | p.capturedAll = p.captured | |||
| 132 | + | if hint { | |||
| 133 | + | p.capturedAll = make(chan ports.CaptureDone, ports.MaxCaptureInflight) | |||
| 134 | + | p.hintScans = make(chan ports.HintScanRequest, 1) | |||
| 135 | + | p.hintResults = make(chan ports.HintScanResult, 1) | |||
| 136 | + | p.hintCaptured = make(chan ports.CaptureDone, 1) | |||
| 137 | + | } | |||
| 115 | if realSession { | 138 | if realSession { | ||
| 116 | p.scales = make(chan ports.ScaleChanged, 8) | 139 | p.scales = make(chan ports.ScaleChanged, 8) | ||
| 117 | } | 140 | } | ||
| @@ -136,9 +159,15 @@ func (p *pipes) core() core.Channels { | |||||
| 136 | ConfigErrors: p.configErrors, | 159 | ConfigErrors: p.configErrors, | ||
| 137 | Scales: p.scales, | 160 | Scales: p.scales, | ||
| 138 | Frames: p.frames, | 161 | Frames: p.frames, | ||
| 162 | + | HintScans: p.hintScans, | |||
| 163 | + | Hints: p.hintResults, | |||
| 139 | } | 164 | } | ||
| 140 | } | 165 | } | ||
| 141 | 166 | ||||
| 167 | + | // internalCaptured is the channel internal capture replies are routed to: the | |||
| 168 | + | // hints adapter reads it. | |||
| 169 | + | func (p *pipes) internalCaptured() chan<- ports.CaptureDone { return p.hintCaptured } | |||
| 170 | + | ||||
| 142 | // wayland is the wayland server's side. | 171 | // wayland is the wayland server's side. | ||
| 143 | func (p *pipes) wayland() wayland.Channels { | 172 | func (p *pipes) wayland() wayland.Channels { | ||
| 144 | return wayland.Channels{ | 173 | return wayland.Channels{ | ||
| @@ -175,7 +204,7 @@ func (p *pipes) outputs(security ports.SessionSecurity, presented ports.Presenta | |||||
| 175 | content: content, | 204 | content: content, | ||
| 176 | frames: p.frames, | 205 | frames: p.frames, | ||
| 177 | captures: p.captures, | 206 | captures: p.captures, | ||
| 178 | - | captured: p.captured, | 207 | + | captured: p.capturedAll, |
| 179 | formats: p.outputFormats, | 208 | formats: p.outputFormats, | ||
| 180 | heads: p.outputHeads, | 209 | heads: p.outputHeads, | ||
| 181 | leaseRequests: p.leaseRequests, | 210 | leaseRequests: p.leaseRequests, | ||