detached 3d282fb

refactor(app): make session dependencies explicit

commit
3d282fb99110359b9f981711523d20630d66380b
author
brice · · 2026-10-06 07:06 UTC
parent
c52e379
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
54 files · +653 -487
M .hexcheck.yaml +1 -1
@@ -28,4 +28,4 @@ ruleSettings:
2828 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/session_backend.go]
2929 suspicious-business-logic-in-adapter:
3030 # app is the composition root; starting core and wiring channels is its job.
31- excludePaths: [internal/app/app.go]
31+ excludePaths: [internal/app/app.go, internal/app/session.go]
@@ -28,4 +28,4 @@ ruleSettings:
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/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/session_backend.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]31+ excludePaths: [internal/app/app.go, internal/app/session.go]
M internal/adapters/launcher/launcher.go +6 -6
@@ -16,14 +16,14 @@ import (
1616 )
1717
1818 type Launcher struct {
19- // Security is the optional defensive admission gate. Set before Run.
20- Security ports.SessionSecurity
19+ // security is the optional defensive admission gate, supplied at construction.
20+ security ports.SessionSecurity
2121 env []string
2222 log zerowrap.Logger
2323 }
2424
25-func New(env []string, log zerowrap.Logger) *Launcher {
26- return &Launcher{env: append([]string(nil), env...), log: log}
25+func New(env []string, security ports.SessionSecurity, log zerowrap.Logger) *Launcher {
26+ return &Launcher{env: append([]string(nil), env...), security: security, log: log}
2727 }
2828
2929 func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) []string {
@@ -101,10 +101,10 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
101101 }
102102
103103 func (l *Launcher) admitted(req ports.SpawnRequest) bool {
104- if l.Security == nil {
104+ if l.security == nil {
105105 return true
106106 }
107- state := l.Security.Snapshot()
107+ state := l.security.Snapshot()
108108 return !state.Protected && req.Security == state
109109 }
110110
@@ -16,14 +16,14 @@ import (
16 )16 )
17 17
18 type Launcher struct {18 type Launcher struct {
19- // Security is the optional defensive admission gate. Set before Run.19+ // security is the optional defensive admission gate, supplied at construction.
20- Security ports.SessionSecurity20+ security ports.SessionSecurity
21 env []string21 env []string
22 log zerowrap.Logger22 log zerowrap.Logger
23 }23 }
24 24
25-func New(env []string, log zerowrap.Logger) *Launcher {25+func New(env []string, security ports.SessionSecurity, log zerowrap.Logger) *Launcher {
26- return &Launcher{env: append([]string(nil), env...), log: log}26+ return &Launcher{env: append([]string(nil), env...), security: security, log: log}
27 }27 }
28 28
29 func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) []string {29 func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) []string {
@@ -101,10 +101,10 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
101 }101 }
102 102
103 func (l *Launcher) admitted(req ports.SpawnRequest) bool {103 func (l *Launcher) admitted(req ports.SpawnRequest) bool {
104- if l.Security == nil {104+ if l.security == nil {
105 return true105 return true
106 }106 }
107- state := l.Security.Snapshot()107+ state := l.security.Snapshot()
108 return !state.Protected && req.Security == state108 return !state.Protected && req.Security == state
109 }109 }
110 110
M internal/adapters/launcher/launcher_test.go +2 -2
@@ -35,7 +35,7 @@ func TestRun(t *testing.T) {
3535 done := make(chan error, 1)
3636 reqs := make(chan ports.SpawnRequest, 3)
3737 go func() {
38- done <- New([]string{"PATH=/bin", "FILE=" + file}, logging.For(ctx, "launcher")).Run(ctx, reqs)
38+ done <- New([]string{"PATH=/bin", "FILE=" + file}, nil, logging.For(ctx, "launcher")).Run(ctx, reqs)
3939 }()
4040 reqs <- ports.SpawnRequest{}
4141 reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-neferwl-binary"}}
@@ -78,7 +78,7 @@ func TestChildPath(t *testing.T) {
7878 defer cancel()
7979 reqs := make(chan ports.SpawnRequest, 1)
8080 done := make(chan error, 1)
81- go func() { done <- New([]string{"PATH=" + dir}, logging.For(ctx, "launcher")).Run(ctx, reqs) }()
81+ go func() { done <- New([]string{"PATH=" + dir}, nil, logging.For(ctx, "launcher")).Run(ctx, reqs) }()
8282 reqs <- ports.SpawnRequest{Argv: []string{name, result}}
8383 deadline := time.After(3 * time.Second)
8484 for {
@@ -35,7 +35,7 @@ func TestRun(t *testing.T) {
35 done := make(chan error, 1)35 done := make(chan error, 1)
36 reqs := make(chan ports.SpawnRequest, 3)36 reqs := make(chan ports.SpawnRequest, 3)
37 go func() {37 go func() {
38- done <- New([]string{"PATH=/bin", "FILE=" + file}, logging.For(ctx, "launcher")).Run(ctx, reqs)38+ done <- New([]string{"PATH=/bin", "FILE=" + file}, nil, logging.For(ctx, "launcher")).Run(ctx, reqs)
39 }()39 }()
40 reqs <- ports.SpawnRequest{}40 reqs <- ports.SpawnRequest{}
41 reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-neferwl-binary"}}41 reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-neferwl-binary"}}
@@ -78,7 +78,7 @@ func TestChildPath(t *testing.T) {
78 defer cancel()78 defer cancel()
79 reqs := make(chan ports.SpawnRequest, 1)79 reqs := make(chan ports.SpawnRequest, 1)
80 done := make(chan error, 1)80 done := make(chan error, 1)
81- go func() { done <- New([]string{"PATH=" + dir}, logging.For(ctx, "launcher")).Run(ctx, reqs) }()81+ go func() { done <- New([]string{"PATH=" + dir}, nil, logging.For(ctx, "launcher")).Run(ctx, reqs) }()
82 reqs <- ports.SpawnRequest{Argv: []string{name, result}}82 reqs <- ports.SpawnRequest{Argv: []string{name, result}}
83 deadline := time.After(3 * time.Second)83 deadline := time.After(3 * time.Second)
84 for {84 for {
M internal/adapters/launcher/security_test.go +2 -4
@@ -14,8 +14,7 @@ import (
1414
1515 func TestSpawnAdmissionRequiresCurrentUnlockedEpoch(t *testing.T) {
1616 var gate sessionsecurity.Gate
17- l := New(nil, logging.For(context.Background(), "launcher"))
18- l.Security = &gate
17+ l := New(nil, &gate, logging.For(context.Background(), "launcher"))
1918 if !l.admitted(ports.SpawnRequest{}) {
2019 t.Fatal("initial unlocked request refused")
2120 }
@@ -43,8 +42,7 @@ func TestSpawnRechecksBeforeProcessStart(t *testing.T) {
4342 security := portsmocks.NewMockSessionSecurity(t)
4443 security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()
4544 security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
46- l := New([]string{"PATH=/bin"}, logging.For(ctx, "launcher"))
47- l.Security = security
45+ l := New([]string{"PATH=/bin"}, security, logging.For(ctx, "launcher"))
4846 result := filepath.Join(t.TempDir(), "must-not-exist")
4947 requests := make(chan ports.SpawnRequest, 1)
5048 requests <- ports.SpawnRequest{Argv: []string{"touch", result}}
@@ -14,8 +14,7 @@ import (
14 14
15 func TestSpawnAdmissionRequiresCurrentUnlockedEpoch(t *testing.T) {15 func TestSpawnAdmissionRequiresCurrentUnlockedEpoch(t *testing.T) {
16 var gate sessionsecurity.Gate16 var gate sessionsecurity.Gate
17- l := New(nil, logging.For(context.Background(), "launcher"))17+ l := New(nil, &gate, logging.For(context.Background(), "launcher"))
18- l.Security = &gate
19 if !l.admitted(ports.SpawnRequest{}) {18 if !l.admitted(ports.SpawnRequest{}) {
20 t.Fatal("initial unlocked request refused")19 t.Fatal("initial unlocked request refused")
21 }20 }
@@ -43,8 +42,7 @@ func TestSpawnRechecksBeforeProcessStart(t *testing.T) {
43 security := portsmocks.NewMockSessionSecurity(t)42 security := portsmocks.NewMockSessionSecurity(t)
44 security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()43 security.EXPECT().Snapshot().Return(ports.SecurityState{}).Once()
45 security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()44 security.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true}).Once()
46- l := New([]string{"PATH=/bin"}, logging.For(ctx, "launcher"))45+ l := New([]string{"PATH=/bin"}, security, logging.For(ctx, "launcher"))
47- l.Security = security
48 result := filepath.Join(t.TempDir(), "must-not-exist")46 result := filepath.Join(t.TempDir(), "must-not-exist")
49 requests := make(chan ports.SpawnRequest, 1)47 requests := make(chan ports.SpawnRequest, 1)
50 requests <- ports.SpawnRequest{Argv: []string{"touch", result}}48 requests <- ports.SpawnRequest{Argv: []string{"touch", result}}
M internal/app/app.go +2 -293
@@ -2,34 +2,14 @@
22 package app
33
44 import (
5- "bufio"
65 "context"
7- "errors"
86 "fmt"
97 "io"
10- "os"
11- "runtime"
12- "sync"
138 "time"
149
15- "github.com/bnema/neferwl/internal/adapters/captureallow"
16- "github.com/bnema/neferwl/internal/adapters/clock"
17- "github.com/bnema/neferwl/internal/adapters/config"
18- "github.com/bnema/neferwl/internal/adapters/drm"
19- "github.com/bnema/neferwl/internal/adapters/headlessinput"
20- "github.com/bnema/neferwl/internal/adapters/launcher"
21- "github.com/bnema/neferwl/internal/adapters/libinput"
2210 "github.com/bnema/neferwl/internal/adapters/logging"
23- "github.com/bnema/neferwl/internal/adapters/sched"
24- "github.com/bnema/neferwl/internal/adapters/screensaver"
25- "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
26- "github.com/bnema/neferwl/internal/adapters/statefile"
27- "github.com/bnema/neferwl/internal/adapters/vulkan"
28- "github.com/bnema/neferwl/internal/adapters/wayland"
29- "github.com/bnema/neferwl/internal/adapters/workspaceid"
3011 "github.com/bnema/neferwl/internal/adapters/xkb"
3112 "github.com/bnema/neferwl/internal/adapters/xwayland"
32- "github.com/bnema/neferwl/internal/core"
3313 "github.com/bnema/neferwl/internal/ports"
3414 "github.com/bnema/zerowrap"
3515 "golang.org/x/sys/unix"
@@ -78,282 +58,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7858 ctx, cancel = context.WithCancel(ctx)
7959 }
8060 defer cancel()
81- sizes := opts.Sizes
82- if len(sizes) == 0 {
83- sizes = [][2]int{{1920, 1080}}
84- }
85- var hw *drmBackend
86- if opts.Backend == "drm" {
87- var err error
88- if hw, err = openDRM(ctx, opts.Config); err != nil {
89- return err
90- }
91- defer hw.close()
92- }
93- security := &sessionsecurity.Gate{}
94- p := newPipes(hw != nil)
95- ch := p.core()
96- ch.Security = security
97- ch.Terminal = !opts.NoTerminal
98- ch.Clock = clock.System{}
99- c, err := core.New(opts.Config, ch)
100- if err != nil {
101- return err
102- }
103- km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
104- if err != nil {
105- return err
106- }
107- keymap := km.String()
108- runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
109- // Every output renders on the same GPU: its formats are the clients'.
110- dmabuf := vulkan.Probe()
111- log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
112- // One launch prefix for ext-workspace ids and the state file.
113- wsIDs := workspaceid.New()
114- // The path is fixed: a config key would let a program of the same user
115- // widen who may capture.
116- // Production: the fixed path, a root-owned file. Only tests set a path,
117- // and an owner for it.
118- captureAllowLog := logging.For(ctx, "captureallow")
119- var captureAllow *captureallow.Store
120- if opts.captureAllowPath != "" {
121- captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
122- } else {
123- captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
124- }
125- server, err := wayland.New(wayland.Options{
126- CaptureAllow: captureAllow,
127- WorkspaceIDs: wsIDs,
128- Security: security,
129- RuntimeDir: runtimeDir,
130- DMABuf: dmabuf,
131- SyncobjNode: renderNode(dmabuf.Device),
132- Keymap: keymap,
133- RepeatRate: opts.Config.Keyboard.RepeatRate,
134- RepeatDelay: opts.Config.Keyboard.RepeatDelay,
135- }, p.wayland(), logging.For(ctx, "wayland"))
61+ s, err := assembleSession(ctx, opts)
13662 if err != nil {
137- km.Close()
13863 return err
13964 }
140- log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
141- childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
142- statePath, err := statefile.Path(runtimeDir, server.SocketName())
143- if err != nil {
144- log.Warn().Err(err).Msg("state file disabled")
145- } else {
146- childEnv = append(childEnv, statefile.Env+"="+statePath)
147- }
148- xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
149- if xdisplay != nil {
150- defer xdisplay.Close()
151- childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
152- }
153- if opts.Session {
154- launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
155- launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
156- defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
157- }
158- child := launcher.New(childEnv, logging.For(ctx, "launcher"))
159- child.Security = security
160- if inject != nil {
161- inject(p.input)
162- }
163- var workers sync.WaitGroup
164- workers.Add(8)
165- done := make(chan error, 8)
166- path := opts.ConfigPath
167- if path == "" {
168- path = config.DefaultPath()
169- }
170- go func() {
171- defer workers.Done()
172- done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
173- }()
174- if p.scales != nil {
175- workers.Add(1)
176- go func() {
177- defer workers.Done()
178- core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), clock.System{}, time.Second)
179- }()
180- }
181- curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
182- defer curs.stop()
183- workers.Add(1)
184- go func() {
185- defer workers.Done()
186- relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
187- }()
188- script := make(chan string)
189- go func() {
190- defer workers.Done()
191- if hw != nil {
192- // Core sends the first layout once DRM reports the outputs.
193- var layout ports.Layout
194- select {
195- case layout = <-p.layouts:
196- case <-ctx.Done():
197- km.Close()
198- return
199- }
200- done <- safe("input", func() error {
201- if opts.Config.Performance.Realtime {
202- runtime.LockOSThread()
203- // Let this dedicated thread exit with its scheduling policy.
204- schedLog := logging.For(ctx, "sched")
205- if err := sched.Realtime(schedLog); err != nil {
206- schedLog.Warn().Err(err).Msg("input scheduling")
207- }
208- }
209- return libinput.Run(ctx, libinput.Options{
210- Security: security,
211- Seat: hw.seat,
212- SeatName: hw.seat.Name(),
213- Keymap: km,
214- Keymaps: p.keymaps,
215- Layout: layout,
216- Layouts: p.layouts,
217- Constraints: p.constraints,
218- DeviceConfig: opts.Config.InputDevicesConfig,
219- DeviceConfigs: p.deviceConfigs,
220- Active: hw.seat.Subscribe(),
221- MoveCursor: curs.move,
222- Log: logging.For(ctx, "input"),
223- LogMotion: logging.Enabled(ctx, "input-motion"),
224- LogKeys: logging.Enabled(ctx, "input-keys"),
225- }, p.input)
226- })
227- return
228- }
229- if err := headlessinput.RunSecure(ctx, km, p.keymaps, script, p.input, p.layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
230- select {
231- case done <- err:
232- case <-ctx.Done():
233- }
234- }
235- }()
236- if opts.Script != nil {
237- go func() { <-ctx.Done(); _ = opts.Script.Close() }()
238- go func() {
239- defer close(script)
240- scanner := bufio.NewScanner(opts.Script)
241- for scanner.Scan() {
242- select {
243- case <-ctx.Done():
244- return
245- case script <- scanner.Text():
246- }
247- }
248- if err := scanner.Err(); err != nil {
249- log.Warn().Err(err).Msg("reading input script")
250- }
251- }()
252- } else {
253- close(script)
254- }
255- go func() { defer workers.Done(); done <- server.Run(ctx) }()
256- go func() {
257- defer workers.Done()
258- // Without a watch the allowlist stays as loaded: restart to apply.
259- if err := captureAllow.Run(ctx); err != nil {
260- log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
261- }
262- }()
263- go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
264- go func() { defer workers.Done(); done <- c.Run(ctx) }()
265- if xdisplay != nil {
266- workers.Add(1)
267- // X11 failing leaves the Wayland session running; Run logs why.
268- go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
269- }
270- if statePath != "" {
271- workers.Add(1)
272- go func() {
273- defer workers.Done()
274- if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
275- // Scripts lose their state; the session goes on.
276- log.Warn().Err(err).Msg("state file disabled")
277- }
278- }()
279- }
280- go func() {
281- defer workers.Done()
282- consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
283- }()
284- workers.Add(1)
285- go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
286-
287- go func() {
288- defer workers.Done()
289- outputIO := p.outputs(security)
290- apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
291- renderLog := logging.For(ctx, "render")
292- newRenderer := func(w, h int) (ports.Renderer, error) {
293- r, err := vulkan.New(w, h)
294- if err != nil {
295- return nil, err
296- }
297- r.SetLogger(renderLog)
298- renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
299- return r, nil
300- }
301- if hw != nil {
302- done <- safe("output", func() error {
303- want := func(cfg ports.Config) drm.Want {
304- w := wantFromConfig(cfg)
305- w.Sampled = dmabuf.Formats
306- w.TraceFlips = logging.Enabled(ctx, "drm-flip")
307- return w
308- }
309- return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
310- })
311- return
312- }
313- done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
314- }()
315-
316- if hw != nil {
317- workers.Add(1)
318- go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
319- // Only a real session serves the session bus: headless runs would take
320- // the name from the desktop they run in.
321- workers.Add(1)
322- go func() {
323- defer workers.Done()
324- reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
325- screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
326- }()
327- }
328- var result error
329- select {
330- case <-ctx.Done():
331- if errors.Is(context.Cause(ctx), errTimeout) {
332- log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
333- }
334- case result = <-done:
335- log.Info().AnErr("cause", result).Msg("stopping")
336- }
337- cancel()
338- workers.Wait()
339- // The wayland server has stopped: no request can be queued any more.
340- drainCaptures(ctx, p.captures, nil)
341- // A keymap the input goroutine never took is still ours to free.
342- select {
343- case km := <-p.keymaps:
344- km.Close()
345- default:
346- }
347- for len(done) > 0 {
348- err := <-done
349- if result == nil && err != nil {
350- result = err
351- }
352- }
353- if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
354- return nil
355- }
356- return result
65+ return s.run(ctx, inject)
35766 }
35867
35968 // screensaverRetry is the first delay before the screensaver service tries
@@ -2,34 +2,14 @@
2 package app2 package app
3 3
4 import (4 import (
5- "bufio"
6 "context"5 "context"
7- "errors"
8 "fmt"6 "fmt"
9 "io"7 "io"
10- "os"
11- "runtime"
12- "sync"
13 "time"8 "time"
14 9
15- "github.com/bnema/neferwl/internal/adapters/captureallow"
16- "github.com/bnema/neferwl/internal/adapters/clock"
17- "github.com/bnema/neferwl/internal/adapters/config"
18- "github.com/bnema/neferwl/internal/adapters/drm"
19- "github.com/bnema/neferwl/internal/adapters/headlessinput"
20- "github.com/bnema/neferwl/internal/adapters/launcher"
21- "github.com/bnema/neferwl/internal/adapters/libinput"
22 "github.com/bnema/neferwl/internal/adapters/logging"10 "github.com/bnema/neferwl/internal/adapters/logging"
23- "github.com/bnema/neferwl/internal/adapters/sched"
24- "github.com/bnema/neferwl/internal/adapters/screensaver"
25- "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
26- "github.com/bnema/neferwl/internal/adapters/statefile"
27- "github.com/bnema/neferwl/internal/adapters/vulkan"
28- "github.com/bnema/neferwl/internal/adapters/wayland"
29- "github.com/bnema/neferwl/internal/adapters/workspaceid"
30 "github.com/bnema/neferwl/internal/adapters/xkb"11 "github.com/bnema/neferwl/internal/adapters/xkb"
31 "github.com/bnema/neferwl/internal/adapters/xwayland"12 "github.com/bnema/neferwl/internal/adapters/xwayland"
32- "github.com/bnema/neferwl/internal/core"
33 "github.com/bnema/neferwl/internal/ports"13 "github.com/bnema/neferwl/internal/ports"
34 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
35 "golang.org/x/sys/unix"15 "golang.org/x/sys/unix"
@@ -78,282 +58,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
78 ctx, cancel = context.WithCancel(ctx)58 ctx, cancel = context.WithCancel(ctx)
79 }59 }
80 defer cancel()60 defer cancel()
81- sizes := opts.Sizes61+ s, err := assembleSession(ctx, opts)
82- if len(sizes) == 0 {
83- sizes = [][2]int{{1920, 1080}}
84- }
85- var hw *drmBackend
86- if opts.Backend == "drm" {
87- var err error
88- if hw, err = openDRM(ctx, opts.Config); err != nil {
89- return err
90- }
91- defer hw.close()
92- }
93- security := &sessionsecurity.Gate{}
94- p := newPipes(hw != nil)
95- ch := p.core()
96- ch.Security = security
97- ch.Terminal = !opts.NoTerminal
98- ch.Clock = clock.System{}
99- c, err := core.New(opts.Config, ch)
100- if err != nil {
101- return err
102- }
103- km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
104- if err != nil {
105- return err
106- }
107- keymap := km.String()
108- runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
109- // Every output renders on the same GPU: its formats are the clients'.
110- dmabuf := vulkan.Probe()
111- log.Info().Int("formats", len(dmabuf.Formats)).Msg("dmabuf")
112- // One launch prefix for ext-workspace ids and the state file.
113- wsIDs := workspaceid.New()
114- // The path is fixed: a config key would let a program of the same user
115- // widen who may capture.
116- // Production: the fixed path, a root-owned file. Only tests set a path,
117- // and an owner for it.
118- captureAllowLog := logging.For(ctx, "captureallow")
119- var captureAllow *captureallow.Store
120- if opts.captureAllowPath != "" {
121- captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
122- } else {
123- captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
124- }
125- server, err := wayland.New(wayland.Options{
126- CaptureAllow: captureAllow,
127- WorkspaceIDs: wsIDs,
128- Security: security,
129- RuntimeDir: runtimeDir,
130- DMABuf: dmabuf,
131- SyncobjNode: renderNode(dmabuf.Device),
132- Keymap: keymap,
133- RepeatRate: opts.Config.Keyboard.RepeatRate,
134- RepeatDelay: opts.Config.Keyboard.RepeatDelay,
135- }, p.wayland(), logging.For(ctx, "wayland"))
136 if err != nil {62 if err != nil {
137- km.Close()
138 return err63 return err
139 }64 }
140- log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")65+ return s.run(ctx, inject)
141- childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir, cursorSize())
142- statePath, err := statefile.Path(runtimeDir, server.SocketName())
143- if err != nil {
144- log.Warn().Err(err).Msg("state file disabled")
145- } else {
146- childEnv = append(childEnv, statefile.Env+"="+statePath)
147- }
148- xdisplay := openXwayland(ctx, opts, childEnv, logging.For(ctx, "xwayland"))
149- if xdisplay != nil {
150- defer xdisplay.Close()
151- childEnv = append(childEnv, "DISPLAY="+xdisplay.Name())
152- }
153- if opts.Session {
154- launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
155- launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
156- defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
157- }
158- child := launcher.New(childEnv, logging.For(ctx, "launcher"))
159- child.Security = security
160- if inject != nil {
161- inject(p.input)
162- }
163- var workers sync.WaitGroup
164- workers.Add(8)
165- done := make(chan error, 8)
166- path := opts.ConfigPath
167- if path == "" {
168- path = config.DefaultPath()
169- }
170- go func() {
171- defer workers.Done()
172- done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
173- }()
174- if p.scales != nil {
175- workers.Add(1)
176- go func() {
177- defer workers.Done()
178- core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), clock.System{}, time.Second)
179- }()
180- }
181- curs := newCursors(clock.System{}, opts.Config.Cursor.HideAfter)
182- defer curs.stop()
183- workers.Add(1)
184- go func() {
185- defer workers.Done()
186- relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
187- }()
188- script := make(chan string)
189- go func() {
190- defer workers.Done()
191- if hw != nil {
192- // Core sends the first layout once DRM reports the outputs.
193- var layout ports.Layout
194- select {
195- case layout = <-p.layouts:
196- case <-ctx.Done():
197- km.Close()
198- return
199- }
200- done <- safe("input", func() error {
201- if opts.Config.Performance.Realtime {
202- runtime.LockOSThread()
203- // Let this dedicated thread exit with its scheduling policy.
204- schedLog := logging.For(ctx, "sched")
205- if err := sched.Realtime(schedLog); err != nil {
206- schedLog.Warn().Err(err).Msg("input scheduling")
207- }
208- }
209- return libinput.Run(ctx, libinput.Options{
210- Security: security,
211- Seat: hw.seat,
212- SeatName: hw.seat.Name(),
213- Keymap: km,
214- Keymaps: p.keymaps,
215- Layout: layout,
216- Layouts: p.layouts,
217- Constraints: p.constraints,
218- DeviceConfig: opts.Config.InputDevicesConfig,
219- DeviceConfigs: p.deviceConfigs,
220- Active: hw.seat.Subscribe(),
221- MoveCursor: curs.move,
222- Log: logging.For(ctx, "input"),
223- LogMotion: logging.Enabled(ctx, "input-motion"),
224- LogKeys: logging.Enabled(ctx, "input-keys"),
225- }, p.input)
226- })
227- return
228- }
229- if err := headlessinput.RunSecure(ctx, km, p.keymaps, script, p.input, p.layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
230- select {
231- case done <- err:
232- case <-ctx.Done():
233- }
234- }
235- }()
236- if opts.Script != nil {
237- go func() { <-ctx.Done(); _ = opts.Script.Close() }()
238- go func() {
239- defer close(script)
240- scanner := bufio.NewScanner(opts.Script)
241- for scanner.Scan() {
242- select {
243- case <-ctx.Done():
244- return
245- case script <- scanner.Text():
246- }
247- }
248- if err := scanner.Err(); err != nil {
249- log.Warn().Err(err).Msg("reading input script")
250- }
251- }()
252- } else {
253- close(script)
254- }
255- go func() { defer workers.Done(); done <- server.Run(ctx) }()
256- go func() {
257- defer workers.Done()
258- // Without a watch the allowlist stays as loaded: restart to apply.
259- if err := captureAllow.Run(ctx); err != nil {
260- log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
261- }
262- }()
263- go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
264- go func() { defer workers.Done(); done <- c.Run(ctx) }()
265- if xdisplay != nil {
266- workers.Add(1)
267- // X11 failing leaves the Wayland session running; Run logs why.
268- go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
269- }
270- if statePath != "" {
271- workers.Add(1)
272- go func() {
273- defer workers.Done()
274- if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
275- // Scripts lose their state; the session goes on.
276- log.Warn().Err(err).Msg("state file disabled")
277- }
278- }()
279- }
280- go func() {
281- defer workers.Done()
282- consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
283- }()
284- workers.Add(1)
285- go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
286-
287- go func() {
288- defer workers.Done()
289- outputIO := p.outputs(security)
290- apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
291- renderLog := logging.For(ctx, "render")
292- newRenderer := func(w, h int) (ports.Renderer, error) {
293- r, err := vulkan.New(w, h)
294- if err != nil {
295- return nil, err
296- }
297- r.SetLogger(renderLog)
298- renderLog.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
299- return r, nil
300- }
301- if hw != nil {
302- done <- safe("output", func() error {
303- want := func(cfg ports.Config) drm.Want {
304- w := wantFromConfig(cfg)
305- w.Sampled = dmabuf.Formats
306- w.TraceFlips = logging.Enabled(ctx, "drm-flip")
307- return w
308- }
309- return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, newRenderer, logging.For(ctx, "drm"))
310- })
311- return
312- }
313- done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, newRenderer, logging.For(ctx, "render"))
314- }()
315-
316- if hw != nil {
317- workers.Add(1)
318- go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
319- // Only a real session serves the session bus: headless runs would take
320- // the name from the desktop they run in.
321- workers.Add(1)
322- go func() {
323- defer workers.Done()
324- reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
325- screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
326- }()
327- }
328- var result error
329- select {
330- case <-ctx.Done():
331- if errors.Is(context.Cause(ctx), errTimeout) {
332- log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
333- }
334- case result = <-done:
335- log.Info().AnErr("cause", result).Msg("stopping")
336- }
337- cancel()
338- workers.Wait()
339- // The wayland server has stopped: no request can be queued any more.
340- drainCaptures(ctx, p.captures, nil)
341- // A keymap the input goroutine never took is still ours to free.
342- select {
343- case km := <-p.keymaps:
344- km.Close()
345- default:
346- }
347- for len(done) > 0 {
348- err := <-done
349- if result == nil && err != nil {
350- result = err
351- }
352- }
353- if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
354- return nil
355- }
356- return result
357 }66 }
358 67
359 // screensaverRetry is the first delay before the screensaver service tries68 // screensaverRetry is the first delay before the screensaver service tries
M internal/app/headless.go +1 -26
@@ -23,14 +23,8 @@ type headlessOptions struct {
2323 // With several outputs, screenshots go to a subdirectory per output. Modes
2424 // are fixed, so apply answers every configuration without backend work.
2525 //
26-// The raw PQ dump (o.raw) needs readable HDR targets, set on these
27-// renderers only: the DRM path never reads them back.
28-//
29-// Virtual outputs have no display to list modifiers, so each renderer is
30-// told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real
31-// GPU refuses the HDR targets and the output falls back to SDR.
26+// newRenderer is configured for virtual outputs by newVulkanRenderer.
3227 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
33- newRenderer = virtualRenderer(newRenderer, o.raw)
3428 set := newOutputSet(ctx, ch.captured)
3529 set.wireSecurity(ch)
3630 inventory := ports.OutputHeads{}
@@ -116,25 +110,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
116110 }
117111 }
118112
119-// virtualRenderer wraps a renderer factory for headless outputs. Vulkan is
120-// the only implementation of these settings; they are not on the port.
121-// readback makes the HDR targets readable (HDRPixels).
122-func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) {
123- return func(w, h int) (ports.Renderer, error) {
124- r, err := newRenderer(w, h)
125- if err != nil {
126- return nil, err
127- }
128- if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok {
129- v.SetVirtualOutput(true)
130- }
131- if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback {
132- v.SetHDRReadback(true)
133- }
134- return r, nil
135- }
136-}
137-
138113 func joinErr(a, b error) error {
139114 if a != nil {
140115 return a
@@ -23,14 +23,8 @@ type headlessOptions struct {
23 // With several outputs, screenshots go to a subdirectory per output. Modes23 // With several outputs, screenshots go to a subdirectory per output. Modes
24 // are fixed, so apply answers every configuration without backend work.24 // are fixed, so apply answers every configuration without backend work.
25 //25 //
26-// The raw PQ dump (o.raw) needs readable HDR targets, set on these26+// newRenderer is configured for virtual outputs by newVulkanRenderer.
27-// renderers only: the DRM path never reads them back.
28-//
29-// Virtual outputs have no display to list modifiers, so each renderer is
30-// told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real
31-// GPU refuses the HDR targets and the output falls back to SDR.
32 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {27 func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
33- newRenderer = virtualRenderer(newRenderer, o.raw)
34 set := newOutputSet(ctx, ch.captured)28 set := newOutputSet(ctx, ch.captured)
35 set.wireSecurity(ch)29 set.wireSecurity(ch)
36 inventory := ports.OutputHeads{}30 inventory := ports.OutputHeads{}
@@ -116,25 +110,6 @@ func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch
116 }110 }
117 }111 }
118 112
119-// virtualRenderer wraps a renderer factory for headless outputs. Vulkan is
120-// the only implementation of these settings; they are not on the port.
121-// readback makes the HDR targets readable (HDRPixels).
122-func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) {
123- return func(w, h int) (ports.Renderer, error) {
124- r, err := newRenderer(w, h)
125- if err != nil {
126- return nil, err
127- }
128- if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok {
129- v.SetVirtualOutput(true)
130- }
131- if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback {
132- v.SetHDRReadback(true)
133- }
134- return r, nil
135- }
136-}
137-
138 func joinErr(a, b error) error {113 func joinErr(a, b error) error {
139 if a != nil {114 if a != nil {
140 return a115 return a
M internal/app/headless_test.go +15 -9
@@ -3,7 +3,7 @@ package app
33 import (
44 "testing"
55
6- "github.com/bnema/neferwl/internal/adapters/vulkan"
6+ "github.com/bnema/neferwl/internal/adapters/logging"
77 "github.com/bnema/neferwl/internal/ports"
88 )
99
@@ -12,14 +12,7 @@ import (
1212 // and a headless output falls back to SDR. Skips without Vulkan or without a
1313 // device that exports dmabufs (lavapipe in CI).
1414 func TestVirtualRendererExportsHDRTargets(t *testing.T) {
15- newRenderer := func(w, h int) (ports.Renderer, error) {
16- r, err := vulkan.New(w, h)
17- if err != nil {
18- return nil, err
19- }
20- return r, nil
21- }
22- r, err := virtualRenderer(newRenderer, false)(64, 16)
15+ r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false)(64, 16)
2316 if err != nil {
2417 t.Skipf("Vulkan unavailable: %v", err)
2518 }
@@ -45,3 +38,16 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
4538 }
4639 }
4740 }
41+
42+// A failed assembly returns an error and releases what it acquired: without
43+// a runtime directory wayland.New, the last fallible step, fails.
44+func TestAssembleSessionFailure(t *testing.T) {
45+ t.Setenv("XDG_RUNTIME_DIR", "")
46+ s, err := assembleSession(t.Context(), Options{Backend: "headless", Config: ports.Config{}})
47+ if err == nil {
48+ t.Fatal("assembleSession succeeded without XDG_RUNTIME_DIR")
49+ }
50+ if s != nil {
51+ t.Fatal("failed assembly returned a session")
52+ }
53+}
@@ -3,7 +3,7 @@ package app
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/neferwl/internal/adapters/vulkan"6+ "github.com/bnema/neferwl/internal/adapters/logging"
7 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
8 )8 )
9 9
@@ -12,14 +12,7 @@ import (
12 // and a headless output falls back to SDR. Skips without Vulkan or without a12 // and a headless output falls back to SDR. Skips without Vulkan or without a
13 // device that exports dmabufs (lavapipe in CI).13 // device that exports dmabufs (lavapipe in CI).
14 func TestVirtualRendererExportsHDRTargets(t *testing.T) {14 func TestVirtualRendererExportsHDRTargets(t *testing.T) {
15- newRenderer := func(w, h int) (ports.Renderer, error) {15+ r, err := newVulkanRenderer(logging.For(t.Context(), "render"), true, false)(64, 16)
16- r, err := vulkan.New(w, h)
17- if err != nil {
18- return nil, err
19- }
20- return r, nil
21- }
22- r, err := virtualRenderer(newRenderer, false)(64, 16)
23 if err != nil {16 if err != nil {
24 t.Skipf("Vulkan unavailable: %v", err)17 t.Skipf("Vulkan unavailable: %v", err)
25 }18 }
@@ -45,3 +38,16 @@ func TestVirtualRendererExportsHDRTargets(t *testing.T) {
45 }38 }
46 }39 }
47 }40 }
41+
42+// A failed assembly returns an error and releases what it acquired: without
43+// a runtime directory wayland.New, the last fallible step, fails.
44+func TestAssembleSessionFailure(t *testing.T) {
45+ t.Setenv("XDG_RUNTIME_DIR", "")
46+ s, err := assembleSession(t.Context(), Options{Backend: "headless", Config: ports.Config{}})
47+ if err == nil {
48+ t.Fatal("assembleSession succeeded without XDG_RUNTIME_DIR")
49+ }
50+ if s != nil {
51+ t.Fatal("failed assembly returned a session")
52+ }
53+}
A internal/app/session.go +395 -0
@@ -0,0 +1,395 @@
1+package app
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "os"
8+ "runtime"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/neferwl/internal/adapters/captureallow"
13+ "github.com/bnema/neferwl/internal/adapters/clock"
14+ "github.com/bnema/neferwl/internal/adapters/config"
15+ "github.com/bnema/neferwl/internal/adapters/drm"
16+ "github.com/bnema/neferwl/internal/adapters/headlessinput"
17+ "github.com/bnema/neferwl/internal/adapters/launcher"
18+ "github.com/bnema/neferwl/internal/adapters/libinput"
19+ "github.com/bnema/neferwl/internal/adapters/logging"
20+ "github.com/bnema/neferwl/internal/adapters/sched"
21+ "github.com/bnema/neferwl/internal/adapters/screensaver"
22+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
23+ "github.com/bnema/neferwl/internal/adapters/statefile"
24+ "github.com/bnema/neferwl/internal/adapters/vulkan"
25+ "github.com/bnema/neferwl/internal/adapters/wayland"
26+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
27+ "github.com/bnema/neferwl/internal/adapters/xkb"
28+ "github.com/bnema/neferwl/internal/adapters/xwayland"
29+ "github.com/bnema/neferwl/internal/core"
30+ "github.com/bnema/neferwl/internal/ports"
31+ "github.com/bnema/zerowrap"
32+)
33+
34+// session holds the dependencies assembleSession acquired and wired; run
35+// starts them. The session owns hw, curs and xdisplay (released by close)
36+// until run ends; the keymap and the wayland server own themselves.
37+type session struct {
38+ opts Options
39+ log zerowrap.Logger
40+
41+ clock ports.Clock
42+ security *sessionsecurity.Gate
43+ p *pipes
44+ sizes [][2]int
45+ path string
46+
47+ hw *drmBackend
48+ km *xkb.Keymap
49+ core *core.Core
50+ dmabuf ports.DMABufSupport
51+ wsIDs *workspaceid.IDs
52+ captureAllow *captureallow.Store
53+ server *wayland.Server
54+ childEnv []string
55+ statePath string
56+ xdisplay *xwayland.Display
57+ child *launcher.Launcher
58+ curs *cursors
59+
60+ newRenderer func(w, h int) (ports.Renderer, error)
61+}
62+
63+// assembleSession acquires and wires every dependency in startup order and
64+// starts no long-lived worker.
65+//
66+// Invariant: wayland.New is the last fallible step. *wayland.Server has no
67+// Close: its sockets are released only by Server.Run. Every later step
68+// (statefile.Path, openXwayland, launcher.New, newCursors) degrades with a
69+// warning or nil instead of failing. A failure therefore releases exactly
70+// the DRM backend and the keymap (abort); nothing else is owned yet.
71+func assembleSession(ctx context.Context, opts Options) (s *session, err error) {
72+ s = &session{opts: opts, log: logging.For(ctx, "app"), clock: clock.System{}, security: &sessionsecurity.Gate{}}
73+ defer func() {
74+ if err != nil {
75+ s.abort()
76+ s = nil
77+ }
78+ }()
79+ log := s.log
80+ s.sizes = opts.Sizes
81+ if len(s.sizes) == 0 {
82+ s.sizes = [][2]int{{1920, 1080}}
83+ }
84+ if opts.Backend == "drm" {
85+ if s.hw, err = openDRM(ctx, opts.Config); err != nil {
86+ return s, err
87+ }
88+ }
89+ s.p = newPipes(s.hw != nil)
90+ // core() carries channels only; the gate, clock and terminal flag are
91+ // explicit core.Options.
92+ if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil {
93+ return s, err
94+ }
95+ 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 {
96+ return s, err
97+ }
98+ runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
99+ // Every output renders on the same GPU: its formats are the clients'.
100+ s.dmabuf = vulkan.Probe()
101+ log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
102+ // One launch prefix for ext-workspace ids and the state file.
103+ s.wsIDs = workspaceid.New()
104+ // The path is fixed: a config key would let a program of the same user
105+ // widen who may capture.
106+ // Production: the fixed path, a root-owned file. Only tests set a path,
107+ // and an owner for it.
108+ captureAllowLog := logging.For(ctx, "captureallow")
109+ if opts.captureAllowPath != "" {
110+ s.captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
111+ } else {
112+ s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
113+ }
114+ if s.server, err = wayland.New(wayland.Options{
115+ CaptureAllow: s.captureAllow,
116+ WorkspaceIDs: s.wsIDs,
117+ Security: s.security,
118+ RuntimeDir: runtimeDir,
119+ DMABuf: s.dmabuf,
120+ SyncobjNode: renderNode(s.dmabuf.Device),
121+ Keymap: s.km.String(),
122+ RepeatRate: opts.Config.Keyboard.RepeatRate,
123+ RepeatDelay: opts.Config.Keyboard.RepeatDelay,
124+ }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
125+ return s, err
126+ }
127+ log.Info().Str("WAYLAND_DISPLAY", s.server.SocketName()).Msg("listening")
128+ s.childEnv = launcher.ChildEnv(os.Environ(), s.server.SocketName(), runtimeDir, cursorSize())
129+ statePath, serr := statefile.Path(runtimeDir, s.server.SocketName())
130+ if serr != nil {
131+ log.Warn().Err(serr).Msg("state file disabled")
132+ } else {
133+ s.statePath = statePath
134+ s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
135+ }
136+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {
137+ s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
138+ }
139+ s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
140+ s.curs = newCursors(s.clock, opts.Config.Cursor.HideAfter)
141+ s.path = opts.ConfigPath
142+ if s.path == "" {
143+ s.path = config.DefaultPath()
144+ }
145+ renderLog := logging.For(ctx, "render")
146+ if s.hw != nil {
147+ s.newRenderer = newVulkanRenderer(renderLog, false, false)
148+ } else {
149+ s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw)
150+ }
151+ return s, nil
152+}
153+
154+// abort releases what assembly acquired before ownership passed on: the DRM
155+// backend and the keymap. It runs only when assembly fails, before
156+// anything else is owned.
157+func (s *session) abort() {
158+ if s.hw != nil {
159+ s.hw.close()
160+ }
161+ if s.km != nil {
162+ s.km.Close()
163+ }
164+}
165+
166+// close releases what the session owns after its workers have joined. The
167+// keymap and the wayland server are not here: the input goroutine and
168+// Server.Run own them.
169+func (s *session) close() {
170+ s.curs.stop()
171+ if s.xdisplay != nil {
172+ s.xdisplay.Close()
173+ }
174+ if s.hw != nil {
175+ s.hw.close()
176+ }
177+}
178+
179+// newVulkanRenderer returns the concrete Vulkan renderer factory. Virtual
180+// outputs have no display to list modifiers, so a headless renderer is told
181+// it drives one: without it a real GPU refuses the HDR targets and the
182+// output falls back to SDR. readback makes the HDR targets readable
183+// (HDRPixels). These settings are Vulkan-only and not on ports.Renderer.
184+func newVulkanRenderer(log zerowrap.Logger, virtual, readback bool) func(w, h int) (ports.Renderer, error) {
185+ return func(w, h int) (ports.Renderer, error) {
186+ r, err := vulkan.New(w, h)
187+ if err != nil {
188+ return nil, err
189+ }
190+ r.SetLogger(log)
191+ log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
192+ r.SetVirtualOutput(virtual)
193+ if readback {
194+ r.SetHDRReadback(true)
195+ }
196+ return r, nil
197+ }
198+}
199+
200+// run starts the workers and waits for the session to end; it releases the
201+// session's resources after every worker has joined.
202+func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) error {
203+ defer s.close()
204+ ctx, cancel := context.WithCancel(ctx)
205+ defer cancel()
206+ opts, log, p, hw, km, security := s.opts, s.log, s.p, s.hw, s.km, s.security
207+ childEnv, curs, xdisplay, statePath := s.childEnv, s.curs, s.xdisplay, s.statePath
208+ server, captureAllow, c, child, wsIDs, dmabuf, sizes, path := s.server, s.captureAllow, s.core, s.child, s.wsIDs, s.dmabuf, s.sizes, s.path
209+ if opts.Session {
210+ launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
211+ launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
212+ defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
213+ }
214+ if inject != nil {
215+ inject(p.input)
216+ }
217+ var workers sync.WaitGroup
218+ workers.Add(8)
219+ done := make(chan error, 8)
220+ go func() {
221+ defer workers.Done()
222+ done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
223+ }()
224+ if p.scales != nil {
225+ workers.Add(1)
226+ go func() {
227+ defer workers.Done()
228+ core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), s.clock, time.Second)
229+ }()
230+ }
231+ workers.Add(1)
232+ go func() {
233+ defer workers.Done()
234+ relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
235+ }()
236+ script := make(chan string)
237+ go func() {
238+ defer workers.Done()
239+ if hw != nil {
240+ // Core sends the first layout once DRM reports the outputs.
241+ var layout ports.Layout
242+ select {
243+ case layout = <-p.layouts:
244+ case <-ctx.Done():
245+ km.Close()
246+ return
247+ }
248+ done <- safe("input", func() error {
249+ if opts.Config.Performance.Realtime {
250+ runtime.LockOSThread()
251+ // Let this dedicated thread exit with its scheduling policy.
252+ schedLog := logging.For(ctx, "sched")
253+ if err := sched.Realtime(schedLog); err != nil {
254+ schedLog.Warn().Err(err).Msg("input scheduling")
255+ }
256+ }
257+ return libinput.Run(ctx, libinput.Options{
258+ Security: security,
259+ Seat: hw.seat,
260+ SeatName: hw.seat.Name(),
261+ Keymap: km,
262+ Keymaps: p.keymaps,
263+ Layout: layout,
264+ Layouts: p.layouts,
265+ Constraints: p.constraints,
266+ DeviceConfig: opts.Config.InputDevicesConfig,
267+ DeviceConfigs: p.deviceConfigs,
268+ Active: hw.seat.Subscribe(),
269+ MoveCursor: curs.move,
270+ Log: logging.For(ctx, "input"),
271+ LogMotion: logging.Enabled(ctx, "input-motion"),
272+ LogKeys: logging.Enabled(ctx, "input-keys"),
273+ }, p.input)
274+ })
275+ return
276+ }
277+ if err := headlessinput.RunSecure(ctx, km, p.keymaps, script, p.input, p.layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
278+ select {
279+ case done <- err:
280+ case <-ctx.Done():
281+ }
282+ }
283+ }()
284+ if opts.Script != nil {
285+ go func() { <-ctx.Done(); _ = opts.Script.Close() }()
286+ go func() {
287+ defer close(script)
288+ scanner := bufio.NewScanner(opts.Script)
289+ for scanner.Scan() {
290+ select {
291+ case <-ctx.Done():
292+ return
293+ case script <- scanner.Text():
294+ }
295+ }
296+ if err := scanner.Err(); err != nil {
297+ log.Warn().Err(err).Msg("reading input script")
298+ }
299+ }()
300+ } else {
301+ close(script)
302+ }
303+ go func() { defer workers.Done(); done <- server.Run(ctx) }()
304+ go func() {
305+ defer workers.Done()
306+ // Without a watch the allowlist stays as loaded: restart to apply.
307+ if err := captureAllow.Run(ctx); err != nil {
308+ log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
309+ }
310+ }()
311+ go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
312+ go func() { defer workers.Done(); done <- c.Run(ctx) }()
313+ if xdisplay != nil {
314+ workers.Add(1)
315+ // X11 failing leaves the Wayland session running; Run logs why.
316+ go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
317+ }
318+ if statePath != "" {
319+ workers.Add(1)
320+ go func() {
321+ defer workers.Done()
322+ if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
323+ // Scripts lose their state; the session goes on.
324+ log.Warn().Err(err).Msg("state file disabled")
325+ }
326+ }()
327+ }
328+ go func() {
329+ defer workers.Done()
330+ consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
331+ }()
332+ workers.Add(1)
333+ go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
334+
335+ go func() {
336+ defer workers.Done()
337+ outputIO := p.outputs(security)
338+ apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
339+ if hw != nil {
340+ done <- safe("output", func() error {
341+ want := func(cfg ports.Config) drm.Want {
342+ w := wantFromConfig(cfg)
343+ w.Sampled = dmabuf.Formats
344+ w.TraceFlips = logging.Enabled(ctx, "drm-flip")
345+ return w
346+ }
347+ return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, s.newRenderer, logging.For(ctx, "drm"))
348+ })
349+ return
350+ }
351+ done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, s.newRenderer, logging.For(ctx, "render"))
352+ }()
353+
354+ if hw != nil {
355+ workers.Add(1)
356+ go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
357+ // Only a real session serves the session bus: headless runs would take
358+ // the name from the desktop they run in.
359+ workers.Add(1)
360+ go func() {
361+ defer workers.Done()
362+ reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
363+ screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
364+ }()
365+ }
366+ var result error
367+ select {
368+ case <-ctx.Done():
369+ if errors.Is(context.Cause(ctx), errTimeout) {
370+ log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
371+ }
372+ case result = <-done:
373+ log.Info().AnErr("cause", result).Msg("stopping")
374+ }
375+ cancel()
376+ workers.Wait()
377+ // The wayland server has stopped: no request can be queued any more.
378+ drainCaptures(ctx, p.captures, nil)
379+ // A keymap the input goroutine never took is still ours to free.
380+ select {
381+ case km := <-p.keymaps:
382+ km.Close()
383+ default:
384+ }
385+ for len(done) > 0 {
386+ err := <-done
387+ if result == nil && err != nil {
388+ result = err
389+ }
390+ }
391+ if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
392+ return nil
393+ }
394+ return result
395+}
@@ -0,0 +1,395 @@
1+package app
2+
3+import (
4+ "bufio"
5+ "context"
6+ "errors"
7+ "os"
8+ "runtime"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/neferwl/internal/adapters/captureallow"
13+ "github.com/bnema/neferwl/internal/adapters/clock"
14+ "github.com/bnema/neferwl/internal/adapters/config"
15+ "github.com/bnema/neferwl/internal/adapters/drm"
16+ "github.com/bnema/neferwl/internal/adapters/headlessinput"
17+ "github.com/bnema/neferwl/internal/adapters/launcher"
18+ "github.com/bnema/neferwl/internal/adapters/libinput"
19+ "github.com/bnema/neferwl/internal/adapters/logging"
20+ "github.com/bnema/neferwl/internal/adapters/sched"
21+ "github.com/bnema/neferwl/internal/adapters/screensaver"
22+ "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
23+ "github.com/bnema/neferwl/internal/adapters/statefile"
24+ "github.com/bnema/neferwl/internal/adapters/vulkan"
25+ "github.com/bnema/neferwl/internal/adapters/wayland"
26+ "github.com/bnema/neferwl/internal/adapters/workspaceid"
27+ "github.com/bnema/neferwl/internal/adapters/xkb"
28+ "github.com/bnema/neferwl/internal/adapters/xwayland"
29+ "github.com/bnema/neferwl/internal/core"
30+ "github.com/bnema/neferwl/internal/ports"
31+ "github.com/bnema/zerowrap"
32+)
33+
34+// session holds the dependencies assembleSession acquired and wired; run
35+// starts them. The session owns hw, curs and xdisplay (released by close)
36+// until run ends; the keymap and the wayland server own themselves.
37+type session struct {
38+ opts Options
39+ log zerowrap.Logger
40+
41+ clock ports.Clock
42+ security *sessionsecurity.Gate
43+ p *pipes
44+ sizes [][2]int
45+ path string
46+
47+ hw *drmBackend
48+ km *xkb.Keymap
49+ core *core.Core
50+ dmabuf ports.DMABufSupport
51+ wsIDs *workspaceid.IDs
52+ captureAllow *captureallow.Store
53+ server *wayland.Server
54+ childEnv []string
55+ statePath string
56+ xdisplay *xwayland.Display
57+ child *launcher.Launcher
58+ curs *cursors
59+
60+ newRenderer func(w, h int) (ports.Renderer, error)
61+}
62+
63+// assembleSession acquires and wires every dependency in startup order and
64+// starts no long-lived worker.
65+//
66+// Invariant: wayland.New is the last fallible step. *wayland.Server has no
67+// Close: its sockets are released only by Server.Run. Every later step
68+// (statefile.Path, openXwayland, launcher.New, newCursors) degrades with a
69+// warning or nil instead of failing. A failure therefore releases exactly
70+// the DRM backend and the keymap (abort); nothing else is owned yet.
71+func assembleSession(ctx context.Context, opts Options) (s *session, err error) {
72+ s = &session{opts: opts, log: logging.For(ctx, "app"), clock: clock.System{}, security: &sessionsecurity.Gate{}}
73+ defer func() {
74+ if err != nil {
75+ s.abort()
76+ s = nil
77+ }
78+ }()
79+ log := s.log
80+ s.sizes = opts.Sizes
81+ if len(s.sizes) == 0 {
82+ s.sizes = [][2]int{{1920, 1080}}
83+ }
84+ if opts.Backend == "drm" {
85+ if s.hw, err = openDRM(ctx, opts.Config); err != nil {
86+ return s, err
87+ }
88+ }
89+ s.p = newPipes(s.hw != nil)
90+ // core() carries channels only; the gate, clock and terminal flag are
91+ // explicit core.Options.
92+ if s.core, err = core.New(opts.Config, s.p.core(), core.Options{Security: s.security, Clock: s.clock, Terminal: !opts.NoTerminal}); err != nil {
93+ return s, err
94+ }
95+ 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 {
96+ return s, err
97+ }
98+ runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
99+ // Every output renders on the same GPU: its formats are the clients'.
100+ s.dmabuf = vulkan.Probe()
101+ log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf")
102+ // One launch prefix for ext-workspace ids and the state file.
103+ s.wsIDs = workspaceid.New()
104+ // The path is fixed: a config key would let a program of the same user
105+ // widen who may capture.
106+ // Production: the fixed path, a root-owned file. Only tests set a path,
107+ // and an owner for it.
108+ captureAllowLog := logging.For(ctx, "captureallow")
109+ if opts.captureAllowPath != "" {
110+ s.captureAllow = captureallow.NewStoreOwnedBy(opts.captureAllowPath, opts.captureAllowOwner, captureAllowLog)
111+ } else {
112+ s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
113+ }
114+ if s.server, err = wayland.New(wayland.Options{
115+ CaptureAllow: s.captureAllow,
116+ WorkspaceIDs: s.wsIDs,
117+ Security: s.security,
118+ RuntimeDir: runtimeDir,
119+ DMABuf: s.dmabuf,
120+ SyncobjNode: renderNode(s.dmabuf.Device),
121+ Keymap: s.km.String(),
122+ RepeatRate: opts.Config.Keyboard.RepeatRate,
123+ RepeatDelay: opts.Config.Keyboard.RepeatDelay,
124+ }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
125+ return s, err
126+ }
127+ log.Info().Str("WAYLAND_DISPLAY", s.server.SocketName()).Msg("listening")
128+ s.childEnv = launcher.ChildEnv(os.Environ(), s.server.SocketName(), runtimeDir, cursorSize())
129+ statePath, serr := statefile.Path(runtimeDir, s.server.SocketName())
130+ if serr != nil {
131+ log.Warn().Err(serr).Msg("state file disabled")
132+ } else {
133+ s.statePath = statePath
134+ s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
135+ }
136+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {
137+ s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
138+ }
139+ s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
140+ s.curs = newCursors(s.clock, opts.Config.Cursor.HideAfter)
141+ s.path = opts.ConfigPath
142+ if s.path == "" {
143+ s.path = config.DefaultPath()
144+ }
145+ renderLog := logging.For(ctx, "render")
146+ if s.hw != nil {
147+ s.newRenderer = newVulkanRenderer(renderLog, false, false)
148+ } else {
149+ s.newRenderer = newVulkanRenderer(renderLog, true, opts.ScreenshotRaw)
150+ }
151+ return s, nil
152+}
153+
154+// abort releases what assembly acquired before ownership passed on: the DRM
155+// backend and the keymap. It runs only when assembly fails, before
156+// anything else is owned.
157+func (s *session) abort() {
158+ if s.hw != nil {
159+ s.hw.close()
160+ }
161+ if s.km != nil {
162+ s.km.Close()
163+ }
164+}
165+
166+// close releases what the session owns after its workers have joined. The
167+// keymap and the wayland server are not here: the input goroutine and
168+// Server.Run own them.
169+func (s *session) close() {
170+ s.curs.stop()
171+ if s.xdisplay != nil {
172+ s.xdisplay.Close()
173+ }
174+ if s.hw != nil {
175+ s.hw.close()
176+ }
177+}
178+
179+// newVulkanRenderer returns the concrete Vulkan renderer factory. Virtual
180+// outputs have no display to list modifiers, so a headless renderer is told
181+// it drives one: without it a real GPU refuses the HDR targets and the
182+// output falls back to SDR. readback makes the HDR targets readable
183+// (HDRPixels). These settings are Vulkan-only and not on ports.Renderer.
184+func newVulkanRenderer(log zerowrap.Logger, virtual, readback bool) func(w, h int) (ports.Renderer, error) {
185+ return func(w, h int) (ports.Renderer, error) {
186+ r, err := vulkan.New(w, h)
187+ if err != nil {
188+ return nil, err
189+ }
190+ r.SetLogger(log)
191+ log.Info().Str("queue_priority", r.QueuePriority()).Msg("vulkan queue")
192+ r.SetVirtualOutput(virtual)
193+ if readback {
194+ r.SetHDRReadback(true)
195+ }
196+ return r, nil
197+ }
198+}
199+
200+// run starts the workers and waits for the session to end; it releases the
201+// session's resources after every worker has joined.
202+func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) error {
203+ defer s.close()
204+ ctx, cancel := context.WithCancel(ctx)
205+ defer cancel()
206+ opts, log, p, hw, km, security := s.opts, s.log, s.p, s.hw, s.km, s.security
207+ childEnv, curs, xdisplay, statePath := s.childEnv, s.curs, s.xdisplay, s.statePath
208+ server, captureAllow, c, child, wsIDs, dmabuf, sizes, path := s.server, s.captureAllow, s.core, s.child, s.wsIDs, s.dmabuf, s.sizes, s.path
209+ if opts.Session {
210+ launcher.ExportSession(ctx, childEnv, logging.For(ctx, "launcher"))
211+ launcher.NotifyReady(os.Getenv("NOTIFY_SOCKET"), logging.For(ctx, "launcher"))
212+ defer launcher.UnexportSession(childEnv, logging.For(ctx, "launcher"))
213+ }
214+ if inject != nil {
215+ inject(p.input)
216+ }
217+ var workers sync.WaitGroup
218+ workers.Add(8)
219+ done := make(chan error, 8)
220+ go func() {
221+ defer workers.Done()
222+ done <- config.Watch(ctx, path, p.watched, logging.For(ctx, "config"))
223+ }()
224+ if p.scales != nil {
225+ workers.Add(1)
226+ go func() {
227+ defer workers.Done()
228+ core.PersistScales(ctx, p.scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), s.clock, time.Second)
229+ }()
230+ }
231+ workers.Add(1)
232+ go func() {
233+ defer workers.Done()
234+ relayConfig(ctx, opts.Config, p.watched, p.filtered, p.keymaps, p.deviceConfigs, curs, p.commands, logging.For(ctx, "config"))
235+ }()
236+ script := make(chan string)
237+ go func() {
238+ defer workers.Done()
239+ if hw != nil {
240+ // Core sends the first layout once DRM reports the outputs.
241+ var layout ports.Layout
242+ select {
243+ case layout = <-p.layouts:
244+ case <-ctx.Done():
245+ km.Close()
246+ return
247+ }
248+ done <- safe("input", func() error {
249+ if opts.Config.Performance.Realtime {
250+ runtime.LockOSThread()
251+ // Let this dedicated thread exit with its scheduling policy.
252+ schedLog := logging.For(ctx, "sched")
253+ if err := sched.Realtime(schedLog); err != nil {
254+ schedLog.Warn().Err(err).Msg("input scheduling")
255+ }
256+ }
257+ return libinput.Run(ctx, libinput.Options{
258+ Security: security,
259+ Seat: hw.seat,
260+ SeatName: hw.seat.Name(),
261+ Keymap: km,
262+ Keymaps: p.keymaps,
263+ Layout: layout,
264+ Layouts: p.layouts,
265+ Constraints: p.constraints,
266+ DeviceConfig: opts.Config.InputDevicesConfig,
267+ DeviceConfigs: p.deviceConfigs,
268+ Active: hw.seat.Subscribe(),
269+ MoveCursor: curs.move,
270+ Log: logging.For(ctx, "input"),
271+ LogMotion: logging.Enabled(ctx, "input-motion"),
272+ LogKeys: logging.Enabled(ctx, "input-keys"),
273+ }, p.input)
274+ })
275+ return
276+ }
277+ if err := headlessinput.RunSecure(ctx, km, p.keymaps, script, p.input, p.layouts, func(o string, x, y float64) { curs.move(o, x, y, true) }, logging.For(ctx, "input"), security); err != nil && !errors.Is(err, context.Canceled) {
278+ select {
279+ case done <- err:
280+ case <-ctx.Done():
281+ }
282+ }
283+ }()
284+ if opts.Script != nil {
285+ go func() { <-ctx.Done(); _ = opts.Script.Close() }()
286+ go func() {
287+ defer close(script)
288+ scanner := bufio.NewScanner(opts.Script)
289+ for scanner.Scan() {
290+ select {
291+ case <-ctx.Done():
292+ return
293+ case script <- scanner.Text():
294+ }
295+ }
296+ if err := scanner.Err(); err != nil {
297+ log.Warn().Err(err).Msg("reading input script")
298+ }
299+ }()
300+ } else {
301+ close(script)
302+ }
303+ go func() { defer workers.Done(); done <- server.Run(ctx) }()
304+ go func() {
305+ defer workers.Done()
306+ // Without a watch the allowlist stays as loaded: restart to apply.
307+ if err := captureAllow.Run(ctx); err != nil {
308+ log.Warn().Err(err).Msg("capture allowlist not watched, restart to apply changes")
309+ }
310+ }()
311+ go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }()
312+ go func() { defer workers.Done(); done <- c.Run(ctx) }()
313+ if xdisplay != nil {
314+ workers.Add(1)
315+ // X11 failing leaves the Wayland session running; Run logs why.
316+ go func() { defer workers.Done(); _ = xdisplay.Run(ctx) }()
317+ }
318+ if statePath != "" {
319+ workers.Add(1)
320+ go func() {
321+ defer workers.Done()
322+ if err := statefile.Run(ctx, statePath, p.states, wsIDs, logging.For(ctx, "statefile")); err != nil {
323+ // Scripts lose their state; the session goes on.
324+ log.Warn().Err(err).Msg("state file disabled")
325+ }
326+ }()
327+ }
328+ go func() {
329+ defer workers.Done()
330+ consumeScenes(ctx, p.scenes, p.configErrors, opts.testScenes, p.renderScenes)
331+ }()
332+ workers.Add(1)
333+ go func() { defer workers.Done(); relayPresented(ctx, p.flips, p.presented, p.frames) }()
334+
335+ go func() {
336+ defer workers.Done()
337+ outputIO := p.outputs(security)
338+ apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
339+ if hw != nil {
340+ done <- safe("output", func() error {
341+ want := func(cfg ports.Config) drm.Want {
342+ w := wantFromConfig(cfg)
343+ w.Sampled = dmabuf.Formats
344+ w.TraceFlips = logging.Enabled(ctx, "drm-flip")
345+ return w
346+ }
347+ return hw.runOutputs(ctx, want, opts.Config, apply, outputIO, curs, s.newRenderer, logging.For(ctx, "drm"))
348+ })
349+ return
350+ }
351+ done <- runHeadless(ctx, headlessOptions{sizes: sizes, shots: opts.ScreenshotDir, hdr: opts.HeadlessHDR, raw: opts.ScreenshotRaw}, apply, outputIO, curs, s.newRenderer, logging.For(ctx, "render"))
352+ }()
353+
354+ if hw != nil {
355+ workers.Add(1)
356+ go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
357+ // Only a real session serves the session bus: headless runs would take
358+ // the name from the desktop they run in.
359+ workers.Add(1)
360+ go func() {
361+ defer workers.Done()
362+ reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
363+ screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver"))
364+ }()
365+ }
366+ var result error
367+ select {
368+ case <-ctx.Done():
369+ if errors.Is(context.Cause(ctx), errTimeout) {
370+ log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
371+ }
372+ case result = <-done:
373+ log.Info().AnErr("cause", result).Msg("stopping")
374+ }
375+ cancel()
376+ workers.Wait()
377+ // The wayland server has stopped: no request can be queued any more.
378+ drainCaptures(ctx, p.captures, nil)
379+ // A keymap the input goroutine never took is still ours to free.
380+ select {
381+ case km := <-p.keymaps:
382+ km.Close()
383+ default:
384+ }
385+ for len(done) > 0 {
386+ err := <-done
387+ if result == nil && err != nil {
388+ result = err
389+ }
390+ }
391+ if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
392+ return nil
393+ }
394+ return result
395+}
M internal/app/wiring.go +2 -2
@@ -118,8 +118,8 @@ func newPipes(realSession bool) *pipes {
118118 return p
119119 }
120120
121-// core is core's side. Security, Terminal and Clock are not channels: the
122-// caller sets them.
121+// core is core's side: channels only. The security gate, clock and terminal
122+// flag are not channels; assembleSession passes them as core.Options.
123123 func (p *pipes) core() core.Channels {
124124 return core.Channels{
125125 Client: p.client,
@@ -118,8 +118,8 @@ func newPipes(realSession bool) *pipes {
118 return p118 return p
119 }119 }
120 120
121-// core is core's side. Security, Terminal and Clock are not channels: the121+// core is core's side: channels only. The security gate, clock and terminal
122-// caller sets them.122+// flag are not channels; assembleSession passes them as core.Options.
123 func (p *pipes) core() core.Channels {123 func (p *pipes) core() core.Channels {
124 return core.Channels{124 return core.Channels{
125 Client: p.client,125 Client: p.client,
M internal/core/action_test.go +1 -1
@@ -31,7 +31,7 @@ func TestDefaultBindsAccepted(t *testing.T) {
3131 if cmd == "alt" {
3232 cfg = altCmdDefaults()
3333 }
34- if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}); err != nil {
34+ if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}, core.Options{}); err != nil {
3535 t.Fatalf("cmd=%s: %v", cmd, err)
3636 }
3737 }
@@ -31,7 +31,7 @@ func TestDefaultBindsAccepted(t *testing.T) {
31 if cmd == "alt" {31 if cmd == "alt" {
32 cfg = altCmdDefaults()32 cfg = altCmdDefaults()
33 }33 }
34- if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}); err != nil {34+ if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}, core.Options{}); err != nil {
35 t.Fatalf("cmd=%s: %v", cmd, err)35 t.Fatalf("cmd=%s: %v", cmd, err)
36 }36 }
37 }37 }
M internal/core/animations_interrupt_internal_test.go +1 -1
@@ -43,7 +43,7 @@ func TestSessionLockMidRectMotion(t *testing.T) {
4343 state := ports.SecurityState{}
4444 gate := portsmocks.NewMockSessionSecurity(t)
4545 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
46- c.ch.Security = gate
46+ c.opts.Security = gate
4747 sc := c.cur()
4848 movingRects(t, c, ic, sc)
4949 if !c.animating() || c.frameC == nil || sc.last.Seq == 0 {
@@ -43,7 +43,7 @@ func TestSessionLockMidRectMotion(t *testing.T) {
43 state := ports.SecurityState{}43 state := ports.SecurityState{}
44 gate := portsmocks.NewMockSessionSecurity(t)44 gate := portsmocks.NewMockSessionSecurity(t)
45 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })45 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
46- c.ch.Security = gate46+ c.opts.Security = gate
47 sc := c.cur()47 sc := c.cur()
48 movingRects(t, c, ic, sc)48 movingRects(t, c, ic, sc)
49 if !c.animating() || c.frameC == nil || sc.last.Seq == 0 {49 if !c.animating() || c.frameC == nil || sc.last.Seq == 0 {
M internal/core/capindicator.go +3 -3
@@ -51,7 +51,7 @@ func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
5151 // Nothing of the target is left on screen to mark.
5252 return changed
5353 }
54- return c.capt.flash(t, c.now(), c.ch.Clock) || changed
54+ return c.capt.flash(t, c.now(), c.opts.Clock) || changed
5555 }
5656
5757 // flash marks t until ports.CaptureFlash after now and arms the timer. It
@@ -153,7 +153,7 @@ func (c *Core) captureFlashTick() bool {
153153 }
154154 now := c.now()
155155 c.capt.expire(now)
156- c.capt.armTimer(now, c.ch.Clock)
156+ c.capt.armTimer(now, c.opts.Clock)
157157 return len(c.capt.flashes) != n
158158 }
159159
@@ -163,7 +163,7 @@ func (c *Core) armCaptureTimer() {
163163 return
164164 }
165165 if c.capt.timerStop == nil {
166- c.capt.armTimer(c.now(), c.ch.Clock)
166+ c.capt.armTimer(c.now(), c.opts.Clock)
167167 }
168168 }
169169
@@ -51,7 +51,7 @@ func (c *Core) captureFrame(v ports.CaptureFrameTaken) bool {
51 // Nothing of the target is left on screen to mark.51 // Nothing of the target is left on screen to mark.
52 return changed52 return changed
53 }53 }
54- return c.capt.flash(t, c.now(), c.ch.Clock) || changed54+ return c.capt.flash(t, c.now(), c.opts.Clock) || changed
55 }55 }
56 56
57 // flash marks t until ports.CaptureFlash after now and arms the timer. It57 // flash marks t until ports.CaptureFlash after now and arms the timer. It
@@ -153,7 +153,7 @@ func (c *Core) captureFlashTick() bool {
153 }153 }
154 now := c.now()154 now := c.now()
155 c.capt.expire(now)155 c.capt.expire(now)
156- c.capt.armTimer(now, c.ch.Clock)156+ c.capt.armTimer(now, c.opts.Clock)
157 return len(c.capt.flashes) != n157 return len(c.capt.flashes) != n
158 }158 }
159 159
@@ -163,7 +163,7 @@ func (c *Core) armCaptureTimer() {
163 return163 return
164 }164 }
165 if c.capt.timerStop == nil {165 if c.capt.timerStop == nil {
166- c.capt.armTimer(c.now(), c.ch.Clock)166+ c.capt.armTimer(c.now(), c.opts.Clock)
167 }167 }
168 }168 }
169 169
M internal/core/capindicator_test.go +5 -5
@@ -39,7 +39,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
3939 cfg.Keyboard.CmdKey = "super"
4040 cfg.Layout.MaxColumns = 2
4141 cfg.Border.Width = 0
42- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64), Clock: clock})
42+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{Clock: clock})
4343 if err != nil {
4444 t.Fatal(err)
4545 }
@@ -109,7 +109,7 @@ func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.
109109 state := ports.SecurityState{Generation: 1}
110110 gate := portsmocks.NewMockSessionSecurity(t)
111111 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
112- c.ch.Security = gate
112+ c.opts.Security = gate
113113 if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
114114 t.Fatal("first frame shows nothing new")
115115 }
@@ -200,7 +200,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
200200 timer.EXPECT().Stop().Return(true).Maybe()
201201 timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
202202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
203- c.ch.Clock = clock
203+ c.opts.Clock = clock
204204 c.configures.cw.reset()
205205 c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})
206206 c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})
@@ -238,7 +238,7 @@ func TestCaptureIndicatorNoAllocationWhenIdle(t *testing.T) {
238238 }
239239 // A live flash, steady state, allocates nothing either (measured on the
240240 // system clock: the generated clock mock allocates on every call).
241- c.ch.Clock = nil
241+ c.opts.Clock = nil
242242 c.captureFrame(ports.CaptureFrameTaken{Output: "A"})
243243 if n := testing.AllocsPerRun(100, func() { c.captureExpire(); c.armCaptureTimer() }); n != 0 {
244244 t.Fatalf("%v allocations keeping a flash", n)
@@ -301,7 +301,7 @@ func TestCaptureIndicatorInflatesThinTargets(t *testing.T) {
301301 // scratch or shared while unchanged.
302302 func TestCaptureRecordingPublishAllocations(t *testing.T) {
303303 c, _ := indicatorCore(t)
304- c.ch.Clock = nil
304+ c.opts.Clock = nil
305305 cmds := make(chan ports.ClientCommand, 64)
306306 c.ch.Commands = cmds
307307 c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})
@@ -39,7 +39,7 @@ func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
39 cfg.Keyboard.CmdKey = "super"39 cfg.Keyboard.CmdKey = "super"
40 cfg.Layout.MaxColumns = 240 cfg.Layout.MaxColumns = 2
41 cfg.Border.Width = 041 cfg.Border.Width = 0
42- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64), Clock: clock})42+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{Clock: clock})
43 if err != nil {43 if err != nil {
44 t.Fatal(err)44 t.Fatal(err)
45 }45 }
@@ -109,7 +109,7 @@ func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.
109 state := ports.SecurityState{Generation: 1}109 state := ports.SecurityState{Generation: 1}
110 gate := portsmocks.NewMockSessionSecurity(t)110 gate := portsmocks.NewMockSessionSecurity(t)
111 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()111 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
112- c.ch.Security = gate112+ c.opts.Security = gate
113 if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {113 if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
114 t.Fatal("first frame shows nothing new")114 t.Fatal("first frame shows nothing new")
115 }115 }
@@ -200,7 +200,7 @@ func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
200 timer.EXPECT().Stop().Return(true).Maybe()200 timer.EXPECT().Stop().Return(true).Maybe()
201 timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()201 timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()202 clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
203- c.ch.Clock = clock203+ c.opts.Clock = clock
204 c.configures.cw.reset()204 c.configures.cw.reset()
205 c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})205 c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})
206 c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})206 c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})
@@ -238,7 +238,7 @@ func TestCaptureIndicatorNoAllocationWhenIdle(t *testing.T) {
238 }238 }
239 // A live flash, steady state, allocates nothing either (measured on the239 // A live flash, steady state, allocates nothing either (measured on the
240 // system clock: the generated clock mock allocates on every call).240 // system clock: the generated clock mock allocates on every call).
241- c.ch.Clock = nil241+ c.opts.Clock = nil
242 c.captureFrame(ports.CaptureFrameTaken{Output: "A"})242 c.captureFrame(ports.CaptureFrameTaken{Output: "A"})
243 if n := testing.AllocsPerRun(100, func() { c.captureExpire(); c.armCaptureTimer() }); n != 0 {243 if n := testing.AllocsPerRun(100, func() { c.captureExpire(); c.armCaptureTimer() }); n != 0 {
244 t.Fatalf("%v allocations keeping a flash", n)244 t.Fatalf("%v allocations keeping a flash", n)
@@ -301,7 +301,7 @@ func TestCaptureIndicatorInflatesThinTargets(t *testing.T) {
301 // scratch or shared while unchanged.301 // scratch or shared while unchanged.
302 func TestCaptureRecordingPublishAllocations(t *testing.T) {302 func TestCaptureRecordingPublishAllocations(t *testing.T) {
303 c, _ := indicatorCore(t)303 c, _ := indicatorCore(t)
304- c.ch.Clock = nil304+ c.opts.Clock = nil
305 cmds := make(chan ports.ClientCommand, 64)305 cmds := make(chan ports.ClientCommand, 64)
306 c.ch.Commands = cmds306 c.ch.Commands = cmds
307 c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})307 c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})
M internal/core/capture_test.go +2 -2
@@ -33,7 +33,7 @@ func captureCore(t *testing.T) *captureRig {
3333 scenes: make(chan []ports.Scene, 1),
3434 workspaces: make(chan ports.Workspaces, 1),
3535 }
36- c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces})
36+ c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces}, core.Options{})
3737 if err != nil {
3838 t.Fatal(err)
3939 }
@@ -514,7 +514,7 @@ func TestCaptureFrameFlashRunsOnTheCoreClock(t *testing.T) {
514514 client := make(chan ports.ClientEvent, 4)
515515 output := make(chan ports.OutputEvent, 4)
516516 scenes := make(chan []ports.Scene, 1)
517- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Clock: clock})
517+ c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes}, core.Options{Clock: clock})
518518 if err != nil {
519519 t.Fatal(err)
520520 }
@@ -33,7 +33,7 @@ func captureCore(t *testing.T) *captureRig {
33 scenes: make(chan []ports.Scene, 1),33 scenes: make(chan []ports.Scene, 1),
34 workspaces: make(chan ports.Workspaces, 1),34 workspaces: make(chan ports.Workspaces, 1),
35 }35 }
36- c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces})36+ c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces}, core.Options{})
37 if err != nil {37 if err != nil {
38 t.Fatal(err)38 t.Fatal(err)
39 }39 }
@@ -514,7 +514,7 @@ func TestCaptureFrameFlashRunsOnTheCoreClock(t *testing.T) {
514 client := make(chan ports.ClientEvent, 4)514 client := make(chan ports.ClientEvent, 4)
515 output := make(chan ports.OutputEvent, 4)515 output := make(chan ports.OutputEvent, 4)
516 scenes := make(chan []ports.Scene, 1)516 scenes := make(chan []ports.Scene, 1)
517- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Clock: clock})517+ c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes}, core.Options{Clock: clock})
518 if err != nil {518 if err != nil {
519 t.Fatal(err)519 t.Fatal(err)
520 }520 }
M internal/core/capture_workspace_test.go +1 -3
@@ -25,9 +25,7 @@ func hiddenCaptureCommands(t *testing.T) (*Core, *Workspace, chan ports.ClientCo
2525 cfg.Layout.MaxColumns = 2
2626 cfg.Border.Width = 2
2727 cfg.Background.Color = "#102030"
28- c, err := New(cfg, Channels{
29- Scenes: make(chan []ports.Scene, 1), Commands: commands,
30- })
28+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{})
3129 if err != nil {
3230 t.Fatal(err)
3331 }
@@ -25,9 +25,7 @@ func hiddenCaptureCommands(t *testing.T) (*Core, *Workspace, chan ports.ClientCo
25 cfg.Layout.MaxColumns = 225 cfg.Layout.MaxColumns = 2
26 cfg.Border.Width = 226 cfg.Border.Width = 2
27 cfg.Background.Color = "#102030"27 cfg.Background.Color = "#102030"
28- c, err := New(cfg, Channels{28+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{})
29- Scenes: make(chan []ports.Scene, 1), Commands: commands,
30- })
31 if err != nil {29 if err != nil {
32 t.Fatal(err)30 t.Fatal(err)
33 }31 }
M internal/core/constraint_frame_test.go +1 -4
@@ -15,10 +15,7 @@ func partlyShown(t *testing.T) *Core {
1515 var cfg ports.Config
1616 cfg.Keyboard.CmdKey = "super"
1717 cfg.Layout.MaxColumns = 2
18- c, err := New(cfg, Channels{
19- Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1),
20- State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1), Commands: make(chan ports.ClientCommand, 16),
21- })
18+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1), Commands: make(chan ports.ClientCommand, 16)}, Options{})
2219 if err != nil {
2320 t.Fatal(err)
2421 }
@@ -15,10 +15,7 @@ func partlyShown(t *testing.T) *Core {
15 var cfg ports.Config15 var cfg ports.Config
16 cfg.Keyboard.CmdKey = "super"16 cfg.Keyboard.CmdKey = "super"
17 cfg.Layout.MaxColumns = 217 cfg.Layout.MaxColumns = 2
18- c, err := New(cfg, Channels{18+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1), Commands: make(chan ports.ClientCommand, 16)}, Options{})
19- Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1),
20- State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1), Commands: make(chan ports.ClientCommand, 16),
21- })
22 if err != nil {19 if err != nil {
23 t.Fatal(err)20 t.Fatal(err)
24 }21 }
M internal/core/core.go +17 -12
@@ -21,8 +21,6 @@ var errSecurityChanged = errors.New("security epoch changed during input")
2121 // its only sender and may drain a stale set; consumers only receive. Each
2222 // send holds one scene per output.
2323 type Channels struct {
24- // Security is the defensive session gate; nil preserves standalone operation.
25- Security ports.SessionSecurity
2624 Client <-chan ports.ClientEvent
2725 Input <-chan ports.InputEvent
2826 Output <-chan ports.OutputEvent
@@ -45,16 +43,22 @@ type Channels struct {
4543 // Scales, when set, receives each scale a scale bind sets, to persist it.
4644 // Core does not wait: a full channel drops the change.
4745 Scales chan<- ports.ScaleChanged
48- // Terminal enables automatic terminal opening according to terminal.auto-open.
49- Terminal bool
50- // Clock tells the time for fullscreenGrace and slides; nil is the
51- // system clock.
52- Clock ports.Clock
5346 // Frames, when set, reports outputs' page flips: a running slide moves
5447 // one step per flip. Without flips it moves on a timer.
5548 Frames <-chan ports.OutputFrame
5649 }
5750
51+// Options are the services and policy core is constructed with.
52+type Options struct {
53+ // Security is the defensive session gate; nil preserves standalone operation.
54+ Security ports.SessionSecurity
55+ // Clock tells the time for fullscreenGrace and slides; nil is the
56+ // system clock.
57+ Clock ports.Clock
58+ // Terminal enables automatic terminal opening according to terminal.auto-open.
59+ Terminal bool
60+}
61+
5862 // fullscreenGrace is how long after mapping a window's fullscreen request
5963 // is ignored. Wine asks for fullscreen whenever a window has the monitor's
6064 // size, so a launcher that remembered a fullscreen size would open
@@ -81,6 +85,7 @@ type Core struct {
8185 startup [][]string
8286 nextWorkspaceID uint64
8387 ch Channels
88+ opts Options
8489 cfg ports.Config
8590 screens []*screen
8691 focusScreen int
@@ -350,12 +355,12 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
350355 }
351356 return nil
352357 }
353-func New(cfg ports.Config, ch Channels) (*Core, error) {
358+func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
354359 if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) || (ch.Workspaces != nil && cap(ch.Workspaces) != 1) {
355360 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
356361 }
357362 // A placeholder screen holds windows until the first output arrives.
358- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}
363+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}
359364 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
360365 if err := c.apply(cfg); err != nil {
361366 return nil, err
@@ -386,8 +391,8 @@ func (t sysTimer) Stop() bool { return t.t.Stop() }
386391 func (t sysTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
387392
388393 func (c *Core) now() time.Time {
389- if c.ch.Clock != nil {
390- return c.ch.Clock.Now()
394+ if c.opts.Clock != nil {
395+ return c.opts.Clock.Now()
391396 }
392397 return time.Now()
393398 }
@@ -396,7 +401,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
396401 if c.inputEpochChanged() {
397402 return errSecurityChanged
398403 }
399- if c.ch.Security != nil {
404+ if c.opts.Security != nil {
400405 v = ports.SecurityCommand{State: c.security, Command: v}
401406 }
402407 select {
@@ -21,8 +21,6 @@ var errSecurityChanged = errors.New("security epoch changed during input")
21 // its only sender and may drain a stale set; consumers only receive. Each21 // its only sender and may drain a stale set; consumers only receive. Each
22 // send holds one scene per output.22 // send holds one scene per output.
23 type Channels struct {23 type Channels struct {
24- // Security is the defensive session gate; nil preserves standalone operation.
25- Security ports.SessionSecurity
26 Client <-chan ports.ClientEvent24 Client <-chan ports.ClientEvent
27 Input <-chan ports.InputEvent25 Input <-chan ports.InputEvent
28 Output <-chan ports.OutputEvent26 Output <-chan ports.OutputEvent
@@ -45,16 +43,22 @@ type Channels struct {
45 // Scales, when set, receives each scale a scale bind sets, to persist it.43 // Scales, when set, receives each scale a scale bind sets, to persist it.
46 // Core does not wait: a full channel drops the change.44 // Core does not wait: a full channel drops the change.
47 Scales chan<- ports.ScaleChanged45 Scales chan<- ports.ScaleChanged
48- // Terminal enables automatic terminal opening according to terminal.auto-open.
49- Terminal bool
50- // Clock tells the time for fullscreenGrace and slides; nil is the
51- // system clock.
52- Clock ports.Clock
53 // Frames, when set, reports outputs' page flips: a running slide moves46 // Frames, when set, reports outputs' page flips: a running slide moves
54 // one step per flip. Without flips it moves on a timer.47 // one step per flip. Without flips it moves on a timer.
55 Frames <-chan ports.OutputFrame48 Frames <-chan ports.OutputFrame
56 }49 }
57 50
51+// Options are the services and policy core is constructed with.
52+type Options struct {
53+ // Security is the defensive session gate; nil preserves standalone operation.
54+ Security ports.SessionSecurity
55+ // Clock tells the time for fullscreenGrace and slides; nil is the
56+ // system clock.
57+ Clock ports.Clock
58+ // Terminal enables automatic terminal opening according to terminal.auto-open.
59+ Terminal bool
60+}
61+
58 // fullscreenGrace is how long after mapping a window's fullscreen request62 // fullscreenGrace is how long after mapping a window's fullscreen request
59 // is ignored. Wine asks for fullscreen whenever a window has the monitor's63 // is ignored. Wine asks for fullscreen whenever a window has the monitor's
60 // size, so a launcher that remembered a fullscreen size would open64 // size, so a launcher that remembered a fullscreen size would open
@@ -81,6 +85,7 @@ type Core struct {
81 startup [][]string85 startup [][]string
82 nextWorkspaceID uint6486 nextWorkspaceID uint64
83 ch Channels87 ch Channels
88+ opts Options
84 cfg ports.Config89 cfg ports.Config
85 screens []*screen90 screens []*screen
86 focusScreen int91 focusScreen int
@@ -350,12 +355,12 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
350 }355 }
351 return nil356 return nil
352 }357 }
353-func New(cfg ports.Config, ch Channels) (*Core, error) {358+func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
354 if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) || (ch.Workspaces != nil && cap(ch.Workspaces) != 1) {359 if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.Constraints != nil && cap(ch.Constraints) != 1) || (ch.State != nil && cap(ch.State) != 1) || (ch.Workspaces != nil && cap(ch.Workspaces) != 1) {
355 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")360 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
356 }361 }
357 // A placeholder screen holds windows until the first output arrives.362 // A placeholder screen holds windows until the first output arrives.
358- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}363+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures()}
359 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}364 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
360 if err := c.apply(cfg); err != nil {365 if err := c.apply(cfg); err != nil {
361 return nil, err366 return nil, err
@@ -386,8 +391,8 @@ func (t sysTimer) Stop() bool { return t.t.Stop() }
386 func (t sysTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }391 func (t sysTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
387 392
388 func (c *Core) now() time.Time {393 func (c *Core) now() time.Time {
389- if c.ch.Clock != nil {394+ if c.opts.Clock != nil {
390- return c.ch.Clock.Now()395+ return c.opts.Clock.Now()
391 }396 }
392 return time.Now()397 return time.Now()
393 }398 }
@@ -396,7 +401,7 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
396 if c.inputEpochChanged() {401 if c.inputEpochChanged() {
397 return errSecurityChanged402 return errSecurityChanged
398 }403 }
399- if c.ch.Security != nil {404+ if c.opts.Security != nil {
400 v = ports.SecurityCommand{State: c.security, Command: v}405 v = ports.SecurityCommand{State: c.security, Command: v}
401 }406 }
402 select {407 select {
M internal/core/core_test.go +41 -33
@@ -58,14 +58,14 @@ type modeLanding struct {
5858 lands int
5959 }
6060
61-// newLanding sets cfg's animations and, when on, the clock and flips of ch.
62-func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, outputs ...string) *modeLanding {
61+// newLanding sets cfg's animations and, when on, the clock of opts and flips of ch.
62+func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, opts *core.Options, outputs ...string) *modeLanding {
6363 t.Helper()
6464 cfg.Animations.On = animated
6565 l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs}
6666 if animated {
6767 l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame)
68- ch.Clock, ch.Frames = l.clk.clock, l.frames
68+ opts.Clock, ch.Frames = l.clk.clock, l.frames
6969 t.Cleanup(func() {
7070 if l.lands == 0 {
7171 t.Error("no spring ever ran: the animations-on variant checked nothing")
@@ -106,8 +106,9 @@ func owner(t *testing.T, animated bool) {
106106 scenes := make(chan []ports.Scene, 1)
107107 errs := make(chan error, 8)
108108 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}
109- l := newLanding(t, animated, &cfg, &ch, "OUT-1")
110- c, err := core.New(cfg, ch)
109+ opts := core.Options{}
110+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
111+ c, err := core.New(cfg, ch, opts)
111112 if err != nil {
112113 t.Fatal(err)
113114 }
@@ -186,11 +187,11 @@ func owner(t *testing.T, animated bool) {
186187 }
187188 func TestCancelAndSceneCapacity(t *testing.T) {
188189 cfg := config.Defaults()
189- if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}); err == nil {
190+ if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}, core.Options{}); err == nil {
190191 t.Fatal("capacity")
191192 }
192193 ctx, cancel := context.WithCancel(context.Background())
193- c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)})
194+ c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}, core.Options{})
194195 if err != nil {
195196 t.Fatal(err)
196197 }
@@ -208,7 +209,7 @@ func TestKeyPressState(t *testing.T) {
208209 input := make(chan ports.InputEvent, 8)
209210 commands := make(chan ports.ClientCommand, 16)
210211 scenes := make(chan []ports.Scene, 1)
211- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes})
212+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes}, core.Options{})
212213 if err != nil {
213214 t.Fatal(err)
214215 }
@@ -248,7 +249,7 @@ func TestBoundReleaseSwallowed(t *testing.T) {
248249 client := make(chan ports.ClientEvent, 2)
249250 commands := make(chan ports.ClientCommand, 8)
250251 scenes := make(chan []ports.Scene, 1)
251- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
252+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}, core.Options{})
252253 if err != nil {
253254 t.Fatal(err)
254255 }
@@ -278,7 +279,7 @@ func TestPointerFocusAndGrab(t *testing.T) {
278279 output := make(chan ports.OutputEvent, 8)
279280 commands := make(chan ports.ClientCommand, 64)
280281 scenes := make(chan []ports.Scene, 1)
281- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
282+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
282283 if err != nil {
283284 t.Fatal(err)
284285 }
@@ -343,8 +344,9 @@ func borderInset(t *testing.T, animated bool) {
343344 commands := make(chan ports.ClientCommand, 64)
344345 scenes := make(chan []ports.Scene, 1)
345346 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
346- l := newLanding(t, animated, &cfg, &ch, "OUT-1")
347- c, err := core.New(cfg, ch)
347+ opts := core.Options{}
348+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
349+ c, err := core.New(cfg, ch, opts)
348350 if err != nil {
349351 t.Fatal(err)
350352 }
@@ -390,7 +392,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
390392 output := make(chan ports.OutputEvent, 8)
391393 commands := make(chan ports.ClientCommand, 64)
392394 scenes := make(chan []ports.Scene, 1)
393- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
395+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
394396 if err != nil {
395397 t.Fatal(err)
396398 }
@@ -469,13 +471,14 @@ func workspaceSwitch(t *testing.T, animated bool) {
469471 commands := make(chan ports.ClientCommand, 64)
470472 scenes := make(chan []ports.Scene, 1)
471473 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
474+ opts := core.Options{}
472475 var sc *stepClock
473476 frames := make(chan ports.OutputFrame)
474477 if animated {
475478 sc = newStepClock(t)
476- ch.Clock, ch.Frames = sc.clock, frames
479+ opts.Clock, ch.Frames = sc.clock, frames
477480 }
478- c, err := core.New(cfg, ch)
481+ c, err := core.New(cfg, ch, opts)
479482 if err != nil {
480483 t.Fatal(err)
481484 }
@@ -529,13 +532,14 @@ func clickAfterWorkspaceSwitch(t *testing.T, animated bool) {
529532 commands := make(chan ports.ClientCommand, 64)
530533 scenes := make(chan []ports.Scene, 1)
531534 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
535+ opts := core.Options{}
532536 var sc *stepClock
533537 frames := make(chan ports.OutputFrame)
534538 if animated {
535539 sc = newStepClock(t)
536- ch.Clock, ch.Frames = sc.clock, frames
540+ opts.Clock, ch.Frames = sc.clock, frames
537541 }
538- c, err := core.New(cfg, ch)
542+ c, err := core.New(cfg, ch, opts)
539543 if err != nil {
540544 t.Fatal(err)
541545 }
@@ -586,7 +590,7 @@ func TestShiftReleasedFirst(t *testing.T) {
586590 client := make(chan ports.ClientEvent, 2)
587591 commands := make(chan ports.ClientCommand, 16)
588592 scenes := make(chan []ports.Scene, 1)
589- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
593+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}, core.Options{})
590594 if err != nil {
591595 t.Fatal(err)
592596 }
@@ -624,8 +628,9 @@ func outputScale(t *testing.T, animated bool) {
624628 commands := make(chan ports.ClientCommand, 64)
625629 scenes := make(chan []ports.Scene, 1)
626630 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}
627- l := newLanding(t, animated, &cfg, &ch, "DP-2")
628- c, err := core.New(cfg, ch)
631+ opts := core.Options{}
632+ l := newLanding(t, animated, &cfg, &ch, &opts, "DP-2")
633+ c, err := core.New(cfg, ch, opts)
629634 if err != nil {
630635 t.Fatal(err)
631636 }
@@ -702,7 +707,7 @@ func TestPointerWarp(t *testing.T) {
702707 commands := make(chan ports.ClientCommand, 64)
703708 scenes := make(chan []ports.Scene, 1)
704709 constraints := make(chan ports.PointerConstraint, 1)
705- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})
710+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}, core.Options{})
706711 if err != nil {
707712 t.Fatal(err)
708713 }
@@ -755,8 +760,9 @@ func pointerConstraint(t *testing.T, animated bool) {
755760 scenes := make(chan []ports.Scene, 1)
756761 constraints := make(chan ports.PointerConstraint, 1)
757762 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}
758- l := newLanding(t, animated, &cfg, &ch, "A", "B")
759- c, err := core.New(cfg, ch)
763+ opts := core.Options{}
764+ l := newLanding(t, animated, &cfg, &ch, &opts, "A", "B")
765+ c, err := core.New(cfg, ch, opts)
760766 if err != nil {
761767 t.Fatal(err)
762768 }
@@ -826,7 +832,7 @@ func TestPopupChainDismissOrder(t *testing.T) {
826832 output := make(chan ports.OutputEvent, 8)
827833 commands := make(chan ports.ClientCommand, 64)
828834 scenes := make(chan []ports.Scene, 1)
829- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
835+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
830836 if err != nil {
831837 t.Fatal(err)
832838 }
@@ -875,7 +881,7 @@ func TestFloatOverFullscreenHit(t *testing.T) {
875881 output := make(chan ports.OutputEvent, 8)
876882 commands := make(chan ports.ClientCommand, 64)
877883 scenes := make(chan []ports.Scene, 1)
878- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
884+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
879885 if err != nil {
880886 t.Fatal(err)
881887 }
@@ -918,7 +924,7 @@ func TestDialogOverFullscreenHit(t *testing.T) {
918924 output := make(chan ports.OutputEvent, 8)
919925 commands := make(chan ports.ClientCommand, 64)
920926 scenes := make(chan []ports.Scene, 1)
921- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
927+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
922928 if err != nil {
923929 t.Fatal(err)
924930 }
@@ -966,13 +972,14 @@ func fullscreenAtMapIgnored(t *testing.T, animated bool) {
966972 scenes := make(chan []ports.Scene, 1)
967973 // Core reads the time on its goroutine: the clock is the test's.
968974 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
969- l := newLanding(t, animated, &cfg, &ch, "OUT-1")
975+ opts := core.Options{}
976+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
970977 clk := l.clk
971978 if clk == nil {
972979 clk = newStepClock(t)
973- ch.Clock = clk.clock
980+ opts.Clock = clk.clock
974981 }
975- c, err := core.New(cfg, ch)
982+ c, err := core.New(cfg, ch, opts)
976983 if err != nil {
977984 t.Fatal(err)
978985 }
@@ -1054,11 +1061,12 @@ func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
10541061 commands := make(chan ports.ClientCommand, 64)
10551062 scenes := make(chan []ports.Scene, 1)
10561063 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
1057- l := newLanding(t, animated, &cfg, &ch, "OUT-1")
1064+ opts := core.Options{}
1065+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
10581066 if l.clk == nil {
1059- ch.Clock = newStepClock(t).clock
1067+ opts.Clock = newStepClock(t).clock
10601068 }
1061- c, err := core.New(cfg, ch)
1069+ c, err := core.New(cfg, ch, opts)
10621070 if err != nil {
10631071 t.Fatal(err)
10641072 }
@@ -1193,7 +1201,7 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
11931201 output := make(chan ports.OutputEvent, 8)
11941202 commands := make(chan ports.ClientCommand, 64)
11951203 scenes := make(chan []ports.Scene, 1)
1196- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
1204+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
11971205 if err != nil {
11981206 t.Fatal(err)
11991207 }
@@ -58,14 +58,14 @@ type modeLanding struct {
58 lands int58 lands int
59 }59 }
60 60
61-// newLanding sets cfg's animations and, when on, the clock and flips of ch.61+// newLanding sets cfg's animations and, when on, the clock of opts and flips of ch.
62-func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, outputs ...string) *modeLanding {62+func newLanding(t *testing.T, animated bool, cfg *ports.Config, ch *core.Channels, opts *core.Options, outputs ...string) *modeLanding {
63 t.Helper()63 t.Helper()
64 cfg.Animations.On = animated64 cfg.Animations.On = animated
65 l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs}65 l := &modeLanding{t: t, scenes: ch.Scenes, outputs: outputs}
66 if animated {66 if animated {
67 l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame)67 l.clk, l.frames = newStepClock(t), make(chan ports.OutputFrame)
68- ch.Clock, ch.Frames = l.clk.clock, l.frames68+ opts.Clock, ch.Frames = l.clk.clock, l.frames
69 t.Cleanup(func() {69 t.Cleanup(func() {
70 if l.lands == 0 {70 if l.lands == 0 {
71 t.Error("no spring ever ran: the animations-on variant checked nothing")71 t.Error("no spring ever ran: the animations-on variant checked nothing")
@@ -106,8 +106,9 @@ func owner(t *testing.T, animated bool) {
106 scenes := make(chan []ports.Scene, 1)106 scenes := make(chan []ports.Scene, 1)
107 errs := make(chan error, 8)107 errs := make(chan error, 8)
108 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}108 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}
109- l := newLanding(t, animated, &cfg, &ch, "OUT-1")109+ opts := core.Options{}
110- c, err := core.New(cfg, ch)110+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
111+ c, err := core.New(cfg, ch, opts)
111 if err != nil {112 if err != nil {
112 t.Fatal(err)113 t.Fatal(err)
113 }114 }
@@ -186,11 +187,11 @@ func owner(t *testing.T, animated bool) {
186 }187 }
187 func TestCancelAndSceneCapacity(t *testing.T) {188 func TestCancelAndSceneCapacity(t *testing.T) {
188 cfg := config.Defaults()189 cfg := config.Defaults()
189- if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}); err == nil {190+ if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}, core.Options{}); err == nil {
190 t.Fatal("capacity")191 t.Fatal("capacity")
191 }192 }
192 ctx, cancel := context.WithCancel(context.Background())193 ctx, cancel := context.WithCancel(context.Background())
193- c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)})194+ c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)}, core.Options{})
194 if err != nil {195 if err != nil {
195 t.Fatal(err)196 t.Fatal(err)
196 }197 }
@@ -208,7 +209,7 @@ func TestKeyPressState(t *testing.T) {
208 input := make(chan ports.InputEvent, 8)209 input := make(chan ports.InputEvent, 8)
209 commands := make(chan ports.ClientCommand, 16)210 commands := make(chan ports.ClientCommand, 16)
210 scenes := make(chan []ports.Scene, 1)211 scenes := make(chan []ports.Scene, 1)
211- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes})212+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes}, core.Options{})
212 if err != nil {213 if err != nil {
213 t.Fatal(err)214 t.Fatal(err)
214 }215 }
@@ -248,7 +249,7 @@ func TestBoundReleaseSwallowed(t *testing.T) {
248 client := make(chan ports.ClientEvent, 2)249 client := make(chan ports.ClientEvent, 2)
249 commands := make(chan ports.ClientCommand, 8)250 commands := make(chan ports.ClientCommand, 8)
250 scenes := make(chan []ports.Scene, 1)251 scenes := make(chan []ports.Scene, 1)
251- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})252+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}, core.Options{})
252 if err != nil {253 if err != nil {
253 t.Fatal(err)254 t.Fatal(err)
254 }255 }
@@ -278,7 +279,7 @@ func TestPointerFocusAndGrab(t *testing.T) {
278 output := make(chan ports.OutputEvent, 8)279 output := make(chan ports.OutputEvent, 8)
279 commands := make(chan ports.ClientCommand, 64)280 commands := make(chan ports.ClientCommand, 64)
280 scenes := make(chan []ports.Scene, 1)281 scenes := make(chan []ports.Scene, 1)
281- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})282+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
282 if err != nil {283 if err != nil {
283 t.Fatal(err)284 t.Fatal(err)
284 }285 }
@@ -343,8 +344,9 @@ func borderInset(t *testing.T, animated bool) {
343 commands := make(chan ports.ClientCommand, 64)344 commands := make(chan ports.ClientCommand, 64)
344 scenes := make(chan []ports.Scene, 1)345 scenes := make(chan []ports.Scene, 1)
345 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}346 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
346- l := newLanding(t, animated, &cfg, &ch, "OUT-1")347+ opts := core.Options{}
347- c, err := core.New(cfg, ch)348+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
349+ c, err := core.New(cfg, ch, opts)
348 if err != nil {350 if err != nil {
349 t.Fatal(err)351 t.Fatal(err)
350 }352 }
@@ -390,7 +392,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
390 output := make(chan ports.OutputEvent, 8)392 output := make(chan ports.OutputEvent, 8)
391 commands := make(chan ports.ClientCommand, 64)393 commands := make(chan ports.ClientCommand, 64)
392 scenes := make(chan []ports.Scene, 1)394 scenes := make(chan []ports.Scene, 1)
393- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})395+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
394 if err != nil {396 if err != nil {
395 t.Fatal(err)397 t.Fatal(err)
396 }398 }
@@ -469,13 +471,14 @@ func workspaceSwitch(t *testing.T, animated bool) {
469 commands := make(chan ports.ClientCommand, 64)471 commands := make(chan ports.ClientCommand, 64)
470 scenes := make(chan []ports.Scene, 1)472 scenes := make(chan []ports.Scene, 1)
471 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}473 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
474+ opts := core.Options{}
472 var sc *stepClock475 var sc *stepClock
473 frames := make(chan ports.OutputFrame)476 frames := make(chan ports.OutputFrame)
474 if animated {477 if animated {
475 sc = newStepClock(t)478 sc = newStepClock(t)
476- ch.Clock, ch.Frames = sc.clock, frames479+ opts.Clock, ch.Frames = sc.clock, frames
477 }480 }
478- c, err := core.New(cfg, ch)481+ c, err := core.New(cfg, ch, opts)
479 if err != nil {482 if err != nil {
480 t.Fatal(err)483 t.Fatal(err)
481 }484 }
@@ -529,13 +532,14 @@ func clickAfterWorkspaceSwitch(t *testing.T, animated bool) {
529 commands := make(chan ports.ClientCommand, 64)532 commands := make(chan ports.ClientCommand, 64)
530 scenes := make(chan []ports.Scene, 1)533 scenes := make(chan []ports.Scene, 1)
531 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}534 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
535+ opts := core.Options{}
532 var sc *stepClock536 var sc *stepClock
533 frames := make(chan ports.OutputFrame)537 frames := make(chan ports.OutputFrame)
534 if animated {538 if animated {
535 sc = newStepClock(t)539 sc = newStepClock(t)
536- ch.Clock, ch.Frames = sc.clock, frames540+ opts.Clock, ch.Frames = sc.clock, frames
537 }541 }
538- c, err := core.New(cfg, ch)542+ c, err := core.New(cfg, ch, opts)
539 if err != nil {543 if err != nil {
540 t.Fatal(err)544 t.Fatal(err)
541 }545 }
@@ -586,7 +590,7 @@ func TestShiftReleasedFirst(t *testing.T) {
586 client := make(chan ports.ClientEvent, 2)590 client := make(chan ports.ClientEvent, 2)
587 commands := make(chan ports.ClientCommand, 16)591 commands := make(chan ports.ClientCommand, 16)
588 scenes := make(chan []ports.Scene, 1)592 scenes := make(chan []ports.Scene, 1)
589- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})593+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}, core.Options{})
590 if err != nil {594 if err != nil {
591 t.Fatal(err)595 t.Fatal(err)
592 }596 }
@@ -624,8 +628,9 @@ func outputScale(t *testing.T, animated bool) {
624 commands := make(chan ports.ClientCommand, 64)628 commands := make(chan ports.ClientCommand, 64)
625 scenes := make(chan []ports.Scene, 1)629 scenes := make(chan []ports.Scene, 1)
626 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}630 ch := core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}
627- l := newLanding(t, animated, &cfg, &ch, "DP-2")631+ opts := core.Options{}
628- c, err := core.New(cfg, ch)632+ l := newLanding(t, animated, &cfg, &ch, &opts, "DP-2")
633+ c, err := core.New(cfg, ch, opts)
629 if err != nil {634 if err != nil {
630 t.Fatal(err)635 t.Fatal(err)
631 }636 }
@@ -702,7 +707,7 @@ func TestPointerWarp(t *testing.T) {
702 commands := make(chan ports.ClientCommand, 64)707 commands := make(chan ports.ClientCommand, 64)
703 scenes := make(chan []ports.Scene, 1)708 scenes := make(chan []ports.Scene, 1)
704 constraints := make(chan ports.PointerConstraint, 1)709 constraints := make(chan ports.PointerConstraint, 1)
705- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints})710+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}, core.Options{})
706 if err != nil {711 if err != nil {
707 t.Fatal(err)712 t.Fatal(err)
708 }713 }
@@ -755,8 +760,9 @@ func pointerConstraint(t *testing.T, animated bool) {
755 scenes := make(chan []ports.Scene, 1)760 scenes := make(chan []ports.Scene, 1)
756 constraints := make(chan ports.PointerConstraint, 1)761 constraints := make(chan ports.PointerConstraint, 1)
757 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}762 ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Constraints: constraints}
758- l := newLanding(t, animated, &cfg, &ch, "A", "B")763+ opts := core.Options{}
759- c, err := core.New(cfg, ch)764+ l := newLanding(t, animated, &cfg, &ch, &opts, "A", "B")
765+ c, err := core.New(cfg, ch, opts)
760 if err != nil {766 if err != nil {
761 t.Fatal(err)767 t.Fatal(err)
762 }768 }
@@ -826,7 +832,7 @@ func TestPopupChainDismissOrder(t *testing.T) {
826 output := make(chan ports.OutputEvent, 8)832 output := make(chan ports.OutputEvent, 8)
827 commands := make(chan ports.ClientCommand, 64)833 commands := make(chan ports.ClientCommand, 64)
828 scenes := make(chan []ports.Scene, 1)834 scenes := make(chan []ports.Scene, 1)
829- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})835+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
830 if err != nil {836 if err != nil {
831 t.Fatal(err)837 t.Fatal(err)
832 }838 }
@@ -875,7 +881,7 @@ func TestFloatOverFullscreenHit(t *testing.T) {
875 output := make(chan ports.OutputEvent, 8)881 output := make(chan ports.OutputEvent, 8)
876 commands := make(chan ports.ClientCommand, 64)882 commands := make(chan ports.ClientCommand, 64)
877 scenes := make(chan []ports.Scene, 1)883 scenes := make(chan []ports.Scene, 1)
878- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})884+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
879 if err != nil {885 if err != nil {
880 t.Fatal(err)886 t.Fatal(err)
881 }887 }
@@ -918,7 +924,7 @@ func TestDialogOverFullscreenHit(t *testing.T) {
918 output := make(chan ports.OutputEvent, 8)924 output := make(chan ports.OutputEvent, 8)
919 commands := make(chan ports.ClientCommand, 64)925 commands := make(chan ports.ClientCommand, 64)
920 scenes := make(chan []ports.Scene, 1)926 scenes := make(chan []ports.Scene, 1)
921- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})927+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
922 if err != nil {928 if err != nil {
923 t.Fatal(err)929 t.Fatal(err)
924 }930 }
@@ -966,13 +972,14 @@ func fullscreenAtMapIgnored(t *testing.T, animated bool) {
966 scenes := make(chan []ports.Scene, 1)972 scenes := make(chan []ports.Scene, 1)
967 // Core reads the time on its goroutine: the clock is the test's.973 // Core reads the time on its goroutine: the clock is the test's.
968 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}974 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
969- l := newLanding(t, animated, &cfg, &ch, "OUT-1")975+ opts := core.Options{}
976+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
970 clk := l.clk977 clk := l.clk
971 if clk == nil {978 if clk == nil {
972 clk = newStepClock(t)979 clk = newStepClock(t)
973- ch.Clock = clk.clock980+ opts.Clock = clk.clock
974 }981 }
975- c, err := core.New(cfg, ch)982+ c, err := core.New(cfg, ch, opts)
976 if err != nil {983 if err != nil {
977 t.Fatal(err)984 t.Fatal(err)
978 }985 }
@@ -1054,11 +1061,12 @@ func externalFullscreenAtMapApplies(t *testing.T, animated bool) {
1054 commands := make(chan ports.ClientCommand, 64)1061 commands := make(chan ports.ClientCommand, 64)
1055 scenes := make(chan []ports.Scene, 1)1062 scenes := make(chan []ports.Scene, 1)
1056 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}1063 ch := core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}
1057- l := newLanding(t, animated, &cfg, &ch, "OUT-1")1064+ opts := core.Options{}
1065+ l := newLanding(t, animated, &cfg, &ch, &opts, "OUT-1")
1058 if l.clk == nil {1066 if l.clk == nil {
1059- ch.Clock = newStepClock(t).clock1067+ opts.Clock = newStepClock(t).clock
1060 }1068 }
1061- c, err := core.New(cfg, ch)1069+ c, err := core.New(cfg, ch, opts)
1062 if err != nil {1070 if err != nil {
1063 t.Fatal(err)1071 t.Fatal(err)
1064 }1072 }
@@ -1193,7 +1201,7 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
1193 output := make(chan ports.OutputEvent, 8)1201 output := make(chan ports.OutputEvent, 8)
1194 commands := make(chan ports.ClientCommand, 64)1202 commands := make(chan ports.ClientCommand, 64)
1195 scenes := make(chan []ports.Scene, 1)1203 scenes := make(chan []ports.Scene, 1)
1196- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})1204+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
1197 if err != nil {1205 if err != nil {
1198 t.Fatal(err)1206 t.Fatal(err)
1199 }1207 }
M internal/core/drag_internal_test.go +1 -1
@@ -96,7 +96,7 @@ func dragCore(t *testing.T) *Core {
9696 cfg := ports.Config{}
9797 cfg.Keyboard.CmdKey = "super"
9898 cfg.Layout.MaxColumns = 3
99- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 1024)})
99+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 1024)}, Options{})
100100 if err != nil {
101101 t.Fatal(err)
102102 }
@@ -96,7 +96,7 @@ func dragCore(t *testing.T) *Core {
96 cfg := ports.Config{}96 cfg := ports.Config{}
97 cfg.Keyboard.CmdKey = "super"97 cfg.Keyboard.CmdKey = "super"
98 cfg.Layout.MaxColumns = 398 cfg.Layout.MaxColumns = 3
99- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 1024)})99+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 1024)}, Options{})
100 if err != nil {100 if err != nil {
101 t.Fatal(err)101 t.Fatal(err)
102 }102 }
M internal/core/drag_test.go +4 -3
@@ -71,10 +71,11 @@ func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...p
7171 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
7272 scenes: make(chan []ports.Scene, 1), epoch: epoch,
7373 }
74- ch := core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
74+ ch := core.Channels{Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
75+ opts := core.Options{Security: gate}
7576 if animated {
7677 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
77- ch.Frames, ch.Clock = r.frames, r.clock.clock
78+ ch.Frames, opts.Clock = r.frames, r.clock.clock
7879 // An on variant that never saw a spring would be the off variant again.
7980 t.Cleanup(func() {
8081 if r.lands == 0 {
@@ -82,7 +83,7 @@ func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...p
8283 }
8384 })
8485 }
85- c, err := core.New(cfg, ch)
86+ c, err := core.New(cfg, ch, opts)
8687 if err != nil {
8788 t.Fatal(err)
8889 }
@@ -71,10 +71,11 @@ func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...p
71 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),71 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
72 scenes: make(chan []ports.Scene, 1), epoch: epoch,72 scenes: make(chan []ports.Scene, 1), epoch: epoch,
73 }73 }
74- ch := core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}74+ ch := core.Channels{Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
75+ opts := core.Options{Security: gate}
75 if animated {76 if animated {
76 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)77 r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
77- ch.Frames, ch.Clock = r.frames, r.clock.clock78+ ch.Frames, opts.Clock = r.frames, r.clock.clock
78 // An on variant that never saw a spring would be the off variant again.79 // An on variant that never saw a spring would be the off variant again.
79 t.Cleanup(func() {80 t.Cleanup(func() {
80 if r.lands == 0 {81 if r.lands == 0 {
@@ -82,7 +83,7 @@ func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...p
82 }83 }
83 })84 })
84 }85 }
85- c, err := core.New(cfg, ch)86+ c, err := core.New(cfg, ch, opts)
86 if err != nil {87 if err != nil {
87 t.Fatal(err)88 t.Fatal(err)
88 }89 }
M internal/core/float_core_test.go +2 -2
@@ -15,7 +15,7 @@ func TestCoreCoveringFloatSceneAndHit(t *testing.T) {
1515 cfg.Floating.Dim = 0.3
1616 scenes := make(chan []ports.Scene, 1)
1717 commands := make(chan ports.ClientCommand, 128)
18- c, err := New(cfg, Channels{Scenes: scenes, Commands: commands})
18+ c, err := New(cfg, Channels{Scenes: scenes, Commands: commands}, Options{})
1919 if err != nil {
2020 t.Fatal(err)
2121 }
@@ -76,7 +76,7 @@ func TestCoreOverviewDimsBehind(t *testing.T) {
7676 cfg.Layout.MaxColumns = 2
7777 cfg.Floating.Dim = 0.3
7878 scenes := make(chan []ports.Scene, 1)
79- c, err := New(cfg, Channels{Scenes: scenes, Commands: make(chan ports.ClientCommand, 128)})
79+ c, err := New(cfg, Channels{Scenes: scenes, Commands: make(chan ports.ClientCommand, 128)}, Options{})
8080 if err != nil {
8181 t.Fatal(err)
8282 }
@@ -15,7 +15,7 @@ func TestCoreCoveringFloatSceneAndHit(t *testing.T) {
15 cfg.Floating.Dim = 0.315 cfg.Floating.Dim = 0.3
16 scenes := make(chan []ports.Scene, 1)16 scenes := make(chan []ports.Scene, 1)
17 commands := make(chan ports.ClientCommand, 128)17 commands := make(chan ports.ClientCommand, 128)
18- c, err := New(cfg, Channels{Scenes: scenes, Commands: commands})18+ c, err := New(cfg, Channels{Scenes: scenes, Commands: commands}, Options{})
19 if err != nil {19 if err != nil {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
@@ -76,7 +76,7 @@ func TestCoreOverviewDimsBehind(t *testing.T) {
76 cfg.Layout.MaxColumns = 276 cfg.Layout.MaxColumns = 2
77 cfg.Floating.Dim = 0.377 cfg.Floating.Dim = 0.3
78 scenes := make(chan []ports.Scene, 1)78 scenes := make(chan []ports.Scene, 1)
79- c, err := New(cfg, Channels{Scenes: scenes, Commands: make(chan ports.ClientCommand, 128)})79+ c, err := New(cfg, Channels{Scenes: scenes, Commands: make(chan ports.ClientCommand, 128)}, Options{})
80 if err != nil {80 if err != nil {
81 t.Fatal(err)81 t.Fatal(err)
82 }82 }
M internal/core/floating_test.go +1 -1
@@ -415,7 +415,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
415415 cfg.Border.Width = 2
416416 commands := make(chan ports.ClientCommand, 128)
417417 scenes := make(chan []ports.Scene, 1)
418- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
418+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
419419 if err != nil {
420420 t.Fatal(err)
421421 }
@@ -415,7 +415,7 @@ func TestFloatingSceneDimAndConfigure(t *testing.T) {
415 cfg.Border.Width = 2415 cfg.Border.Width = 2
416 commands := make(chan ports.ClientCommand, 128)416 commands := make(chan ports.ClientCommand, 128)
417 scenes := make(chan []ports.Scene, 1)417 scenes := make(chan []ports.Scene, 1)
418- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})418+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
419 if err != nil {419 if err != nil {
420 t.Fatal(err)420 t.Fatal(err)
421 }421 }
M internal/core/fullscreen_left_core_test.go +1 -1
@@ -19,7 +19,7 @@ func TestCoreLeftFullscreenLatch(t *testing.T) {
1919 cfg := ports.Config{}
2020 cfg.Keyboard.CmdKey = "super"
2121 cfg.Layout.MaxColumns = 2
22- c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)})
22+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, Options{Clock: laterClock(t)})
2323 if err != nil {
2424 t.Fatal(err)
2525 }
@@ -19,7 +19,7 @@ func TestCoreLeftFullscreenLatch(t *testing.T) {
19 cfg := ports.Config{}19 cfg := ports.Config{}
20 cfg.Keyboard.CmdKey = "super"20 cfg.Keyboard.CmdKey = "super"
21 cfg.Layout.MaxColumns = 221 cfg.Layout.MaxColumns = 2
22- c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)})22+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, Options{Clock: laterClock(t)})
23 if err != nil {23 if err != nil {
24 t.Fatal(err)24 t.Fatal(err)
25 }25 }
M internal/core/gesture.go +1 -1
@@ -576,7 +576,7 @@ func (c *Core) frameFallback() time.Duration {
576576 func (c *Core) armFrame() {
577577 d := c.frameFallback()
578578 if c.frameTimer == nil {
579- c.frameTimer = newPortTimer(c.ch.Clock, d)
579+ c.frameTimer = newPortTimer(c.opts.Clock, d)
580580 } else {
581581 c.frameTimer.Stop()
582582 c.frameTimer.Reset(d)
@@ -576,7 +576,7 @@ func (c *Core) frameFallback() time.Duration {
576 func (c *Core) armFrame() {576 func (c *Core) armFrame() {
577 d := c.frameFallback()577 d := c.frameFallback()
578 if c.frameTimer == nil {578 if c.frameTimer == nil {
579- c.frameTimer = newPortTimer(c.ch.Clock, d)579+ c.frameTimer = newPortTimer(c.opts.Clock, d)
580 } else {580 } else {
581 c.frameTimer.Stop()581 c.frameTimer.Stop()
582 c.frameTimer.Reset(d)582 c.frameTimer.Reset(d)
M internal/core/inhibit_test.go +2 -2
@@ -39,7 +39,7 @@ func TestShortcutsInhibit(t *testing.T) {
3939 spawn := make(chan ports.SpawnRequest, 4)
4040 state := make(chan ports.State, 1)
4141 output := make(chan ports.OutputEvent, 1)
42- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn, State: state})
42+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn, State: state}, core.Options{})
4343 if err != nil {
4444 t.Fatal(err)
4545 }
@@ -99,7 +99,7 @@ func TestShortcutsInhibitDropsBoundRelease(t *testing.T) {
9999 scenes := make(chan []ports.Scene, 1)
100100 spawn := make(chan ports.SpawnRequest, 4)
101101 output := make(chan ports.OutputEvent, 1)
102- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn})
102+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn}, core.Options{})
103103 if err != nil {
104104 t.Fatal(err)
105105 }
@@ -39,7 +39,7 @@ func TestShortcutsInhibit(t *testing.T) {
39 spawn := make(chan ports.SpawnRequest, 4)39 spawn := make(chan ports.SpawnRequest, 4)
40 state := make(chan ports.State, 1)40 state := make(chan ports.State, 1)
41 output := make(chan ports.OutputEvent, 1)41 output := make(chan ports.OutputEvent, 1)
42- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn, State: state})42+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn, State: state}, core.Options{})
43 if err != nil {43 if err != nil {
44 t.Fatal(err)44 t.Fatal(err)
45 }45 }
@@ -99,7 +99,7 @@ func TestShortcutsInhibitDropsBoundRelease(t *testing.T) {
99 scenes := make(chan []ports.Scene, 1)99 scenes := make(chan []ports.Scene, 1)
100 spawn := make(chan ports.SpawnRequest, 4)100 spawn := make(chan ports.SpawnRequest, 4)
101 output := make(chan ports.OutputEvent, 1)101 output := make(chan ports.OutputEvent, 1)
102- c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn})102+ c, err := core.New(cfg, core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: spawn}, core.Options{})
103 if err != nil {103 if err != nil {
104 t.Fatal(err)104 t.Fatal(err)
105 }105 }
M internal/core/layer_input_test.go +8 -8
@@ -30,7 +30,7 @@ func TestLayerPointerPopupAndFocus(t *testing.T) {
3030 output := make(chan ports.OutputEvent, 8)
3131 commands := make(chan ports.ClientCommand, 256)
3232 scenes := make(chan []ports.Scene, 1)
33- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
33+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
3434 if err != nil {
3535 t.Fatal(err)
3636 }
@@ -149,7 +149,7 @@ func TestWindowPopupUnderOverlay(t *testing.T) {
149149 output := make(chan ports.OutputEvent, 8)
150150 commands := make(chan ports.ClientCommand, 256)
151151 scenes := make(chan []ports.Scene, 1)
152- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
152+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
153153 if err != nil {
154154 t.Fatal(err)
155155 }
@@ -186,7 +186,7 @@ func TestLayerHitOrder(t *testing.T) {
186186 output := make(chan ports.OutputEvent, 8)
187187 commands := make(chan ports.ClientCommand, 256)
188188 scenes := make(chan []ports.Scene, 1)
189- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
189+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
190190 if err != nil {
191191 t.Fatal(err)
192192 }
@@ -232,7 +232,7 @@ func TestLayerFocusAcrossOutputs(t *testing.T) {
232232 output := make(chan ports.OutputEvent, 8)
233233 commands := make(chan ports.ClientCommand, 256)
234234 scenes := make(chan []ports.Scene, 1)
235- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
235+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
236236 if err != nil {
237237 t.Fatal(err)
238238 }
@@ -280,7 +280,7 @@ func TestLayerInputRegionPassThrough(t *testing.T) {
280280 output := make(chan ports.OutputEvent, 16)
281281 commands := make(chan ports.ClientCommand, 256)
282282 scenes := make(chan []ports.Scene, 1)
283- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
283+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
284284 if err != nil {
285285 t.Fatal(err)
286286 }
@@ -325,7 +325,7 @@ func TestRemovedLayerInputRegionCleared(t *testing.T) {
325325 output := make(chan ports.OutputEvent, 16)
326326 commands := make(chan ports.ClientCommand, 256)
327327 scenes := make(chan []ports.Scene, 1)
328- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
328+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
329329 if err != nil {
330330 t.Fatal(err)
331331 }
@@ -367,7 +367,7 @@ func TestPopupInputRegionPassesThroughToWindow(t *testing.T) {
367367 output := make(chan ports.OutputEvent, 16)
368368 commands := make(chan ports.ClientCommand, 256)
369369 scenes := make(chan []ports.Scene, 1)
370- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
370+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
371371 if err != nil {
372372 t.Fatal(err)
373373 }
@@ -404,7 +404,7 @@ func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
404404 output := make(chan ports.OutputEvent, 8)
405405 commands := make(chan ports.ClientCommand, 64)
406406 scenes := make(chan []ports.Scene, 1)
407- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})
407+ c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
408408 if err != nil {
409409 t.Fatal(err)
410410 }
@@ -30,7 +30,7 @@ func TestLayerPointerPopupAndFocus(t *testing.T) {
30 output := make(chan ports.OutputEvent, 8)30 output := make(chan ports.OutputEvent, 8)
31 commands := make(chan ports.ClientCommand, 256)31 commands := make(chan ports.ClientCommand, 256)
32 scenes := make(chan []ports.Scene, 1)32 scenes := make(chan []ports.Scene, 1)
33- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})33+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
34 if err != nil {34 if err != nil {
35 t.Fatal(err)35 t.Fatal(err)
36 }36 }
@@ -149,7 +149,7 @@ func TestWindowPopupUnderOverlay(t *testing.T) {
149 output := make(chan ports.OutputEvent, 8)149 output := make(chan ports.OutputEvent, 8)
150 commands := make(chan ports.ClientCommand, 256)150 commands := make(chan ports.ClientCommand, 256)
151 scenes := make(chan []ports.Scene, 1)151 scenes := make(chan []ports.Scene, 1)
152- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})152+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
153 if err != nil {153 if err != nil {
154 t.Fatal(err)154 t.Fatal(err)
155 }155 }
@@ -186,7 +186,7 @@ func TestLayerHitOrder(t *testing.T) {
186 output := make(chan ports.OutputEvent, 8)186 output := make(chan ports.OutputEvent, 8)
187 commands := make(chan ports.ClientCommand, 256)187 commands := make(chan ports.ClientCommand, 256)
188 scenes := make(chan []ports.Scene, 1)188 scenes := make(chan []ports.Scene, 1)
189- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})189+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
190 if err != nil {190 if err != nil {
191 t.Fatal(err)191 t.Fatal(err)
192 }192 }
@@ -232,7 +232,7 @@ func TestLayerFocusAcrossOutputs(t *testing.T) {
232 output := make(chan ports.OutputEvent, 8)232 output := make(chan ports.OutputEvent, 8)
233 commands := make(chan ports.ClientCommand, 256)233 commands := make(chan ports.ClientCommand, 256)
234 scenes := make(chan []ports.Scene, 1)234 scenes := make(chan []ports.Scene, 1)
235- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})235+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
236 if err != nil {236 if err != nil {
237 t.Fatal(err)237 t.Fatal(err)
238 }238 }
@@ -280,7 +280,7 @@ func TestLayerInputRegionPassThrough(t *testing.T) {
280 output := make(chan ports.OutputEvent, 16)280 output := make(chan ports.OutputEvent, 16)
281 commands := make(chan ports.ClientCommand, 256)281 commands := make(chan ports.ClientCommand, 256)
282 scenes := make(chan []ports.Scene, 1)282 scenes := make(chan []ports.Scene, 1)
283- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})283+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
284 if err != nil {284 if err != nil {
285 t.Fatal(err)285 t.Fatal(err)
286 }286 }
@@ -325,7 +325,7 @@ func TestRemovedLayerInputRegionCleared(t *testing.T) {
325 output := make(chan ports.OutputEvent, 16)325 output := make(chan ports.OutputEvent, 16)
326 commands := make(chan ports.ClientCommand, 256)326 commands := make(chan ports.ClientCommand, 256)
327 scenes := make(chan []ports.Scene, 1)327 scenes := make(chan []ports.Scene, 1)
328- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})328+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
329 if err != nil {329 if err != nil {
330 t.Fatal(err)330 t.Fatal(err)
331 }331 }
@@ -367,7 +367,7 @@ func TestPopupInputRegionPassesThroughToWindow(t *testing.T) {
367 output := make(chan ports.OutputEvent, 16)367 output := make(chan ports.OutputEvent, 16)
368 commands := make(chan ports.ClientCommand, 256)368 commands := make(chan ports.ClientCommand, 256)
369 scenes := make(chan []ports.Scene, 1)369 scenes := make(chan []ports.Scene, 1)
370- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})370+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
371 if err != nil {371 if err != nil {
372 t.Fatal(err)372 t.Fatal(err)
373 }373 }
@@ -404,7 +404,7 @@ func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
404 output := make(chan ports.OutputEvent, 8)404 output := make(chan ports.OutputEvent, 8)
405 commands := make(chan ports.ClientCommand, 64)405 commands := make(chan ports.ClientCommand, 64)
406 scenes := make(chan []ports.Scene, 1)406 scenes := make(chan []ports.Scene, 1)
407- c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: steppingClock(t)})407+ c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
408 if err != nil {408 if err != nil {
409 t.Fatal(err)409 t.Fatal(err)
410 }410 }
M internal/core/layers_test.go +2 -2
@@ -71,7 +71,7 @@ func TestLayerChangedChannels(t *testing.T) {
7171 cfg := ports.Config{}
7272 cfg.Keyboard.CmdKey = "super"
7373 cfg.Layout.MaxColumns = 2
74- c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})
74+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, Options{})
7575 if err != nil {
7676 t.Fatal(err)
7777 }
@@ -171,7 +171,7 @@ func TestFullscreenSceneLayers(t *testing.T) {
171171 cfg := ports.Config{}
172172 cfg.Keyboard.CmdKey = "super"
173173 cfg.Layout.MaxColumns = 2
174- c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)})
174+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, Options{Clock: laterClock(t)})
175175 if err != nil {
176176 t.Fatal(err)
177177 }
@@ -71,7 +71,7 @@ func TestLayerChangedChannels(t *testing.T) {
71 cfg := ports.Config{}71 cfg := ports.Config{}
72 cfg.Keyboard.CmdKey = "super"72 cfg.Keyboard.CmdKey = "super"
73 cfg.Layout.MaxColumns = 273 cfg.Layout.MaxColumns = 2
74- c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes})74+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, Options{})
75 if err != nil {75 if err != nil {
76 t.Fatal(err)76 t.Fatal(err)
77 }77 }
@@ -171,7 +171,7 @@ func TestFullscreenSceneLayers(t *testing.T) {
171 cfg := ports.Config{}171 cfg := ports.Config{}
172 cfg.Keyboard.CmdKey = "super"172 cfg.Keyboard.CmdKey = "super"
173 cfg.Layout.MaxColumns = 2173 cfg.Layout.MaxColumns = 2
174- c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)})174+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes}, Options{Clock: laterClock(t)})
175 if err != nil {175 if err != nil {
176 t.Fatal(err)176 t.Fatal(err)
177 }177 }
M internal/core/overview_configure_test.go +2 -2
@@ -15,7 +15,7 @@ func TestOverviewNeighborPreviewKeepsHiddenConfigureSize(t *testing.T) {
1515 cfg.Layout.MaxColumns = 3
1616 commands := make(chan ports.ClientCommand, 256)
1717 scenes := make(chan []ports.Scene, 1)
18- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
18+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
1919 if err != nil {
2020 t.Fatal(err)
2121 }
@@ -72,7 +72,7 @@ func TestOverviewConfigureUsesRealLayoutAfterMaximize(t *testing.T) {
7272 cfg.Layout.MaxColumns = 3
7373 commands := make(chan ports.ClientCommand, 256)
7474 scenes := make(chan []ports.Scene, 1)
75- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
75+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
7676 if err != nil {
7777 t.Fatal(err)
7878 }
@@ -15,7 +15,7 @@ func TestOverviewNeighborPreviewKeepsHiddenConfigureSize(t *testing.T) {
15 cfg.Layout.MaxColumns = 315 cfg.Layout.MaxColumns = 3
16 commands := make(chan ports.ClientCommand, 256)16 commands := make(chan ports.ClientCommand, 256)
17 scenes := make(chan []ports.Scene, 1)17 scenes := make(chan []ports.Scene, 1)
18- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})18+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
19 if err != nil {19 if err != nil {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
@@ -72,7 +72,7 @@ func TestOverviewConfigureUsesRealLayoutAfterMaximize(t *testing.T) {
72 cfg.Layout.MaxColumns = 372 cfg.Layout.MaxColumns = 3
73 commands := make(chan ports.ClientCommand, 256)73 commands := make(chan ports.ClientCommand, 256)
74 scenes := make(chan []ports.Scene, 1)74 scenes := make(chan []ports.Scene, 1)
75- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})75+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
76 if err != nil {76 if err != nil {
77 t.Fatal(err)77 t.Fatal(err)
78 }78 }
M internal/core/overview_input_test.go +4 -4
@@ -18,7 +18,7 @@ func TestOverviewLauncherKeepsKeys(t *testing.T) {
1818 output := make(chan ports.OutputEvent, 8)
1919 commands := make(chan ports.ClientCommand, 256)
2020 scenes := make(chan []ports.Scene, 1)
21- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
21+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
2222 if err != nil {
2323 t.Fatal(err)
2424 }
@@ -71,7 +71,7 @@ func TestOverviewFocusBindsNavigate(t *testing.T) {
7171 output := make(chan ports.OutputEvent, 8)
7272 commands := make(chan ports.ClientCommand, 256)
7373 scenes := make(chan []ports.Scene, 1)
74- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
74+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
7575 if err != nil {
7676 t.Fatal(err)
7777 }
@@ -122,7 +122,7 @@ func TestOverviewCloseBindTargetsPreview(t *testing.T) {
122122 output := make(chan ports.OutputEvent, 8)
123123 commands := make(chan ports.ClientCommand, 256)
124124 scenes := make(chan []ports.Scene, 1)
125- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
125+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
126126 if err != nil {
127127 t.Fatal(err)
128128 }
@@ -194,7 +194,7 @@ func TestOverviewThreeFingerNavigation(t *testing.T) {
194194 output := make(chan ports.OutputEvent, 8)
195195 commands := make(chan ports.ClientCommand, 256)
196196 scenes := make(chan []ports.Scene, 1)
197- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
197+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
198198 if err != nil {
199199 t.Fatal(err)
200200 }
@@ -18,7 +18,7 @@ func TestOverviewLauncherKeepsKeys(t *testing.T) {
18 output := make(chan ports.OutputEvent, 8)18 output := make(chan ports.OutputEvent, 8)
19 commands := make(chan ports.ClientCommand, 256)19 commands := make(chan ports.ClientCommand, 256)
20 scenes := make(chan []ports.Scene, 1)20 scenes := make(chan []ports.Scene, 1)
21- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})21+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
22 if err != nil {22 if err != nil {
23 t.Fatal(err)23 t.Fatal(err)
24 }24 }
@@ -71,7 +71,7 @@ func TestOverviewFocusBindsNavigate(t *testing.T) {
71 output := make(chan ports.OutputEvent, 8)71 output := make(chan ports.OutputEvent, 8)
72 commands := make(chan ports.ClientCommand, 256)72 commands := make(chan ports.ClientCommand, 256)
73 scenes := make(chan []ports.Scene, 1)73 scenes := make(chan []ports.Scene, 1)
74- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})74+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
75 if err != nil {75 if err != nil {
76 t.Fatal(err)76 t.Fatal(err)
77 }77 }
@@ -122,7 +122,7 @@ func TestOverviewCloseBindTargetsPreview(t *testing.T) {
122 output := make(chan ports.OutputEvent, 8)122 output := make(chan ports.OutputEvent, 8)
123 commands := make(chan ports.ClientCommand, 256)123 commands := make(chan ports.ClientCommand, 256)
124 scenes := make(chan []ports.Scene, 1)124 scenes := make(chan []ports.Scene, 1)
125- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})125+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
126 if err != nil {126 if err != nil {
127 t.Fatal(err)127 t.Fatal(err)
128 }128 }
@@ -194,7 +194,7 @@ func TestOverviewThreeFingerNavigation(t *testing.T) {
194 output := make(chan ports.OutputEvent, 8)194 output := make(chan ports.OutputEvent, 8)
195 commands := make(chan ports.ClientCommand, 256)195 commands := make(chan ports.ClientCommand, 256)
196 scenes := make(chan []ports.Scene, 1)196 scenes := make(chan []ports.Scene, 1)
197- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})197+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
198 if err != nil {198 if err != nil {
199 t.Fatal(err)199 t.Fatal(err)
200 }200 }
M internal/core/overview_named_test.go +2 -2
@@ -124,7 +124,7 @@ func TestOverviewNamedAnchorSurvivesReplug(t *testing.T) {
124124 cfg.Layout.MaxColumns = 3
125125 cfg.Keyboard.CmdKey = "super"
126126 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}}
127- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
127+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
128128 if err != nil {
129129 t.Fatal(err)
130130 }
@@ -394,7 +394,7 @@ func TestOverviewNamedDividerPublished(t *testing.T) {
394394 cfg := ports.Config{}
395395 cfg.Keyboard.CmdKey = "super"
396396 cfg.Layout.MaxColumns = 3
397- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes})
397+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes}, Options{})
398398 if err != nil {
399399 t.Fatal(err)
400400 }
@@ -124,7 +124,7 @@ func TestOverviewNamedAnchorSurvivesReplug(t *testing.T) {
124 cfg.Layout.MaxColumns = 3124 cfg.Layout.MaxColumns = 3
125 cfg.Keyboard.CmdKey = "super"125 cfg.Keyboard.CmdKey = "super"
126 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}}126 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}}
127- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})127+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
128 if err != nil {128 if err != nil {
129 t.Fatal(err)129 t.Fatal(err)
130 }130 }
@@ -394,7 +394,7 @@ func TestOverviewNamedDividerPublished(t *testing.T) {
394 cfg := ports.Config{}394 cfg := ports.Config{}
395 cfg.Keyboard.CmdKey = "super"395 cfg.Keyboard.CmdKey = "super"
396 cfg.Layout.MaxColumns = 3396 cfg.Layout.MaxColumns = 3
397- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes})397+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes}, Options{})
398 if err != nil {398 if err != nil {
399 t.Fatal(err)399 t.Fatal(err)
400 }400 }
M internal/core/persist_test.go +1 -1
@@ -96,7 +96,7 @@ func TestScaleBindReportsScale(t *testing.T) {
9696 output := make(chan ports.OutputEvent, 8)
9797 scales := make(chan ports.ScaleChanged, 8)
9898 scenes := make(chan []ports.Scene, 1)
99- c, err := core.New(cfg, core.Channels{Input: input, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Scales: scales})
99+ c, err := core.New(cfg, core.Channels{Input: input, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Scales: scales}, core.Options{})
100100 if err != nil {
101101 t.Fatal(err)
102102 }
@@ -96,7 +96,7 @@ func TestScaleBindReportsScale(t *testing.T) {
96 output := make(chan ports.OutputEvent, 8)96 output := make(chan ports.OutputEvent, 8)
97 scales := make(chan ports.ScaleChanged, 8)97 scales := make(chan ports.ScaleChanged, 8)
98 scenes := make(chan []ports.Scene, 1)98 scenes := make(chan []ports.Scene, 1)
99- c, err := core.New(cfg, core.Channels{Input: input, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Scales: scales})99+ c, err := core.New(cfg, core.Channels{Input: input, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Scales: scales}, core.Options{})
100 if err != nil {100 if err != nil {
101 t.Fatal(err)101 t.Fatal(err)
102 }102 }
M internal/core/power.go +1 -2
@@ -2,7 +2,6 @@ package core
22
33 import (
44 "context"
5- "time"
65
76 "github.com/bnema/neferwl/internal/ports"
87 )
@@ -13,7 +12,7 @@ import (
1312 // they reconnect on.
1413 func (c *Core) userActivity(ctx context.Context) error {
1514 clear(c.offGone)
16- now := time.Now()
15+ now := c.now()
1716 if now.Sub(c.activity) < ports.ActivityInterval {
1817 return nil
1918 }
@@ -2,7 +2,6 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5- "time"
6 5
7 "github.com/bnema/neferwl/internal/ports"6 "github.com/bnema/neferwl/internal/ports"
8 )7 )
@@ -13,7 +12,7 @@ import (
13 // they reconnect on.12 // they reconnect on.
14 func (c *Core) userActivity(ctx context.Context) error {13 func (c *Core) userActivity(ctx context.Context) error {
15 clear(c.offGone)14 clear(c.offGone)
16- now := time.Now()15+ now := c.now()
17 if now.Sub(c.activity) < ports.ActivityInterval {16 if now.Sub(c.activity) < ports.ActivityInterval {
18 return nil17 return nil
19 }18 }
M internal/core/power_test.go +36 -1
@@ -12,13 +12,18 @@ import (
1212 )
1313
1414 func powerCore(t *testing.T) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {
15+ t.Helper()
16+ return powerCoreWith(t, core.Options{})
17+}
18+
19+func powerCoreWith(t *testing.T, opts core.Options) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {
1520 t.Helper()
1621 input := make(chan ports.InputEvent, 4)
1722 client := make(chan ports.ClientEvent, 4)
1823 output := make(chan ports.OutputEvent, 2)
1924 commands := make(chan ports.ClientCommand, 64)
2025 scenes := make(chan []ports.Scene, 1)
21- c, err := core.New(config.Defaults(), core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: make(chan ports.SpawnRequest, 4)})
26+ c, err := core.New(config.Defaults(), core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: make(chan ports.SpawnRequest, 4)}, opts)
2227 if err != nil {
2328 t.Fatal(err)
2429 }
@@ -164,3 +169,33 @@ func TestUserActivity(t *testing.T) {
164169 input <- ports.PointerMotion{X: 2, Y: 2}
165170 commandOf[ports.UserActivity](t, commands)
166171 }
172+
173+// With an injected clock, the throttle follows it: no second UserActivity
174+// until the clock has advanced past the interval.
175+func TestUserActivityFollowsClock(t *testing.T) {
176+ clk := newStepClock(t)
177+ input, _, _, commands, _ := powerCoreWith(t, core.Options{Clock: clk.clock})
178+ input <- ports.PointerMotion{X: 1, Y: 1}
179+ commandOf[ports.UserActivity](t, commands)
180+ clk.advance(ports.ActivityInterval / 2)
181+ // Core takes input in order and the input channel holds 4: once the
182+ // sixth send returns, the first motion is handled, and a wrongly
183+ // unthrottled activity would already be in commands.
184+ for range 6 {
185+ input <- ports.PointerMotion{X: 1, Y: 1}
186+ }
187+ for {
188+ select {
189+ case c := <-commands:
190+ if _, ok := c.(ports.UserActivity); ok {
191+ t.Fatal("second UserActivity before the interval passed on the clock")
192+ }
193+ continue
194+ default:
195+ }
196+ break
197+ }
198+ clk.advance(ports.ActivityInterval)
199+ input <- ports.PointerMotion{X: 2, Y: 2}
200+ commandOf[ports.UserActivity](t, commands)
201+}
@@ -12,13 +12,18 @@ import (
12 )12 )
13 13
14 func powerCore(t *testing.T) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {14 func powerCore(t *testing.T) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {
15+ t.Helper()
16+ return powerCoreWith(t, core.Options{})
17+}
18+
19+func powerCoreWith(t *testing.T, opts core.Options) (chan ports.InputEvent, chan ports.ClientEvent, chan ports.OutputEvent, chan ports.ClientCommand, chan []ports.Scene) {
15 t.Helper()20 t.Helper()
16 input := make(chan ports.InputEvent, 4)21 input := make(chan ports.InputEvent, 4)
17 client := make(chan ports.ClientEvent, 4)22 client := make(chan ports.ClientEvent, 4)
18 output := make(chan ports.OutputEvent, 2)23 output := make(chan ports.OutputEvent, 2)
19 commands := make(chan ports.ClientCommand, 64)24 commands := make(chan ports.ClientCommand, 64)
20 scenes := make(chan []ports.Scene, 1)25 scenes := make(chan []ports.Scene, 1)
21- c, err := core.New(config.Defaults(), core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: make(chan ports.SpawnRequest, 4)})26+ c, err := core.New(config.Defaults(), core.Channels{Input: input, Client: client, Output: output, Commands: commands, Scenes: scenes, Spawn: make(chan ports.SpawnRequest, 4)}, opts)
22 if err != nil {27 if err != nil {
23 t.Fatal(err)28 t.Fatal(err)
24 }29 }
@@ -164,3 +169,33 @@ func TestUserActivity(t *testing.T) {
164 input <- ports.PointerMotion{X: 2, Y: 2}169 input <- ports.PointerMotion{X: 2, Y: 2}
165 commandOf[ports.UserActivity](t, commands)170 commandOf[ports.UserActivity](t, commands)
166 }171 }
172+
173+// With an injected clock, the throttle follows it: no second UserActivity
174+// until the clock has advanced past the interval.
175+func TestUserActivityFollowsClock(t *testing.T) {
176+ clk := newStepClock(t)
177+ input, _, _, commands, _ := powerCoreWith(t, core.Options{Clock: clk.clock})
178+ input <- ports.PointerMotion{X: 1, Y: 1}
179+ commandOf[ports.UserActivity](t, commands)
180+ clk.advance(ports.ActivityInterval / 2)
181+ // Core takes input in order and the input channel holds 4: once the
182+ // sixth send returns, the first motion is handled, and a wrongly
183+ // unthrottled activity would already be in commands.
184+ for range 6 {
185+ input <- ports.PointerMotion{X: 1, Y: 1}
186+ }
187+ for {
188+ select {
189+ case c := <-commands:
190+ if _, ok := c.(ports.UserActivity); ok {
191+ t.Fatal("second UserActivity before the interval passed on the clock")
192+ }
193+ continue
194+ default:
195+ }
196+ break
197+ }
198+ clk.advance(ports.ActivityInterval)
199+ input <- ports.PointerMotion{X: 2, Y: 2}
200+ commandOf[ports.UserActivity](t, commands)
201+}
M internal/core/publish_alloc_test.go +1 -1
@@ -42,7 +42,7 @@ func publishRig(t *testing.T, screens int) *Core {
4242 cfg.Border.Width = 2
4343 cfg.Animations.On = true
4444 cfg.Animations.Slowdown = 10
45- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
45+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{})
4646 if err != nil {
4747 t.Fatal(err)
4848 }
@@ -42,7 +42,7 @@ func publishRig(t *testing.T, screens int) *Core {
42 cfg.Border.Width = 242 cfg.Border.Width = 2
43 cfg.Animations.On = true43 cfg.Animations.On = true
44 cfg.Animations.Slowdown = 1044 cfg.Animations.Slowdown = 10
45- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})45+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{})
46 if err != nil {46 if err != nil {
47 t.Fatal(err)47 t.Fatal(err)
48 }48 }
M internal/core/pulse.go +3 -3
@@ -77,7 +77,7 @@ func (c *Core) pulseFocus(window WindowID, held bool) {
7777 if c.justMapped(window) {
7878 return
7979 }
80- p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseSettle)
80+ p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseSettle)
8181 }
8282
8383 // justMapped reports whether the window got the focus as it mapped: opening
@@ -98,14 +98,14 @@ func (c *Core) pulseTick() bool {
9898 }
9999 now := c.now()
100100 if wait := pulseSettle - now.Sub(p.since); wait > 0 {
101- p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)
101+ p.timerC, p.timerStop = newTimer(c.opts.Clock, wait)
102102 return false
103103 }
104104 // A swipe or its landing slide still shows the neighbours: decide on
105105 // the settled layout, or a window left alone would start a pulse cut
106106 // short.
107107 if !c.cur().settled() {
108- p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)
108+ p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseRecheck)
109109 return false
110110 }
111111 if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
@@ -77,7 +77,7 @@ func (c *Core) pulseFocus(window WindowID, held bool) {
77 if c.justMapped(window) {77 if c.justMapped(window) {
78 return78 return
79 }79 }
80- p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseSettle)80+ p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseSettle)
81 }81 }
82 82
83 // justMapped reports whether the window got the focus as it mapped: opening83 // justMapped reports whether the window got the focus as it mapped: opening
@@ -98,14 +98,14 @@ func (c *Core) pulseTick() bool {
98 }98 }
99 now := c.now()99 now := c.now()
100 if wait := pulseSettle - now.Sub(p.since); wait > 0 {100 if wait := pulseSettle - now.Sub(p.since); wait > 0 {
101- p.timerC, p.timerStop = newTimer(c.ch.Clock, wait)101+ p.timerC, p.timerStop = newTimer(c.opts.Clock, wait)
102 return false102 return false
103 }103 }
104 // A swipe or its landing slide still shows the neighbours: decide on104 // A swipe or its landing slide still shows the neighbours: decide on
105 // the settled layout, or a window left alone would start a pulse cut105 // the settled layout, or a window left alone would start a pulse cut
106 // short.106 // short.
107 if !c.cur().settled() {107 if !c.cur().settled() {
108- p.timerC, p.timerStop = newTimer(c.ch.Clock, pulseRecheck)108+ p.timerC, p.timerStop = newTimer(c.opts.Clock, pulseRecheck)
109 return false109 return false
110 }110 }
111 if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {111 if p.target == p.last && now.Sub(p.lastAt) < pulseCooldown {
M internal/core/refresh_bench_test.go +1 -1
@@ -17,7 +17,7 @@ func BenchmarkRefreshShown(b *testing.B) {
1717 cfg.Layout.MaxColumns = 4
1818 cfg.Animations.On = true
1919 cfg.Animations.Slowdown = 10
20- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})
20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{})
2121 if err != nil {
2222 b.Fatal(err)
2323 }
@@ -17,7 +17,7 @@ func BenchmarkRefreshShown(b *testing.B) {
17 cfg.Layout.MaxColumns = 417 cfg.Layout.MaxColumns = 4
18 cfg.Animations.On = true18 cfg.Animations.On = true
19 cfg.Animations.Slowdown = 1019 cfg.Animations.Slowdown = 10
20- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)})20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{})
21 if err != nil {21 if err != nil {
22 b.Fatal(err)22 b.Fatal(err)
23 }23 }
M internal/core/screens_test.go +41 -2
@@ -40,6 +40,12 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo
4040
4141 // startRig is startMulti; terminal enables the configured automatic-open policy.
4242 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
43+ t.Helper()
44+ return startRigWith(t, core.Options{Terminal: terminal}, edit, outs...)
45+}
46+
47+// startRigWith is startRig with explicit core options.
48+func startRigWith(t *testing.T, opts core.Options, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
4349 t.Helper()
4450 cfg := altCmdDefaults()
4551 cfg.Border.Width = 0
@@ -51,7 +57,7 @@ func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...por
5157 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
5258 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
5359 }
54- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
60+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state}, opts)
5561 if err != nil {
5662 t.Fatal(err)
5763 }
@@ -622,7 +628,7 @@ func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
622628 output := make(chan ports.OutputEvent, 1)
623629 scenes := make(chan []ports.Scene, 1)
624630 state := make(chan ports.State, 1)
625- c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state, Terminal: true})
631+ c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state}, core.Options{Terminal: true})
626632 if err != nil {
627633 t.Fatal(err)
628634 }
@@ -753,6 +759,39 @@ func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
753759 }
754760 }
755761
762+// A terminal that exits is not respawned before termRetry has passed on
763+// the injected clock, and is once it has.
764+func TestTerminalRetryFollowsClock(t *testing.T) {
765+ clk := newStepClock(t)
766+ r := startRigWith(t, core.Options{Terminal: true, Clock: clk.clock}, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
767+ first := receive(t, r.spawn)
768+ r.client <- ports.WindowMapped{ID: 1, Slot: token(t, first)}
769+ receive(t, r.scenes)
770+ r.client <- ports.WindowUnmapped{ID: 1}
771+ receive(t, r.scenes)
772+ if len(r.spawn) != 0 {
773+ t.Fatal("respawned at once")
774+ }
775+ // churn empties the workspace again, so fillEmpty runs at the clock's time.
776+ churn := func(id ports.WindowID) {
777+ r.client <- ports.WindowMapped{ID: id}
778+ receive(t, r.scenes)
779+ r.client <- ports.WindowUnmapped{ID: id}
780+ receive(t, r.scenes)
781+ }
782+ // termRetry is private to core: stay far below and far above it.
783+ clk.advance(time.Second)
784+ churn(2)
785+ if len(r.spawn) != 0 {
786+ t.Fatal("respawned before the retry interval")
787+ }
788+ clk.advance(time.Minute)
789+ churn(3)
790+ if req := receive(t, r.spawn); req.Argv[0] != "foot" {
791+ t.Fatal(req)
792+ }
793+}
794+
756795 func TestLastColumnMovesToNeighborOutput(t *testing.T) {
757796 r := startRig(t, true, nil, left, right)
758797 r.mapWindow(t, 1)
@@ -40,6 +40,12 @@ func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo
40 40
41 // startRig is startMulti; terminal enables the configured automatic-open policy.41 // startRig is startMulti; terminal enables the configured automatic-open policy.
42 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {42 func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
43+ t.Helper()
44+ return startRigWith(t, core.Options{Terminal: terminal}, edit, outs...)
45+}
46+
47+// startRigWith is startRig with explicit core options.
48+func startRigWith(t *testing.T, opts core.Options, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
43 t.Helper()49 t.Helper()
44 cfg := altCmdDefaults()50 cfg := altCmdDefaults()
45 cfg.Border.Width = 051 cfg.Border.Width = 0
@@ -51,7 +57,7 @@ func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...por
51 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),57 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
52 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,58 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
53 }59 }
54- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})60+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state}, opts)
55 if err != nil {61 if err != nil {
56 t.Fatal(err)62 t.Fatal(err)
57 }63 }
@@ -622,7 +628,7 @@ func TestFirstTerminalFollowsSetOutputs(t *testing.T) {
622 output := make(chan ports.OutputEvent, 1)628 output := make(chan ports.OutputEvent, 1)
623 scenes := make(chan []ports.Scene, 1)629 scenes := make(chan []ports.Scene, 1)
624 state := make(chan ports.State, 1)630 state := make(chan ports.State, 1)
625- c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state, Terminal: true})631+ c, err := core.New(cfg, core.Channels{Output: output, Commands: commands, Spawn: spawn, Scenes: scenes, State: state}, core.Options{Terminal: true})
626 if err != nil {632 if err != nil {
627 t.Fatal(err)633 t.Fatal(err)
628 }634 }
@@ -753,6 +759,39 @@ func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
753 }759 }
754 }760 }
755 761
762+// A terminal that exits is not respawned before termRetry has passed on
763+// the injected clock, and is once it has.
764+func TestTerminalRetryFollowsClock(t *testing.T) {
765+ clk := newStepClock(t)
766+ r := startRigWith(t, core.Options{Terminal: true, Clock: clk.clock}, func(c *ports.Config) { c.Terminal.AutoOpen = "all" }, left)
767+ first := receive(t, r.spawn)
768+ r.client <- ports.WindowMapped{ID: 1, Slot: token(t, first)}
769+ receive(t, r.scenes)
770+ r.client <- ports.WindowUnmapped{ID: 1}
771+ receive(t, r.scenes)
772+ if len(r.spawn) != 0 {
773+ t.Fatal("respawned at once")
774+ }
775+ // churn empties the workspace again, so fillEmpty runs at the clock's time.
776+ churn := func(id ports.WindowID) {
777+ r.client <- ports.WindowMapped{ID: id}
778+ receive(t, r.scenes)
779+ r.client <- ports.WindowUnmapped{ID: id}
780+ receive(t, r.scenes)
781+ }
782+ // termRetry is private to core: stay far below and far above it.
783+ clk.advance(time.Second)
784+ churn(2)
785+ if len(r.spawn) != 0 {
786+ t.Fatal("respawned before the retry interval")
787+ }
788+ clk.advance(time.Minute)
789+ churn(3)
790+ if req := receive(t, r.spawn); req.Argv[0] != "foot" {
791+ t.Fatal(req)
792+ }
793+}
794+
756 func TestLastColumnMovesToNeighborOutput(t *testing.T) {795 func TestLastColumnMovesToNeighborOutput(t *testing.T) {
757 r := startRig(t, true, nil, left, right)796 r := startRig(t, true, nil, left, right)
758 r.mapWindow(t, 1)797 r.mapWindow(t, 1)
M internal/core/security.go +7 -7
@@ -13,10 +13,10 @@ func (c *Core) syncSecurity() bool {
1313 if c.inputActive {
1414 return false
1515 }
16- if c.ch.Security == nil {
16+ if c.opts.Security == nil {
1717 return false
1818 }
19- state := c.ch.Security.Snapshot()
19+ state := c.opts.Security.Snapshot()
2020 if state == c.security {
2121 return false
2222 }
@@ -44,11 +44,11 @@ func (c *Core) syncSecurity() bool {
4444 // Check spawns without adopting a new epoch midway through processing an
4545 // event: commands emitted by that event must retain its original owner epoch.
4646 func (c *Core) inputEpochChanged() bool {
47- return c.inputActive && c.ch.Security != nil && c.ch.Security.Snapshot() != c.security
47+ return c.inputActive && c.opts.Security != nil && c.opts.Security.Snapshot() != c.security
4848 }
4949
5050 func (c *Core) protectionRequested() bool {
51- return c.security.Protected || c.ch.Security != nil && c.ch.Security.Snapshot() != c.security
51+ return c.security.Protected || c.opts.Security != nil && c.opts.Security.Snapshot() != c.security
5252 }
5353
5454 func (c *Core) securityCheckpoint(ctx context.Context) error {
@@ -89,7 +89,7 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool {
8989 }
9090
9191 func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool {
92- if c.ch.Security == nil || v.State != c.security || v.State != c.ch.Security.Snapshot() {
92+ if c.opts.Security == nil || v.State != c.security || v.State != c.opts.Security.Snapshot() {
9393 return false
9494 }
9595 if !c.security.Protected {
@@ -211,7 +211,7 @@ func (c *Core) admitInput(ev ports.InputEvent) (ports.InputEvent, bool) {
211211 return nil, false
212212 }
213213 ev = v.Event
214- } else if c.ch.Security != nil || c.security != (ports.SecurityState{}) {
214+ } else if c.opts.Security != nil || c.security != (ports.SecurityState{}) {
215215 return nil, false
216216 }
217217 // The producer resets native keymap state and quarantines physically held
@@ -219,7 +219,7 @@ func (c *Core) admitInput(ev ports.InputEvent) (ports.InputEvent, bool) {
219219 // it must not infer modifier meaning from evdev codes or XKB bit positions.
220220 // No release without a press in this epoch (password keys must not reach
221221 // the desktop after unlock). Standalone legacy channel tests remain raw.
222- if c.ch.Security != nil {
222+ if c.opts.Security != nil {
223223 if b, ok := ev.(ports.PointerButton); ok && !b.Pressed && !c.buttons[b.Button] {
224224 return nil, false
225225 }
@@ -13,10 +13,10 @@ func (c *Core) syncSecurity() bool {
13 if c.inputActive {13 if c.inputActive {
14 return false14 return false
15 }15 }
16- if c.ch.Security == nil {16+ if c.opts.Security == nil {
17 return false17 return false
18 }18 }
19- state := c.ch.Security.Snapshot()19+ state := c.opts.Security.Snapshot()
20 if state == c.security {20 if state == c.security {
21 return false21 return false
22 }22 }
@@ -44,11 +44,11 @@ func (c *Core) syncSecurity() bool {
44 // Check spawns without adopting a new epoch midway through processing an44 // Check spawns without adopting a new epoch midway through processing an
45 // event: commands emitted by that event must retain its original owner epoch.45 // event: commands emitted by that event must retain its original owner epoch.
46 func (c *Core) inputEpochChanged() bool {46 func (c *Core) inputEpochChanged() bool {
47- return c.inputActive && c.ch.Security != nil && c.ch.Security.Snapshot() != c.security47+ return c.inputActive && c.opts.Security != nil && c.opts.Security.Snapshot() != c.security
48 }48 }
49 49
50 func (c *Core) protectionRequested() bool {50 func (c *Core) protectionRequested() bool {
51- return c.security.Protected || c.ch.Security != nil && c.ch.Security.Snapshot() != c.security51+ return c.security.Protected || c.opts.Security != nil && c.opts.Security.Snapshot() != c.security
52 }52 }
53 53
54 func (c *Core) securityCheckpoint(ctx context.Context) error {54 func (c *Core) securityCheckpoint(ctx context.Context) error {
@@ -89,7 +89,7 @@ func blockedProtectedEvent(ev ports.ClientEvent) bool {
89 }89 }
90 90
91 func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool {91 func (c *Core) applyLockChanged(v ports.SessionLockChanged) bool {
92- if c.ch.Security == nil || v.State != c.security || v.State != c.ch.Security.Snapshot() {92+ if c.opts.Security == nil || v.State != c.security || v.State != c.opts.Security.Snapshot() {
93 return false93 return false
94 }94 }
95 if !c.security.Protected {95 if !c.security.Protected {
@@ -211,7 +211,7 @@ func (c *Core) admitInput(ev ports.InputEvent) (ports.InputEvent, bool) {
211 return nil, false211 return nil, false
212 }212 }
213 ev = v.Event213 ev = v.Event
214- } else if c.ch.Security != nil || c.security != (ports.SecurityState{}) {214+ } else if c.opts.Security != nil || c.security != (ports.SecurityState{}) {
215 return nil, false215 return nil, false
216 }216 }
217 // The producer resets native keymap state and quarantines physically held217 // The producer resets native keymap state and quarantines physically held
@@ -219,7 +219,7 @@ func (c *Core) admitInput(ev ports.InputEvent) (ports.InputEvent, bool) {
219 // it must not infer modifier meaning from evdev codes or XKB bit positions.219 // it must not infer modifier meaning from evdev codes or XKB bit positions.
220 // No release without a press in this epoch (password keys must not reach220 // No release without a press in this epoch (password keys must not reach
221 // the desktop after unlock). Standalone legacy channel tests remain raw.221 // the desktop after unlock). Standalone legacy channel tests remain raw.
222- if c.ch.Security != nil {222+ if c.opts.Security != nil {
223 if b, ok := ev.(ports.PointerButton); ok && !b.Pressed && !c.buttons[b.Button] {223 if b, ok := ev.(ports.PointerButton); ok && !b.Pressed && !c.buttons[b.Button] {
224 return nil, false224 return nil, false
225 }225 }
M internal/core/security_internal_test.go +3 -3
@@ -14,7 +14,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
1414 state := ports.SecurityState{Generation: 1, Protected: true}
1515 gate := portsmocks.NewMockSessionSecurity(t)
1616 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
17- c.ch.Security = gate
17+ c.opts.Security = gate
1818 c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})
1919 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 7})
2020 c.capt.flashes = []capFlash{{target: capTarget{output: "A"}, until: c.now().Add(time.Hour)}}
@@ -92,7 +92,7 @@ func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) {
9292 state := ports.SecurityState{Generation: 1, Protected: true}
9393 gate := portsmocks.NewMockSessionSecurity(t)
9494 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
95- c.ch.Security = gate
95+ c.opts.Security = gate
9696 c.syncSecurity()
9797 ctx := context.Background()
9898 mapped := func(s ...ports.LockSurfacePlacement) {
@@ -127,7 +127,7 @@ func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
127127 gate := portsmocks.NewMockSessionSecurity(t)
128128 next := ports.SecurityState{Generation: 1, Protected: true}
129129 gate.EXPECT().Snapshot().Return(next)
130- c.ch.Security = gate
130+ c.opts.Security = gate
131131 if _, ok := c.admitInput(ports.KeyEvent{Pressed: true}); ok {
132132 t.Fatal("raw wired input admitted")
133133 }
@@ -14,7 +14,7 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
14 state := ports.SecurityState{Generation: 1, Protected: true}14 state := ports.SecurityState{Generation: 1, Protected: true}
15 gate := portsmocks.NewMockSessionSecurity(t)15 gate := portsmocks.NewMockSessionSecurity(t)
16 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })16 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
17- c.ch.Security = gate17+ c.opts.Security = gate
18 c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})18 c.captureOpen(ports.CaptureSessionOpen{ID: 7, Workspace: ws.ID})
19 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 7})19 c.captureExclusionBegin(ports.CaptureExclusionBegin{Session: 7})
20 c.capt.flashes = []capFlash{{target: capTarget{output: "A"}, until: c.now().Add(time.Hour)}}20 c.capt.flashes = []capFlash{{target: capTarget{output: "A"}, until: c.now().Add(time.Hour)}}
@@ -92,7 +92,7 @@ func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) {
92 state := ports.SecurityState{Generation: 1, Protected: true}92 state := ports.SecurityState{Generation: 1, Protected: true}
93 gate := portsmocks.NewMockSessionSecurity(t)93 gate := portsmocks.NewMockSessionSecurity(t)
94 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })94 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
95- c.ch.Security = gate95+ c.opts.Security = gate
96 c.syncSecurity()96 c.syncSecurity()
97 ctx := context.Background()97 ctx := context.Background()
98 mapped := func(s ...ports.LockSurfacePlacement) {98 mapped := func(s ...ports.LockSurfacePlacement) {
@@ -127,7 +127,7 @@ func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
127 gate := portsmocks.NewMockSessionSecurity(t)127 gate := portsmocks.NewMockSessionSecurity(t)
128 next := ports.SecurityState{Generation: 1, Protected: true}128 next := ports.SecurityState{Generation: 1, Protected: true}
129 gate.EXPECT().Snapshot().Return(next)129 gate.EXPECT().Snapshot().Return(next)
130- c.ch.Security = gate130+ c.opts.Security = gate
131 if _, ok := c.admitInput(ports.KeyEvent{Pressed: true}); ok {131 if _, ok := c.admitInput(ports.KeyEvent{Pressed: true}); ok {
132 t.Fatal("raw wired input admitted")132 t.Fatal("raw wired input admitted")
133 }133 }
M internal/core/security_review_test.go +6 -6
@@ -38,7 +38,7 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
3838 }
3939 return v
4040 })
41- c.ch.Security = gate
41+ c.opts.Security = gate
4242 c.sentOutputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
4343 commands := make(chan ports.ClientCommand)
4444 c.ch.Commands = commands
@@ -68,7 +68,7 @@ func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) {
6868 state := ports.SecurityState{Generation: 2}
6969 gate := portsmocks.NewMockSessionSecurity(t)
7070 gate.EXPECT().Snapshot().Return(state)
71- c.ch.Security = gate
71+ c.opts.Security = gate
7272 c.syncSecurity()
7373 key := ports.KeyEvent{Keycode: 58, Keysym: "Control_L", Pressed: true, Mods: ports.ModCtrl, State: ports.ModState{Depressed: 1 << 19, Latched: 1 << 21, Locked: 1 << 23, Group: 2}}
7474 ev, ok := c.admitInput(ports.SecurityInput{State: state, Event: key})
@@ -97,7 +97,7 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
9797 }
9898 return v
9999 })
100- c.ch.Security = gate
100+ c.opts.Security = gate
101101 commands := make(chan ports.ClientCommand)
102102 c.ch.Commands = commands
103103 spawn := make(chan ports.SpawnRequest, 2)
@@ -132,7 +132,7 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
132132 c, _, _ := hiddenCaptureCommands(t)
133133 c.cur().mon.RemoveWindow(1)
134134 c.cur().mon.RemoveWindow(2)
135- c.ch.Terminal = true
135+ c.opts.Terminal = true
136136 c.cfg.Terminal.AutoOpen = "always"
137137 c.cfg.Terminal.Command = []string{"terminal"}
138138 var state atomic.Value
@@ -147,7 +147,7 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
147147 }
148148 return v
149149 })
150- c.ch.Security = gate
150+ c.opts.Security = gate
151151 commands := make(chan ports.ClientCommand)
152152 c.ch.Commands = commands
153153 spawn := make(chan ports.SpawnRequest, 1)
@@ -173,7 +173,7 @@ func TestStartupWorkspaceSnapshotHasNoOutputBeforeOutputAdded(t *testing.T) {
173173 cfg.Keyboard.CmdKey = "super"
174174 cfg.Layout.MaxColumns = 2
175175 workspaces := make(chan ports.Workspaces, 1)
176- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces})
176+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces}, Options{})
177177 if err != nil {
178178 t.Fatal(err)
179179 }
@@ -38,7 +38,7 @@ func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
38 }38 }
39 return v39 return v
40 })40 })
41- c.ch.Security = gate41+ c.opts.Security = gate
42 c.sentOutputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}42 c.sentOutputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43 commands := make(chan ports.ClientCommand)43 commands := make(chan ports.ClientCommand)
44 c.ch.Commands = commands44 c.ch.Commands = commands
@@ -68,7 +68,7 @@ func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) {
68 state := ports.SecurityState{Generation: 2}68 state := ports.SecurityState{Generation: 2}
69 gate := portsmocks.NewMockSessionSecurity(t)69 gate := portsmocks.NewMockSessionSecurity(t)
70 gate.EXPECT().Snapshot().Return(state)70 gate.EXPECT().Snapshot().Return(state)
71- c.ch.Security = gate71+ c.opts.Security = gate
72 c.syncSecurity()72 c.syncSecurity()
73 key := ports.KeyEvent{Keycode: 58, Keysym: "Control_L", Pressed: true, Mods: ports.ModCtrl, State: ports.ModState{Depressed: 1 << 19, Latched: 1 << 21, Locked: 1 << 23, Group: 2}}73 key := ports.KeyEvent{Keycode: 58, Keysym: "Control_L", Pressed: true, Mods: ports.ModCtrl, State: ports.ModState{Depressed: 1 << 19, Latched: 1 << 21, Locked: 1 << 23, Group: 2}}
74 ev, ok := c.admitInput(ports.SecurityInput{State: state, Event: key})74 ev, ok := c.admitInput(ports.SecurityInput{State: state, Event: key})
@@ -97,7 +97,7 @@ func TestSlotsPendingBackpressureSuppressesSpawnAndDefersSlots(t *testing.T) {
97 }97 }
98 return v98 return v
99 })99 })
100- c.ch.Security = gate100+ c.opts.Security = gate
101 commands := make(chan ports.ClientCommand)101 commands := make(chan ports.ClientCommand)
102 c.ch.Commands = commands102 c.ch.Commands = commands
103 spawn := make(chan ports.SpawnRequest, 2)103 spawn := make(chan ports.SpawnRequest, 2)
@@ -132,7 +132,7 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
132 c, _, _ := hiddenCaptureCommands(t)132 c, _, _ := hiddenCaptureCommands(t)
133 c.cur().mon.RemoveWindow(1)133 c.cur().mon.RemoveWindow(1)
134 c.cur().mon.RemoveWindow(2)134 c.cur().mon.RemoveWindow(2)
135- c.ch.Terminal = true135+ c.opts.Terminal = true
136 c.cfg.Terminal.AutoOpen = "always"136 c.cfg.Terminal.AutoOpen = "always"
137 c.cfg.Terminal.Command = []string{"terminal"}137 c.cfg.Terminal.Command = []string{"terminal"}
138 var state atomic.Value138 var state atomic.Value
@@ -147,7 +147,7 @@ func TestTerminalPendingBackpressureSuppressesSpawnAndRestoresRetry(t *testing.T
147 }147 }
148 return v148 return v
149 })149 })
150- c.ch.Security = gate150+ c.opts.Security = gate
151 commands := make(chan ports.ClientCommand)151 commands := make(chan ports.ClientCommand)
152 c.ch.Commands = commands152 c.ch.Commands = commands
153 spawn := make(chan ports.SpawnRequest, 1)153 spawn := make(chan ports.SpawnRequest, 1)
@@ -173,7 +173,7 @@ func TestStartupWorkspaceSnapshotHasNoOutputBeforeOutputAdded(t *testing.T) {
173 cfg.Keyboard.CmdKey = "super"173 cfg.Keyboard.CmdKey = "super"
174 cfg.Layout.MaxColumns = 2174 cfg.Layout.MaxColumns = 2
175 workspaces := make(chan ports.Workspaces, 1)175 workspaces := make(chan ports.Workspaces, 1)
176- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces})176+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces}, Options{})
177 if err != nil {177 if err != nil {
178 t.Fatal(err)178 t.Fatal(err)
179 }179 }
M internal/core/security_test.go +1 -1
@@ -28,7 +28,7 @@ func TestSecurityOwnerAdversarialQueues(t *testing.T) {
2828 cfg.Terminal.Command = []string{"terminal"}
2929 cfg.Terminal.AutoOpen = "off"
3030 cfg.Binds = map[string]string{"Super+q": "quit"}
31- c, err := core.New(cfg, core.Channels{Security: gate, Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints, Terminal: true})
31+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints}, core.Options{Security: gate, Terminal: true})
3232 if err != nil {
3333 t.Fatal(err)
3434 }
@@ -28,7 +28,7 @@ func TestSecurityOwnerAdversarialQueues(t *testing.T) {
28 cfg.Terminal.Command = []string{"terminal"}28 cfg.Terminal.Command = []string{"terminal"}
29 cfg.Terminal.AutoOpen = "off"29 cfg.Terminal.AutoOpen = "off"
30 cfg.Binds = map[string]string{"Super+q": "quit"}30 cfg.Binds = map[string]string{"Super+q": "quit"}
31- c, err := core.New(cfg, core.Channels{Security: gate, Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints, Terminal: true})31+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes, Spawn: spawn, Constraints: constraints}, core.Options{Security: gate, Terminal: true})
32 if err != nil {32 if err != nil {
33 t.Fatal(err)33 t.Fatal(err)
34 }34 }
M internal/core/slots_test.go +4 -3
@@ -62,16 +62,17 @@ func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
6262 output := make(chan ports.OutputEvent, 1)
6363 commands := make(chan ports.ClientCommand, 1024)
6464 ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}
65+ opts := core.Options{}
6566 if animated != nil && *animated {
6667 r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame)
67- ch.Clock, ch.Frames = r.clk.clock, r.frames
68+ opts.Clock, ch.Frames = r.clk.clock, r.frames
6869 t.Cleanup(func() {
6970 if r.lands == 0 {
7071 t.Error("no spring ever ran: the animations-on variant checked nothing")
7172 }
7273 })
7374 }
74- c, err := core.New(cfg, ch)
75+ c, err := core.New(cfg, ch, opts)
7576 if err != nil {
7677 t.Fatal(err)
7778 }
@@ -423,7 +424,7 @@ func TestStartupCommands(t *testing.T) {
423424 cfg := config.Defaults()
424425 cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}}
425426 spawn := make(chan ports.SpawnRequest, 4)
426- c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)})
427+ c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)}, core.Options{})
427428 if err != nil {
428429 t.Fatal(err)
429430 }
@@ -62,16 +62,17 @@ func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
62 output := make(chan ports.OutputEvent, 1)62 output := make(chan ports.OutputEvent, 1)
63 commands := make(chan ports.ClientCommand, 1024)63 commands := make(chan ports.ClientCommand, 1024)
64 ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}64 ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}
65+ opts := core.Options{}
65 if animated != nil && *animated {66 if animated != nil && *animated {
66 r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame)67 r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame)
67- ch.Clock, ch.Frames = r.clk.clock, r.frames68+ opts.Clock, ch.Frames = r.clk.clock, r.frames
68 t.Cleanup(func() {69 t.Cleanup(func() {
69 if r.lands == 0 {70 if r.lands == 0 {
70 t.Error("no spring ever ran: the animations-on variant checked nothing")71 t.Error("no spring ever ran: the animations-on variant checked nothing")
71 }72 }
72 })73 })
73 }74 }
74- c, err := core.New(cfg, ch)75+ c, err := core.New(cfg, ch, opts)
75 if err != nil {76 if err != nil {
76 t.Fatal(err)77 t.Fatal(err)
77 }78 }
@@ -423,7 +424,7 @@ func TestStartupCommands(t *testing.T) {
423 cfg := config.Defaults()424 cfg := config.Defaults()
424 cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}}425 cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}}
425 spawn := make(chan ports.SpawnRequest, 4)426 spawn := make(chan ports.SpawnRequest, 4)
426- c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)})427+ c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)}, core.Options{})
427 if err != nil {428 if err != nil {
428 t.Fatal(err)429 t.Fatal(err)
429 }430 }
M internal/core/spawn_placement_test.go +2 -2
@@ -17,7 +17,7 @@ func TestSlotClaimWithoutWorkspace(t *testing.T) {
1717 cfg := ports.Config{}
1818 cfg.Keyboard.CmdKey = "alt"
1919 cfg.Layout.MaxColumns = 1
20- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
2121 if err != nil {
2222 t.Fatal(err)
2323 }
@@ -47,7 +47,7 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
4747 cfg := ports.Config{}
4848 cfg.Keyboard.CmdKey = "alt"
4949 cfg.Layout.MaxColumns = 1
50- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
50+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
5151 if err != nil {
5252 t.Fatal(err)
5353 }
@@ -17,7 +17,7 @@ func TestSlotClaimWithoutWorkspace(t *testing.T) {
17 cfg := ports.Config{}17 cfg := ports.Config{}
18 cfg.Keyboard.CmdKey = "alt"18 cfg.Keyboard.CmdKey = "alt"
19 cfg.Layout.MaxColumns = 119 cfg.Layout.MaxColumns = 1
20- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})20+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
21 if err != nil {21 if err != nil {
22 t.Fatal(err)22 t.Fatal(err)
23 }23 }
@@ -47,7 +47,7 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
47 cfg := ports.Config{}47 cfg := ports.Config{}
48 cfg.Keyboard.CmdKey = "alt"48 cfg.Keyboard.CmdKey = "alt"
49 cfg.Layout.MaxColumns = 149 cfg.Layout.MaxColumns = 1
50- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})50+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
51 if err != nil {51 if err != nil {
52 t.Fatal(err)52 t.Fatal(err)
53 }53 }
M internal/core/spawn_security_test.go +5 -5
@@ -20,7 +20,7 @@ func TestFullSpawnQueueSlotsTerminalsExplicitRemainTransitionSafe(t *testing.T)
2020 epoch.Store(ports.SecurityState{Generation: 2})
2121 gate := portsmocks.NewMockSessionSecurity(t)
2222 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
23- c.ch.Security = gate
23+ c.opts.Security = gate
2424 c.syncSecurity()
2525 spawn := make(chan ports.SpawnRequest, 1)
2626 c.ch.Spawn = spawn
@@ -29,7 +29,7 @@ func TestFullSpawnQueueSlotsTerminalsExplicitRemainTransitionSafe(t *testing.T)
2929 key := slotKey{workspace: "dev", index: 1}
3030 c.cfg.Terminal.Command = []string{"terminal"}
3131 c.cfg.Terminal.AutoOpen = "always"
32- c.ch.Terminal = kind == "terminal"
32+ c.opts.Terminal = kind == "terminal"
3333 if kind == "slot" {
3434 c.slots[key] = &slotState{argv: []string{"slot"}}
3535 c.toSpawn = []slotKey{key}
@@ -131,7 +131,7 @@ func TestRunStartupPendingDisabledWhileLockedResumesOnUnlockEvent(t *testing.T)
131131 epoch.Store(locked)
132132 gate := portsmocks.NewMockSessionSecurity(t)
133133 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
134- c.ch.Security = gate
134+ c.opts.Security = gate
135135 spawn := make(chan ports.SpawnRequest, 1)
136136 c.ch.Spawn = spawn
137137 client := make(chan ports.ClientEvent, 1)
@@ -175,7 +175,7 @@ func TestStartupSuccessfulHandoffConsumesDespiteImmediateTransition(t *testing.T
175175 }
176176 return epoch.Load().(ports.SecurityState)
177177 })
178- c.ch.Security = gate
178+ c.opts.Security = gate
179179 c.spawnStartup(context.Background())
180180 if len(c.startup) != 0 {
181181 t.Fatal("successful handoff kept pending startup")
@@ -202,7 +202,7 @@ func TestRunSelectableStartupConsumedOnceAcrossReceiverTransition(t *testing.T)
202202 epoch.Store(ports.SecurityState{})
203203 gate := portsmocks.NewMockSessionSecurity(t)
204204 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
205- c.ch.Security = gate
205+ c.opts.Security = gate
206206 spawn := make(chan ports.SpawnRequest)
207207 c.ch.Spawn = spawn
208208 client := make(chan ports.ClientEvent, 1)
@@ -20,7 +20,7 @@ func TestFullSpawnQueueSlotsTerminalsExplicitRemainTransitionSafe(t *testing.T)
20 epoch.Store(ports.SecurityState{Generation: 2})20 epoch.Store(ports.SecurityState{Generation: 2})
21 gate := portsmocks.NewMockSessionSecurity(t)21 gate := portsmocks.NewMockSessionSecurity(t)
22 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })22 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
23- c.ch.Security = gate23+ c.opts.Security = gate
24 c.syncSecurity()24 c.syncSecurity()
25 spawn := make(chan ports.SpawnRequest, 1)25 spawn := make(chan ports.SpawnRequest, 1)
26 c.ch.Spawn = spawn26 c.ch.Spawn = spawn
@@ -29,7 +29,7 @@ func TestFullSpawnQueueSlotsTerminalsExplicitRemainTransitionSafe(t *testing.T)
29 key := slotKey{workspace: "dev", index: 1}29 key := slotKey{workspace: "dev", index: 1}
30 c.cfg.Terminal.Command = []string{"terminal"}30 c.cfg.Terminal.Command = []string{"terminal"}
31 c.cfg.Terminal.AutoOpen = "always"31 c.cfg.Terminal.AutoOpen = "always"
32- c.ch.Terminal = kind == "terminal"32+ c.opts.Terminal = kind == "terminal"
33 if kind == "slot" {33 if kind == "slot" {
34 c.slots[key] = &slotState{argv: []string{"slot"}}34 c.slots[key] = &slotState{argv: []string{"slot"}}
35 c.toSpawn = []slotKey{key}35 c.toSpawn = []slotKey{key}
@@ -131,7 +131,7 @@ func TestRunStartupPendingDisabledWhileLockedResumesOnUnlockEvent(t *testing.T)
131 epoch.Store(locked)131 epoch.Store(locked)
132 gate := portsmocks.NewMockSessionSecurity(t)132 gate := portsmocks.NewMockSessionSecurity(t)
133 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })133 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
134- c.ch.Security = gate134+ c.opts.Security = gate
135 spawn := make(chan ports.SpawnRequest, 1)135 spawn := make(chan ports.SpawnRequest, 1)
136 c.ch.Spawn = spawn136 c.ch.Spawn = spawn
137 client := make(chan ports.ClientEvent, 1)137 client := make(chan ports.ClientEvent, 1)
@@ -175,7 +175,7 @@ func TestStartupSuccessfulHandoffConsumesDespiteImmediateTransition(t *testing.T
175 }175 }
176 return epoch.Load().(ports.SecurityState)176 return epoch.Load().(ports.SecurityState)
177 })177 })
178- c.ch.Security = gate178+ c.opts.Security = gate
179 c.spawnStartup(context.Background())179 c.spawnStartup(context.Background())
180 if len(c.startup) != 0 {180 if len(c.startup) != 0 {
181 t.Fatal("successful handoff kept pending startup")181 t.Fatal("successful handoff kept pending startup")
@@ -202,7 +202,7 @@ func TestRunSelectableStartupConsumedOnceAcrossReceiverTransition(t *testing.T)
202 epoch.Store(ports.SecurityState{})202 epoch.Store(ports.SecurityState{})
203 gate := portsmocks.NewMockSessionSecurity(t)203 gate := portsmocks.NewMockSessionSecurity(t)
204 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })204 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return epoch.Load().(ports.SecurityState) })
205- c.ch.Security = gate205+ c.opts.Security = gate
206 spawn := make(chan ports.SpawnRequest)206 spawn := make(chan ports.SpawnRequest)
207 c.ch.Spawn = spawn207 c.ch.Spawn = spawn
208 client := make(chan ports.ClientEvent, 1)208 client := make(chan ports.ClientEvent, 1)
M internal/core/stash_cover_test.go +1 -1
@@ -15,7 +15,7 @@ func stashCoverCore(t *testing.T, overflow Overflow, float bool) (*Core, *Worksp
1515 cfg.Keyboard.CmdKey = "super"
1616 cfg.Layout.Overflow = string(overflow)
1717 cfg.Layout.MaxColumns = 2
18- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
18+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
1919 if err != nil {
2020 t.Fatal(err)
2121 }
@@ -15,7 +15,7 @@ func stashCoverCore(t *testing.T, overflow Overflow, float bool) (*Core, *Worksp
15 cfg.Keyboard.CmdKey = "super"15 cfg.Keyboard.CmdKey = "super"
16 cfg.Layout.Overflow = string(overflow)16 cfg.Layout.Overflow = string(overflow)
17 cfg.Layout.MaxColumns = 217 cfg.Layout.MaxColumns = 2
18- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})18+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
19 if err != nil {19 if err != nil {
20 t.Fatal(err)20 t.Fatal(err)
21 }21 }
M internal/core/stash_swipe_internal_test.go +1 -1
@@ -260,7 +260,7 @@ func TestStashSwipeSecurityLockSettlesTheStash(t *testing.T) {
260260 }
261261 gate := portsmocks.NewMockSessionSecurity(t)
262262 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
263- s.c.ch.Security = gate
263+ s.c.opts.Security = gate
264264 if !s.c.syncSecurity() {
265265 t.Fatal("the lock was not taken")
266266 }
@@ -260,7 +260,7 @@ func TestStashSwipeSecurityLockSettlesTheStash(t *testing.T) {
260 }260 }
261 gate := portsmocks.NewMockSessionSecurity(t)261 gate := portsmocks.NewMockSessionSecurity(t)
262 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})262 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
263- s.c.ch.Security = gate263+ s.c.opts.Security = gate
264 if !s.c.syncSecurity() {264 if !s.c.syncSecurity() {
265 t.Fatal("the lock was not taken")265 t.Fatal("the lock was not taken")
266 }266 }
M internal/core/stash_test.go +1 -1
@@ -289,7 +289,7 @@ func TestFullscreenStashEdgeGoesToMonitor(t *testing.T) {
289289 cfg.Keyboard.CmdKey = "super"
290290 cfg.Layout.MaxColumns = 2
291291 cfg.Layout.Overflow = string(OverflowFixed)
292- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
292+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
293293 if err != nil {
294294 t.Fatal(err)
295295 }
@@ -289,7 +289,7 @@ func TestFullscreenStashEdgeGoesToMonitor(t *testing.T) {
289 cfg.Keyboard.CmdKey = "super"289 cfg.Keyboard.CmdKey = "super"
290 cfg.Layout.MaxColumns = 2290 cfg.Layout.MaxColumns = 2
291 cfg.Layout.Overflow = string(OverflowFixed)291 cfg.Layout.Overflow = string(OverflowFixed)
292- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})292+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
293 if err != nil {293 if err != nil {
294 t.Fatal(err)294 t.Fatal(err)
295 }295 }
M internal/core/state_cell_test.go +1 -1
@@ -12,7 +12,7 @@ func TestStateColumnRow(t *testing.T) {
1212 cfg := ports.Config{}
1313 cfg.Keyboard.CmdKey = "super"
1414 cfg.Layout.MaxColumns = 1
15- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)})
15+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
1616 if err != nil {
1717 t.Fatal(err)
1818 }
@@ -12,7 +12,7 @@ func TestStateColumnRow(t *testing.T) {
12 cfg := ports.Config{}12 cfg := ports.Config{}
13 cfg.Keyboard.CmdKey = "super"13 cfg.Keyboard.CmdKey = "super"
14 cfg.Layout.MaxColumns = 114 cfg.Layout.MaxColumns = 1
15- c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)})15+ c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 16), Scenes: make(chan []ports.Scene, 1)}, Options{})
16 if err != nil {16 if err != nil {
17 t.Fatal(err)17 t.Fatal(err)
18 }18 }
M internal/core/swipe_test.go +1 -1
@@ -48,7 +48,7 @@ func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
4848 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
4949 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
5050 }
51- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: r.clk.clock, Frames: r.frames})
51+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Frames: r.frames}, core.Options{Clock: r.clk.clock})
5252 if err != nil {
5353 t.Fatal(err)
5454 }
@@ -48,7 +48,7 @@ func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
48 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),48 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
49 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,49 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
50 }50 }
51- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: r.clk.clock, Frames: r.frames})51+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Frames: r.frames}, core.Options{Clock: r.clk.clock})
52 if err != nil {52 if err != nil {
53 t.Fatal(err)53 t.Fatal(err)
54 }54 }
M internal/core/terminal.go +2 -2
@@ -16,13 +16,13 @@ const termRetry = 5 * time.Second
1616
1717 // keepsTerminal reports whether empty workspaces get a terminal.
1818 func (c *Core) keepsTerminal() bool {
19- return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
19+ return c.opts.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
2020 }
2121
2222 // fillEmpty returns the terminals to spawn for empty workspaces on screen.
2323 // Workspaces with slots are left to them.
2424 func (c *Core) fillEmpty() []ports.SpawnRequest {
25- now := time.Now()
25+ now := c.now()
2626 c.placement.expireTerminals(now)
2727 if !c.keepsTerminal() {
2828 return nil
@@ -16,13 +16,13 @@ const termRetry = 5 * time.Second
16 16
17 // keepsTerminal reports whether empty workspaces get a terminal.17 // keepsTerminal reports whether empty workspaces get a terminal.
18 func (c *Core) keepsTerminal() bool {18 func (c *Core) keepsTerminal() bool {
19- return c.ch.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 019+ return c.opts.Terminal && c.cfg.Terminal.AutoOpen != "off" && len(c.cfg.Terminal.Command) > 0
20 }20 }
21 21
22 // fillEmpty returns the terminals to spawn for empty workspaces on screen.22 // fillEmpty returns the terminals to spawn for empty workspaces on screen.
23 // Workspaces with slots are left to them.23 // Workspaces with slots are left to them.
24 func (c *Core) fillEmpty() []ports.SpawnRequest {24 func (c *Core) fillEmpty() []ports.SpawnRequest {
25- now := time.Now()25+ now := c.now()
26 c.placement.expireTerminals(now)26 c.placement.expireTerminals(now)
27 if !c.keepsTerminal() {27 if !c.keepsTerminal() {
28 return nil28 return nil
M internal/core/transition_unmap_internal_test.go +1 -1
@@ -312,7 +312,7 @@ func TestUnmapLeavingDroppedByInterruptions(t *testing.T) {
312312 state := ports.SecurityState{Generation: 1, Protected: true}
313313 gate := portsmocks.NewMockSessionSecurity(t)
314314 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315- c.ch.Security = gate
315+ c.opts.Security = gate
316316 if err := c.publish(context.Background()); err != nil {
317317 t.Fatal(err)
318318 }
@@ -312,7 +312,7 @@ func TestUnmapLeavingDroppedByInterruptions(t *testing.T) {
312 state := ports.SecurityState{Generation: 1, Protected: true}312 state := ports.SecurityState{Generation: 1, Protected: true}
313 gate := portsmocks.NewMockSessionSecurity(t)313 gate := portsmocks.NewMockSessionSecurity(t)
314 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })314 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315- c.ch.Security = gate315+ c.opts.Security = gate
316 if err := c.publish(context.Background()); err != nil {316 if err := c.publish(context.Background()); err != nil {
317 t.Fatal(err)317 t.Fatal(err)
318 }318 }
M internal/core/visibility_test.go +1 -1
@@ -16,7 +16,7 @@ func TestConfigureVisible(t *testing.T) {
1616 cfg.Layout.MaxColumns = 1
1717 commands := make(chan ports.ClientCommand, 256)
1818 scenes := make(chan []ports.Scene, 1)
19- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})
19+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
2020 if err != nil {
2121 t.Fatal(err)
2222 }
@@ -16,7 +16,7 @@ func TestConfigureVisible(t *testing.T) {
16 cfg.Layout.MaxColumns = 116 cfg.Layout.MaxColumns = 1
17 commands := make(chan ports.ClientCommand, 256)17 commands := make(chan ports.ClientCommand, 256)
18 scenes := make(chan []ports.Scene, 1)18 scenes := make(chan []ports.Scene, 1)
19- c, err := New(cfg, Channels{Commands: commands, Scenes: scenes})19+ c, err := New(cfg, Channels{Commands: commands, Scenes: scenes}, Options{})
20 if err != nil {20 if err != nil {
21 t.Fatal(err)21 t.Fatal(err)
22 }22 }
M internal/core/workspace_size_internal_test.go +1 -1
@@ -138,7 +138,7 @@ func TestBringNamedLookupAllocations(t *testing.T) {
138138 // newSizedCore returns a core with buffered output channels and no outputs.
139139 func newSizedCore(t *testing.T, cfg ports.Config) *Core {
140140 t.Helper()
141- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)})
141+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)}, Options{})
142142 if err != nil {
143143 t.Fatal(err)
144144 }
@@ -138,7 +138,7 @@ func TestBringNamedLookupAllocations(t *testing.T) {
138 // newSizedCore returns a core with buffered output channels and no outputs.138 // newSizedCore returns a core with buffered output channels and no outputs.
139 func newSizedCore(t *testing.T, cfg ports.Config) *Core {139 func newSizedCore(t *testing.T, cfg ports.Config) *Core {
140 t.Helper()140 t.Helper()
141- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)})141+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Layouts: make(chan ports.Layout, 1), Constraints: make(chan ports.PointerConstraint, 1), State: make(chan ports.State, 1), Workspaces: make(chan ports.Workspaces, 1)}, Options{})
142 if err != nil {142 if err != nil {
143 t.Fatal(err)143 t.Fatal(err)
144 }144 }
M internal/core/workspaces_test.go +3 -3
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestWorkspaceChannelCapacity(t *testing.T) {
13- _, err := core.New(config.Defaults(), core.Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: make(chan ports.Workspaces, 2)})
13+ _, err := core.New(config.Defaults(), core.Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: make(chan ports.Workspaces, 2)}, core.Options{})
1414 if err == nil {
1515 t.Fatal("expected capacity-1 validation")
1616 }
@@ -27,7 +27,7 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
2727 scenes := make(chan []ports.Scene, 1)
2828 state := make(chan ports.State, 1)
2929 spawn := make(chan ports.SpawnRequest, 8)
30- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn})
30+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn}, core.Options{})
3131 if err != nil {
3232 t.Fatal(err)
3333 }
@@ -114,7 +114,7 @@ func TestMoveWorkspaceReordersSnapshot(t *testing.T) {
114114 scenes := make(chan []ports.Scene, 1)
115115 spawn := make(chan ports.SpawnRequest, 8)
116116 commands := make(chan ports.ClientCommand, 32)
117- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn})
117+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn}, core.Options{})
118118 if err != nil {
119119 t.Fatal(err)
120120 }
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestWorkspaceChannelCapacity(t *testing.T) {12 func TestWorkspaceChannelCapacity(t *testing.T) {
13- _, err := core.New(config.Defaults(), core.Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: make(chan ports.Workspaces, 2)})13+ _, err := core.New(config.Defaults(), core.Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: make(chan ports.Workspaces, 2)}, core.Options{})
14 if err == nil {14 if err == nil {
15 t.Fatal("expected capacity-1 validation")15 t.Fatal("expected capacity-1 validation")
16 }16 }
@@ -27,7 +27,7 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
27 scenes := make(chan []ports.Scene, 1)27 scenes := make(chan []ports.Scene, 1)
28 state := make(chan ports.State, 1)28 state := make(chan ports.State, 1)
29 spawn := make(chan ports.SpawnRequest, 8)29 spawn := make(chan ports.SpawnRequest, 8)
30- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn})30+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn}, core.Options{})
31 if err != nil {31 if err != nil {
32 t.Fatal(err)32 t.Fatal(err)
33 }33 }
@@ -114,7 +114,7 @@ func TestMoveWorkspaceReordersSnapshot(t *testing.T) {
114 scenes := make(chan []ports.Scene, 1)114 scenes := make(chan []ports.Scene, 1)
115 spawn := make(chan ports.SpawnRequest, 8)115 spawn := make(chan ports.SpawnRequest, 8)
116 commands := make(chan ports.ClientCommand, 32)116 commands := make(chan ports.ClientCommand, 32)
117- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn})117+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn}, core.Options{})
118 if err != nil {118 if err != nil {
119 t.Fatal(err)119 t.Fatal(err)
120 }120 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 24

Tags 10

↑↓ moveenter openesc close