detached d3fc2d3

feat(capture): replace the form with rapid layer-shell selection and recording controls

commit
d3fc2d3fdb6ba5bb7c58ae123c20caf895490e00
author
brice · · 2026-09-30 10:30 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
e07cd4d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
57 files · +7423 -1359
M .hexcheck.yaml +4 -2
@@ -17,5 +17,7 @@ mocking:
1717 generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock']
1818 ruleSettings:
1919 suspicious-business-logic-in-adapter:
20- # app is the composition root; starting core and wiring channels is its job.
21- excludePaths: [internal/app/app.go]
20+ # Run/resolveOutput and recordInteractive wire sessions, GUI and core.
21+ # resolveCapture maps compositor-granted session state to a screencopy
22+ # target; it makes no selection or fallback decision.
23+ excludePaths: [internal/app/app.go, internal/app/record_interactive.go, internal/adapters/wayland/capture_session.go]
M AGENTS.md +1 -1
@@ -3,7 +3,7 @@
33 - Local-only repository: do not create remotes, publish branches, or access GitHub without explicit permission.
44 - Use Go 1.27; normal builds and tests use CGO_ENABLED=0. Enable CGO only for race tests.
55 - Follow lightweight hexagonal architecture: cmd/nefercap and internal/app wire internal/core, internal/ports, and internal/adapters. Core imports only stdlib and ports; ports contain no framework types.
6-- Use NeferGUI v0.1.0 for graphical interfaces and zerowrap with a component field for logging. Reuse NeferWL dependency versions where applicable; do not copy unrelated compositor dependencies.
6+- Use the exact NeferGUI version pinned in go.mod for layer-shell interfaces, and zerowrap with a component field for logging. Local signed commits may be pinned with Go pseudo-versions; do not invent release tags. Reuse NeferWL dependency versions where applicable; do not copy unrelated compositor dependencies.
77 - Work in .worktrees/ branches, not directly on main. Merge locally only after checks and review pass.
88 - Sign every commit and tag; commit format: type(scope): description.
99 - Minimize footprint. Bound buffers, queues and subprocess lifetimes. Reuse frame storage; test and benchmark steady-state allocations. Do not claim zero allocations without measurements.
M Makefile +2 -2
@@ -45,8 +45,8 @@ arch:
4545 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
4646 # the packages holding them. Every named test must exist, so the guard cannot
4747 # pass vacuously when a frame-path test is removed or renamed.
48-PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestFormAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations
49-PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/gui ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
48+PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestPickerAllocations TestSelectorAllocations TestRefreshAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations
49+PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/selection ./internal/adapters/indicator ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
5050 perf_empty :=
5151 perf_space := $(perf_empty) $(perf_empty)
5252 PERF_RE := ^($(subst $(perf_space),|,$(strip $(PERF_TESTS))))$$
M README.md +48 -26
@@ -1,47 +1,69 @@
11 # nefercap
22
3-A small Wayland screenshot and silent video recorder, with a NeferGUI control panel.
3+Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell selector.
4+
5+## Quick capture
6+
7+```sh
8+make bin
9+./bin/nefercap shot
10+./bin/nefercap rec
11+./bin/nefercap stop
12+./bin/nefercap status
13+```
14+
15+`nefercap` without a command starts screenshot selection. The selector covers the connected outputs, up to nine:
16+
17+- **Drag and release:** capture a rectangle, clamped to its monitor.
18+- **Click:** capture the monitor; small pointer jitter counts as a click.
19+- **M, then Enter:** select the monitor.
20+- **W, then Enter:** select the current workspace when native workspace metadata is available.
21+- **R:** return to rectangle selection. **G:** toggle thirds guides.
22+- **1–9:** select that monitor. **Esc:** cancel without creating a capture.
23+
24+A screenshot saves immediately. Recording displays a red border around the visible target and a small **REC / Stop** HUD. Both are excluded from nefercap's video. The HUD takes no keyboard focus; clicks outside Stop pass through. Press the recording shortcut again, click Stop, or run `nefercap stop` to finish the file.
25+
26+Monitor recording follows workspace changes. A workspace recording follows its stable identity and continues when another workspace is displayed. A fixed region stays attached to its monitor. A hidden workspace has no border on the unrelated visible workspace; the recording HUD remains available.
27+
28+Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path.
29+
30+Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border.
431
532 ## Requirements
633
734 - Go 1.27 for development; normal builds use `CGO_ENABLED=0`.
8-- A Wayland compositor exposing `zwlr_screencopy_manager_v1`.
9-- FFmpeg with `libx264` for MP4 recording.
10-- For the control panel: Vulkan, libxkbcommon, linux-dmabuf and linux-drm-syncobj support.
35+- For selection: layer-shell v4, Vulkan, libxkbcommon, linux-dmabuf and linux-drm-syncobj.
36+- For capture: `zwlr_screencopy_manager_v1`.
37+- For recording indicators and workspace targets: NeferWL's native capture-session protocol.
38+- FFmpeg with `libx264` for silent H.264 video in fragmented MP4.
39+
40+A standard compositor can support monitor/region screenshots. Recording refuses to start without native session support rather than capture its own controls. Standard capture clients keep their normal behavior and see the HUD and border.
1141
12-## Build and run
42+## Scripted commands
1343
1444 ```sh
15-make bin
1645 ./bin/nefercap outputs
1746 ./bin/nefercap screenshot -output DP-1 -file capture.png
1847 ./bin/nefercap screenshot -output DP-1 -region 0,0,1920x1080 -file region.png
1948 ./bin/nefercap record -output DP-1 -fps 30 -duration 10s -file capture.mp4
20-./bin/nefercap record -output DP-1 -size 1920x1080 -file presentation.mp4
21-./bin/nefercap gui
49+./bin/nefercap rec -size 1920x1080 -file presentation.mp4
2250 ```
2351
24-Use an output name from `outputs`. Omitting `-output` is allowed when there is exactly one output. `-file` is required for CLI captures. Running `nefercap` without a command opens the GUI.
25-
26-Regions use nonnegative, output-local logical coordinates, not desktop-global coordinates; captured frames use the compositor's negotiated physical dimensions. `-size` changes the video's encoded resolution, not application scaling, and requires two even dimensions. `-fps` accepts 1–120 (default 30). `-debug` enables diagnostic logging.
52+Use an output name from `outputs`. Scripted `screenshot` and `record` require `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor.
2753
28-Recording without `-duration` stops with Ctrl+C or SIGTERM and finalizes the file. Timed recording encodes `ceil(duration × fps)` frames; slow captures repeat the previous frame rather than shorten the video. A slow capture or encoder can make completion take longer than the requested duration. Odd source dimensions require an explicit even `-size` for video; pixels are never silently cropped.
54+`-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging.
2955
30-The GUI defaults to a ten-second recording and closes its panel before capturing. Existing files are never overwritten, and captured files are created with owner-only permissions.
56+Untimed recording stops with the HUD, the control command, Ctrl+C or SIGTERM and finalizes the file. The `stop` command acknowledges the request; finalization finishes in the recorder process, not before the control reply. Timed recording encodes `ceil(duration × fps)` frames. Slow capture can repeat frames; encoder backpressure can extend wall time without adding an unbounded queue.
3157
32-## Capture scope
33-
34-Screenshots are opaque SDR PNGs. Video is silent SDR H.264 in fragmented MP4. Monitor capture follows workspace changes. Regions remain attached to their monitor.
35-
36-The encoder uses libx264's `veryfast` preset with `zerolatency`, two encoding threads and one filter thread. This bounds buffering and favors a smaller runtime footprint over compression efficiency; files can be larger than with slower presets.
37-
38-Workspace identity capture, offscreen workspaces, audio, cursor guarantees, mouse-drawn selection, layer-shell overlays and a persistent recording control panel are not supported. NeferWL ignores the capture protocol's cursor option.
58+Screenshots and video are opaque SDR. Audio, pause and cursor guarantees are not supported. NeferWL ignores the capture protocol's cursor option.
3959
4060 ## Architecture and footprint
4161
42-`internal/core` owns the capture workflow and imports only the standard library and `internal/ports`. `internal/adapters` contains Wayland, PNG, FFmpeg, CLI and GUI integrations. `internal/app` is the composition root.
62+`internal/core` owns capture and selection state and imports only stdlib and `internal/ports`. `internal/adapters` contains Wayland, selection, indicator, control, PNG and FFmpeg integrations. `internal/app` wires them together.
63+
64+One capture is in flight. Writers synchronously consume borrowed, reusable shared-memory storage. Video strips stride padding and handles vertical inversion without a full-frame heap copy. The selector opens one transient layer surface per output; the HUD is one small surface, not a fullscreen GPU buffer. Idle UI does not redraw continuously.
4365
44-One capture is in flight at a time. Frame storage is borrowed until the next capture; writers consume it synchronously. Shared-memory storage is bounded and reused at unchanged geometry. Video row views remove stride padding and handle vertical inversion without copying a full frame into the Go heap. The encoder is a separate bounded-lifetime process; its memory use is not the Go heap.
66+The encoder uses `veryfast`, `zerolatency`, two encoding threads and one filter thread. Its separate-process footprint is not the Go heap; lower buffering can produce larger files than slower presets.
4567
4668 ```sh
4769 make check
@@ -51,13 +73,13 @@ staticcheck ./...
5173 go test ./... -run '^$' -bench . -benchmem
5274 ```
5375
54-Test doubles are generated by Mockery v3. Allocation guards hold frame views, selection validation, cached form parsing, video rows, encoder writes and output lookup at zero allocations. A real-socket capture test caps protocol allocations; it includes its in-process peer. GUI rendering and whole-process memory are separate costs, so the application does not claim zero total allocations.
76+Mockery v3 generates test doubles. Allocation guards cover frame views, selection state, unchanged overlay geometry, HUD refresh, video rows, encoder writes and output lookup. Protocol capture has a measured allocation budget; the application does not claim zero total allocations.
5577
56-## Local dependency setup
78+## Local dependencies
5779
58-This repository has no remote. NeferGUI is pinned to the locally available **v0.1.0** tag. Its exact tagged source has been loaded into the local Go module cache through a file-based module proxy; no GitHub access is needed on this machine.
80+This repository has no remote. NeferGUI is pinned to a signed local commit with layer-shell support, using a Go pseudo-version rather than an unpublished release tag. Its exact source is cached through an offline file-based module proxy.
5981
60-The ignored `.local-deps/` directory in the bootstrap worktree holds the tagged archive and local proxy tooling. Worktrees share the Go module cache, so they do not need a `replace` directive. A different machine without that cached module needs the same tagged source or access to the matching published version; the repository alone does not supply the dependency archive.
82+The bootstrap worktree's ignored `.local-deps/` contains source archives and proxy tooling. Builds on this machine work with `GOWORK=off GOPROXY=off`; another machine needs those exact sources or matching module-proxy entries. `go.mod` and `go.sum` pin the versions and hashes.
6183
6284 ## License
6385
M go.mod +1 -1
@@ -3,7 +3,7 @@ module github.com/bnema/nefercap
33 go 1.27
44
55 require (
6- github.com/bnema/nefergui v0.1.0
6+ github.com/bnema/nefergui v0.1.1-0.20260930094617-905a5c7e50c4
77 github.com/bnema/wlturbo v0.3.0
88 github.com/bnema/zerowrap v1.4.1
99 github.com/stretchr/testify v1.12.1
M go.sum +2 -2
@@ -1,5 +1,5 @@
1-github.com/bnema/nefergui v0.1.0 h1:YqSAJI0OqwsJy3GzIWwjo3u+UM0WfGbER+Dl68e9/dM=
2-github.com/bnema/nefergui v0.1.0/go.mod h1:JNjwv/YlesI0FflJvmHB5OqLd/bHfqFakpvJ2zJr+F4=
1+github.com/bnema/nefergui v0.1.1-0.20260930094617-905a5c7e50c4 h1:8oHG+25zJ2gwCjUUMXMkLp44zeDF7XR8OhjDRyfI8vY=
2+github.com/bnema/nefergui v0.1.1-0.20260930094617-905a5c7e50c4/go.mod h1:JNjwv/YlesI0FflJvmHB5OqLd/bHfqFakpvJ2zJr+F4=
33 github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8=
44 github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM=
55 github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE=
M internal/adapters/cli/cli.go +56 -18
@@ -18,10 +18,18 @@ import (
1818 type Command string
1919
2020 const (
21+ // Shot and Rec pick a target with the layer-shell selector; Rec toggles:
22+ // while a recording is active it stops that recording instead. Shot is
23+ // the default command.
24+ Shot Command = "shot"
25+ Rec Command = "rec"
26+ Stop Command = "stop"
27+ Status Command = "status"
28+ // Outputs, Screenshot and Record are the scriptable commands. They never
29+ // open the selector and require -file.
2130 Outputs Command = "outputs"
2231 Screenshot Command = "screenshot"
2332 Record Command = "record"
24- GUI Command = "gui"
2533 )
2634
2735 // DefaultFPS is the recording frame rate when -fps is not given.
@@ -30,7 +38,9 @@ const DefaultFPS = 30
3038 // ErrUsage wraps every command-line usage error.
3139 var ErrUsage = errors.New("usage error")
3240
33-// Options is the parsed command line. Video is set only for Record.
41+// Options is the parsed command line. Video is set only for Record and Rec.
42+// Path is optional for Shot and Rec, where an empty Path lets the caller
43+// choose one.
3444 type Options struct {
3545 Command Command
3646 Output string // output name; empty lets the caller choose
@@ -45,20 +55,35 @@ type Options struct {
4555 func Usage() string {
4656 return fmt.Sprintf(`Usage: nefercap [command] [flags]
4757
48-Commands:
49- gui open the capture control panel (default)
58+Interactive commands (layer-shell selector over the current output):
59+ shot select what to capture and save a PNG (default)
60+ rec select what to record; run again while recording to stop it
61+ stop stop the active recording
62+ status print the recording state
63+
64+Selector keys and mouse:
65+ drag select a region, accepted when the left button is released
66+ click capture the whole monitor; after w, the current workspace
67+ r back to region selection
68+ m choose the whole monitor, Enter confirms
69+ w choose the current workspace when known, click or Enter confirms
70+ 1..9 pick that whole monitor from any overlay
71+ g toggle rule-of-thirds guides
72+ Escape cancel
73+
74+Script commands (never open the selector):
5075 outputs list outputs
5176 screenshot save a PNG screenshot
5277 record record silent video until interrupted or target video length is reached
5378
5479 Flags:
5580 -debug enable debug logging (all commands)
56- -output NAME output name (screenshot, record)
81+ -output NAME output name (all except stop, status, outputs)
82+ -file PATH new file to write; optional for shot, rec, required for screenshot, record
5783 -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
58- -file PATH new file to write, required (screenshot, record)
59- -fps N frames per second, 1..%d (record, default %d)
60- -size WxH scaled video size, both even, at most %d each (record, default source size)
61- -duration D encoded video length such as 30s; 0 records until interrupted (record)
84+ -fps N frames per second, 1..%d (rec, record, default %d)
85+ -size WxH scaled video size, both even, at most %d each (rec, record, default source size)
86+ -duration D encoded video length such as 30s; 0 records until stopped (rec, record)
6287 `, ports.MaxFPS, DefaultFPS, ports.MaxDimension)
6388 }
6489
@@ -68,10 +93,10 @@ func Parse(args []string, out io.Writer) (Options, error) {
6893 if out == nil {
6994 out = io.Discard
7095 }
71- opts := Options{Command: GUI}
96+ opts := Options{Command: Shot}
7297 if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
7398 switch cmd := Command(args[0]); cmd {
74- case Outputs, Screenshot, Record, GUI:
99+ case Shot, Rec, Stop, Status, Outputs, Screenshot, Record:
75100 opts.Command = cmd
76101 args = args[1:]
77102 default:
@@ -79,18 +104,23 @@ func Parse(args []string, out io.Writer) (Options, error) {
79104 }
80105 }
81106
107+ scripted := opts.Command == Screenshot || opts.Command == Record
108+ interactive := opts.Command == Shot || opts.Command == Rec
109+ video := opts.Command == Record || opts.Command == Rec
110+
82111 fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
83112 fs.SetOutput(io.Discard)
84113 fs.BoolVar(&opts.Debug, "debug", false, "")
85114 var region, size string
86115 fps := DefaultFPS
87- capture := opts.Command == Screenshot || opts.Command == Record
88- if capture {
116+ if scripted || interactive {
89117 fs.StringVar(&opts.Output, "output", "", "")
90- fs.StringVar(&region, "region", "", "")
91118 fs.StringVar(&opts.Path, "file", "", "")
92119 }
93- if opts.Command == Record {
120+ if scripted {
121+ fs.StringVar(&region, "region", "", "")
122+ }
123+ if video {
94124 fs.IntVar(&fps, "fps", DefaultFPS, "")
95125 fs.StringVar(&size, "size", "", "")
96126 fs.DurationVar(&opts.Duration, "duration", 0, "")
@@ -106,13 +136,21 @@ func Parse(args []string, out io.Writer) (Options, error) {
106136 if fs.NArg() > 0 {
107137 return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
108138 }
109- if !capture {
139+ if !scripted && !interactive {
110140 return opts, nil
111141 }
112142
113- if opts.Path == "" {
143+ if scripted && opts.Path == "" {
114144 return Options{}, usageErr("-file is required")
115145 }
146+ if interactive {
147+ // Set explicitly so "-file ''" is rejected rather than treated as auto.
148+ explicit := false
149+ fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" })
150+ if explicit && opts.Path == "" {
151+ return Options{}, usageErr("-file must not be empty")
152+ }
153+ }
116154 if region != "" {
117155 r, err := parseRegion(region)
118156 if err != nil {
@@ -120,7 +158,7 @@ func Parse(args []string, out io.Writer) (Options, error) {
120158 }
121159 opts.Region = r
122160 }
123- if opts.Command == Record {
161+ if video {
124162 if fps < 1 || fps > ports.MaxFPS {
125163 return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
126164 }
M internal/adapters/cli/cli_test.go +44 -7
@@ -20,9 +20,21 @@ func TestParseValid(t *testing.T) {
2020 args []string
2121 want cli.Options
2222 }{
23- {"default gui", nil, cli.Options{Command: cli.GUI}},
24- {"gui debug", []string{"-debug"}, cli.Options{Command: cli.GUI, Debug: true}},
25- {"explicit gui", []string{"gui"}, cli.Options{Command: cli.GUI}},
23+ {"default shot", nil, cli.Options{Command: cli.Shot}},
24+ {"default shot debug", []string{"-debug"}, cli.Options{Command: cli.Shot, Debug: true}},
25+ {"default shot flags", []string{"-file", "a.png", "-output", "DP-1"},
26+ cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1"}},
27+ {"shot", []string{"shot"}, cli.Options{Command: cli.Shot}},
28+ {"shot all", []string{"shot", "-file", "a.png", "-output", "DP-1", "-debug"},
29+ cli.Options{Command: cli.Shot, Path: "a.png", Output: "DP-1", Debug: true}},
30+ {"rec defaults", []string{"rec"}, cli.Options{Command: cli.Rec, Video: ports.VideoSettings{FPS: 30}}},
31+ {"rec all", []string{"rec", "-file", "a.mkv", "-output", "eDP-1", "-fps", "60", "-size", "1280x720", "-duration", "10s", "-debug"},
32+ cli.Options{Command: cli.Rec, Path: "a.mkv", Output: "eDP-1", Duration: 10 * time.Second, Debug: true,
33+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
34+ {"stop", []string{"stop"}, cli.Options{Command: cli.Stop}},
35+ {"stop debug", []string{"stop", "-debug"}, cli.Options{Command: cli.Stop, Debug: true}},
36+ {"status", []string{"status"}, cli.Options{Command: cli.Status}},
37+ {"status debug", []string{"status", "-debug"}, cli.Options{Command: cli.Status, Debug: true}},
2638 {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}},
2739 {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}},
2840 {"screenshot", []string{"screenshot", "-file", "a.png"},
@@ -58,14 +70,37 @@ func TestParseUsageErrors(t *testing.T) {
5870 cases := map[string][]string{
5971 "unknown command": {"snap"},
6072 "positional after command": {"outputs", "extra"},
61- "positional gui": {"extra"},
73+ "positional default": {"extra"},
74+ "positional shot": {"shot", "extra"},
75+ "positional stop": {"stop", "extra"},
76+ "gui removed": {"gui"},
77+ "gui removed with flag": {"gui", "-debug"},
78+ "empty shot file": {"shot", "-file", ""},
79+ "empty rec file": {"rec", "-file", ""},
80+ "empty default file": {"-file", ""},
81+ "region on shot": {"shot", "-region", "0,0,10x10"},
82+ "region on rec": {"rec", "-region", "0,0,10x10"},
83+ "region on default": {"-region", "0,0,10x10"},
84+ "fps on shot": {"shot", "-fps", "30"},
85+ "size on shot": {"shot", "-size", "2x2"},
86+ "duration on shot": {"shot", "-duration", "1s"},
87+ "fps zero on rec": {"rec", "-fps", "0"},
88+ "fps high on rec": {"rec", "-fps", "121"},
89+ "size odd on rec": {"rec", "-size", "641x480"},
90+ "size huge on rec": {"rec", "-size", "16384x16384"},
91+ "duration negative on rec": {"rec", "-duration", "-1s"},
92+ "file on stop": {"stop", "-file", "a"},
93+ "output on stop": {"stop", "-output", "DP-1"},
94+ "file on status": {"status", "-file", "a"},
95+ "output on status": {"status", "-output", "DP-1"},
96+ "fps on status": {"status", "-fps", "30"},
97+ "file on outputs": {"outputs", "-file", "a"},
6298 "positional after flags": {"record", "-file", "a.mkv", "extra"},
6399 "unknown flag": {"-nope"},
64100 "unknown flag in command": {"outputs", "-nope"},
65101 "screenshot missing file": {"screenshot"},
66102 "record missing file": {"record", "-fps", "30"},
67103 "empty file": {"screenshot", "-file", ""},
68- "capture flag on gui": {"-file", "a.png"},
69104 "capture flag on outputs": {"outputs", "-output", "DP-1"},
70105 "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"},
71106 "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"},
@@ -121,7 +156,7 @@ func TestParseErrorNamesProblem(t *testing.T) {
121156 }
122157
123158 func TestParseHelp(t *testing.T) {
124- for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}} {
159+ for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}, {"shot", "-h"}, {"rec", "-h"}, {"stop", "-h"}, {"status", "--help"}} {
125160 var out bytes.Buffer
126161 got, err := cli.Parse(args, &out)
127162 assert.True(t, errors.Is(err, flag.ErrHelp), args)
@@ -132,7 +167,9 @@ func TestParseHelp(t *testing.T) {
132167
133168 func TestUsageMentionsEverything(t *testing.T) {
134169 u := cli.Usage()
135- for _, s := range []string{"gui", "outputs", "screenshot", "record", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
170+ for _, s := range []string{"shot", "rec", "stop", "status", "outputs", "screenshot", "record",
171+ "layer-shell", "drag", "click", "Escape", "Enter", "workspace", "1..9", " r ", " m ", " w ", " g ", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
136172 assert.Contains(t, u, s)
137173 }
174+ assert.NotContains(t, u, " gui ")
138175 }
A internal/adapters/control/control.go +527 -0
@@ -0,0 +1,527 @@
1+// Package control implements the private recording control socket.
2+//
3+// A recording process calls Listen to own $XDG_RUNTIME_DIR/nefercap/record.sock
4+// (mode 0600, inside a 0700 directory owned by the caller). Another process
5+// asks it to stop or reports its state with Stop and Query. The wire protocol
6+// is two request lines, "stop\n" and "status\n", answered with "ok\n" and
7+// "recording\n". No path, pid or file name is ever sent.
8+//
9+// Ownership is enforced by an flock(2) on a private lock file held for the
10+// server's lifetime, so a leftover socket is removed only by the process that
11+// holds the lock and clients never unlink anything. Peers are checked with
12+// SO_PEERCRED and must run as the same user; this is not general Unix
13+// authentication, and processes of the same user are trusted.
14+package control
15+
16+import (
17+ "bytes"
18+ "context"
19+ "errors"
20+ "fmt"
21+ "io"
22+ "net"
23+ "os"
24+ "path/filepath"
25+ "strconv"
26+ "strings"
27+ "sync"
28+ "time"
29+
30+ "golang.org/x/sys/unix"
31+)
32+
33+const (
34+ dirName = "nefercap"
35+ sockName = "record.sock"
36+ lockName = "record.lock"
37+ maxPath = 4096 // longest accepted runtime directory
38+ maxRequest = 64
39+ connLimit = 4
40+ ioTimeout = time.Second
41+ backlog = connLimit
42+
43+ stopLine = "stop\n"
44+ statusLine = "status\n"
45+ okLine = "ok\n"
46+ recLine = "recording\n"
47+)
48+
49+var (
50+ // ErrAlreadyRecording means another live process owns the control socket.
51+ ErrAlreadyRecording = errors.New("control: a recording is already active")
52+ // ErrNoRuntimeDir means XDG_RUNTIME_DIR is unset or unusable.
53+ ErrNoRuntimeDir = errors.New("control: XDG_RUNTIME_DIR is not an absolute path")
54+ // ErrInsecure means a control path has the wrong type, owner or mode.
55+ ErrInsecure = errors.New("control: insecure control path")
56+ // ErrProtocol means the peer answered with something unexpected.
57+ ErrProtocol = errors.New("control: protocol error")
58+)
59+
60+// Request is a control request delivered to the recording owner. Status
61+// requests are answered by the server itself and are never delivered.
62+type Request uint8
63+
64+// RequestStop asks the recording owner to cancel and finalize.
65+const RequestStop Request = iota + 1
66+
67+// Server is the listening side owned by the recording process.
68+type Server struct {
69+ ln *net.UnixListener
70+ dirFD int
71+ lockFD int
72+ dev uint64
73+ ino uint64
74+ peerUID uint32
75+
76+ reqs chan Request
77+ sem chan struct{}
78+ done chan struct{}
79+ wg sync.WaitGroup
80+
81+ mu sync.Mutex
82+ closed bool
83+ conns map[net.Conn]struct{}
84+
85+ once sync.Once
86+ closeErr error
87+}
88+
89+// cleanRuntimeDir validates a raw runtime directory string.
90+func cleanRuntimeDir(rd string) (string, error) {
91+ if !filepath.IsAbs(rd) || len(rd) > maxPath || strings.IndexByte(rd, 0) >= 0 {
92+ return "", ErrNoRuntimeDir
93+ }
94+ return filepath.Clean(rd), nil
95+}
96+
97+func runtimeDir() (string, error) { return cleanRuntimeDir(os.Getenv("XDG_RUNTIME_DIR")) }
98+
99+// openSecureDir opens runtime/nefercap without following symlinks and checks
100+// that it is a directory owned by uid with mode exactly 0700. All later
101+// operations are relative to the returned descriptor.
102+func openSecureDir(runtime string, create bool, uid uint32) (int, error) {
103+ dir := filepath.Join(runtime, dirName)
104+ if create {
105+ err := unix.Mkdir(dir, 0o700)
106+ if err == nil {
107+ err = unix.Chmod(dir, 0o700) // independent of the umask
108+ }
109+ if err != nil && err != unix.EEXIST {
110+ return -1, fmt.Errorf("control: create %s: %w", dirName, err)
111+ }
112+ }
113+ fd, err := unix.Open(dir, unix.O_PATH|unix.O_NOFOLLOW|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
114+ if err != nil {
115+ if err == unix.ELOOP || err == unix.ENOTDIR {
116+ return -1, fmt.Errorf("%w: %s is not a directory", ErrInsecure, dirName)
117+ }
118+ return -1, fmt.Errorf("control: open %s: %w", dirName, err)
119+ }
120+ var st unix.Stat_t
121+ if err := unix.Fstat(fd, &st); err != nil {
122+ unix.Close(fd)
123+ return -1, fmt.Errorf("control: stat %s: %w", dirName, err)
124+ }
125+ if st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Uid != uid || st.Mode&0o777 != 0o700 {
126+ unix.Close(fd)
127+ return -1, fmt.Errorf("%w: %s must be a 0700 directory owned by the current user", ErrInsecure, dirName)
128+ }
129+ return fd, nil
130+}
131+
132+// Listen becomes the single control server. It returns ErrAlreadyRecording
133+// when another live process holds the lock. The caller must Close the server.
134+func Listen() (*Server, error) {
135+ rd, err := runtimeDir()
136+ if err != nil {
137+ return nil, err
138+ }
139+ return listen(rd, uint32(os.Geteuid()))
140+}
141+
142+// listen is Listen with an explicit accepted peer uid, so tests can exercise
143+// the foreign-peer rejection with real sockets.
144+func listen(runtime string, peerUID uint32) (s *Server, err error) {
145+ if runtime, err = cleanRuntimeDir(runtime); err != nil {
146+ return nil, err
147+ }
148+ euid := uint32(os.Geteuid())
149+ dirFD, err := openSecureDir(runtime, true, euid)
150+ if err != nil {
151+ return nil, err
152+ }
153+ lockFD := -1
154+ sockFD := -1
155+ bound := false
156+ var dev, ino uint64
157+ defer func() {
158+ if err == nil {
159+ return
160+ }
161+ if sockFD >= 0 {
162+ unix.Close(sockFD)
163+ }
164+ if bound && sameInode(dirFD, dev, ino) {
165+ unix.Unlinkat(dirFD, sockName, 0)
166+ }
167+ if lockFD >= 0 {
168+ unix.Close(lockFD)
169+ }
170+ unix.Close(dirFD)
171+ }()
172+
173+ lockFD, err = unix.Openat(dirFD, lockName, unix.O_RDWR|unix.O_CREAT|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600)
174+ if err != nil {
175+ return nil, fmt.Errorf("control: open lock: %w", err)
176+ }
177+ var st unix.Stat_t
178+ if err = unix.Fstat(lockFD, &st); err != nil {
179+ return nil, fmt.Errorf("control: stat lock: %w", err)
180+ }
181+ if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Uid != euid {
182+ return nil, fmt.Errorf("%w: lock file", ErrInsecure)
183+ }
184+ if err = unix.Fchmod(lockFD, 0o600); err != nil {
185+ return nil, fmt.Errorf("control: chmod lock: %w", err)
186+ }
187+ if err = unix.Flock(lockFD, unix.LOCK_EX|unix.LOCK_NB); err != nil {
188+ if err == unix.EWOULDBLOCK {
189+ err = ErrAlreadyRecording
190+ } else {
191+ err = fmt.Errorf("control: lock: %w", err)
192+ }
193+ return nil, err
194+ }
195+
196+ // The lock is held, so no live server owns the socket path: a leftover
197+ // socket of ours is stale. Anything else is left alone.
198+ if err = unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err == nil {
199+ if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid {
200+ err = fmt.Errorf("%w: %s is not a socket owned by the current user", ErrInsecure, sockName)
201+ return nil, err
202+ }
203+ if err = unix.Unlinkat(dirFD, sockName, 0); err != nil {
204+ return nil, fmt.Errorf("control: remove stale socket: %w", err)
205+ }
206+ } else if err != unix.ENOENT {
207+ return nil, fmt.Errorf("control: stat socket: %w", err)
208+ }
209+
210+ sockFD, err = unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, 0)
211+ if err != nil {
212+ return nil, fmt.Errorf("control: socket: %w", err)
213+ }
214+ if err = unix.Bind(sockFD, &unix.SockaddrUnix{Name: dirPath(dirFD)}); err != nil {
215+ return nil, fmt.Errorf("control: bind: %w", err)
216+ }
217+ bound = true
218+ if err = unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
219+ bound = false
220+ return nil, fmt.Errorf("control: stat socket: %w", err)
221+ }
222+ dev, ino = uint64(st.Dev), uint64(st.Ino)
223+ // Not yet listening, so nobody can connect while the mode is tightened.
224+ if err = unix.Fchmodat(dirFD, sockName, 0o600, 0); err != nil {
225+ return nil, fmt.Errorf("control: chmod socket: %w", err)
226+ }
227+ if err = unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
228+ return nil, fmt.Errorf("control: stat socket: %w", err)
229+ }
230+ if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid || st.Mode&0o777 != 0o600 ||
231+ uint64(st.Dev) != dev || uint64(st.Ino) != ino {
232+ err = fmt.Errorf("%w: created socket", ErrInsecure)
233+ return nil, err
234+ }
235+ if err = unix.Listen(sockFD, backlog); err != nil {
236+ return nil, fmt.Errorf("control: listen: %w", err)
237+ }
238+ f := os.NewFile(uintptr(sockFD), sockName)
239+ sockFD = -1
240+ l, lerr := net.FileListener(f)
241+ f.Close()
242+ if lerr != nil {
243+ err = fmt.Errorf("control: listener: %w", lerr)
244+ return nil, err
245+ }
246+ ln := l.(*net.UnixListener)
247+ ln.SetUnlinkOnClose(false)
248+
249+ s = &Server{
250+ ln: ln,
251+ dirFD: dirFD,
252+ lockFD: lockFD,
253+ dev: dev,
254+ ino: ino,
255+ peerUID: peerUID,
256+ reqs: make(chan Request, 1),
257+ sem: make(chan struct{}, connLimit),
258+ done: make(chan struct{}),
259+ conns: make(map[net.Conn]struct{}, connLimit),
260+ }
261+ s.wg.Add(1)
262+ go s.acceptLoop()
263+ return s, nil
264+}
265+
266+// dirPath names the verified directory through its descriptor, so bind and
267+// connect cannot be redirected by swapping path components afterwards. It is
268+// short, which also avoids the sun_path length limit.
269+func dirPath(dirFD int) string {
270+ return "/proc/self/fd/" + strconv.Itoa(dirFD) + "/" + sockName
271+}
272+
273+func sameInode(dirFD int, dev, ino uint64) bool {
274+ var st unix.Stat_t
275+ if unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW) != nil {
276+ return false
277+ }
278+ return st.Mode&unix.S_IFMT == unix.S_IFSOCK && uint64(st.Dev) == dev && uint64(st.Ino) == ino
279+}
280+
281+// Requests delivers RequestStop. Repeated stops coalesce into one pending
282+// request. The channel is never closed; select on it together with the
283+// recording's own completion.
284+func (s *Server) Requests() <-chan Request { return s.reqs }
285+
286+// Close stops serving, removes the socket only if it is still the inode this
287+// server created, then releases the lock. It is idempotent and safe for
288+// concurrent use.
289+func (s *Server) Close() error {
290+ s.once.Do(func() {
291+ s.mu.Lock()
292+ s.closed = true
293+ close(s.done)
294+ for c := range s.conns {
295+ c.Close()
296+ }
297+ s.mu.Unlock()
298+ s.closeErr = s.ln.Close()
299+ s.wg.Wait()
300+ if sameInode(s.dirFD, s.dev, s.ino) {
301+ if err := unix.Unlinkat(s.dirFD, sockName, 0); err != nil && err != unix.ENOENT && s.closeErr == nil {
302+ s.closeErr = fmt.Errorf("control: remove socket: %w", err)
303+ }
304+ }
305+ unix.Close(s.dirFD)
306+ unix.Close(s.lockFD) // releases the flock last
307+ })
308+ return s.closeErr
309+}
310+
311+func (s *Server) acceptLoop() {
312+ defer s.wg.Done()
313+ for {
314+ select {
315+ case s.sem <- struct{}{}:
316+ case <-s.done:
317+ return
318+ }
319+ c, err := s.ln.AcceptUnix()
320+ if err != nil {
321+ <-s.sem
322+ select {
323+ case <-s.done:
324+ return
325+ default:
326+ }
327+ // Transient failure (for example fd exhaustion): back off.
328+ t := time.NewTimer(50 * time.Millisecond)
329+ select {
330+ case <-t.C:
331+ case <-s.done:
332+ t.Stop()
333+ return
334+ }
335+ continue
336+ }
337+ s.wg.Add(1)
338+ go s.serve(c)
339+ }
340+}
341+
342+func (s *Server) serve(c *net.UnixConn) {
343+ defer s.wg.Done()
344+ defer func() { <-s.sem }()
345+ s.mu.Lock()
346+ if s.closed {
347+ s.mu.Unlock()
348+ c.Close()
349+ return
350+ }
351+ s.conns[c] = struct{}{}
352+ s.mu.Unlock()
353+ defer func() {
354+ s.mu.Lock()
355+ delete(s.conns, c)
356+ s.mu.Unlock()
357+ c.Close()
358+ }()
359+
360+ if c.SetDeadline(time.Now().Add(ioTimeout)) != nil {
361+ return
362+ }
363+ if uid, err := peerUID(c); err != nil || uid != s.peerUID {
364+ return
365+ }
366+ line, ok := readLine(c)
367+ if !ok {
368+ return
369+ }
370+ var reply string
371+ switch {
372+ case bytes.Equal(line, []byte(stopLine)):
373+ // Coalescing, non-blocking: the ack means the stop was requested,
374+ // not that the capture file is finalized.
375+ select {
376+ case s.reqs <- RequestStop:
377+ default:
378+ }
379+ reply = okLine
380+ case bytes.Equal(line, []byte(statusLine)):
381+ reply = recLine
382+ default:
383+ return
384+ }
385+ io.WriteString(c, reply)
386+}
387+
388+// readLine reads one newline-terminated request of at most maxRequest bytes.
389+func readLine(r io.Reader) ([]byte, bool) {
390+ var buf [maxRequest]byte
391+ b := buf[:]
392+ n := 0
393+ for n < len(b) {
394+ m, err := r.Read(b[n:])
395+ n += m
396+ if i := bytes.IndexByte(b[:n], '\n'); i >= 0 {
397+ // The request is exactly one line; trailing bytes are junk.
398+ return b[:i+1], i+1 == n
399+ }
400+ if err != nil {
401+ return nil, false
402+ }
403+ }
404+ return nil, false
405+}
406+
407+func peerUID(c *net.UnixConn) (uint32, error) {
408+ rc, err := c.SyscallConn()
409+ if err != nil {
410+ return 0, err
411+ }
412+ var uc *unix.Ucred
413+ var serr error
414+ if err := rc.Control(func(fd uintptr) {
415+ uc, serr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
416+ }); err != nil {
417+ return 0, err
418+ }
419+ if serr != nil {
420+ return 0, serr
421+ }
422+ return uc.Uid, nil
423+}
424+
425+// Stop asks the active recording to stop. It returns false with a nil error
426+// when no recording is active. A true result means the request was accepted,
427+// not that the capture has been finalized.
428+func Stop(ctx context.Context) (bool, error) {
429+ rd, err := runtimeDir()
430+ if err != nil {
431+ return false, err
432+ }
433+ return exchange(ctx, rd, stopLine, okLine)
434+}
435+
436+// Query reports whether a recording is active.
437+func Query(ctx context.Context) (bool, error) {
438+ rd, err := runtimeDir()
439+ if err != nil {
440+ return false, err
441+ }
442+ return exchange(ctx, rd, statusLine, recLine)
443+}
444+
445+func exchange(ctx context.Context, runtime, req, want string) (bool, error) {
446+ euid := uint32(os.Geteuid())
447+ dirFD, err := openSecureDir(runtime, false, euid)
448+ if err != nil {
449+ if errors.Is(err, unix.ENOENT) {
450+ return false, nil
451+ }
452+ return false, err
453+ }
454+ defer unix.Close(dirFD)
455+ var st unix.Stat_t
456+ if err := unix.Fstatat(dirFD, sockName, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
457+ if err == unix.ENOENT {
458+ return false, nil
459+ }
460+ return false, fmt.Errorf("control: stat socket: %w", err)
461+ }
462+ if st.Mode&unix.S_IFMT != unix.S_IFSOCK || st.Uid != euid || st.Mode&0o077 != 0 {
463+ return false, fmt.Errorf("%w: %s", ErrInsecure, sockName)
464+ }
465+
466+ ctx, cancel := context.WithTimeout(ctx, ioTimeout)
467+ defer cancel()
468+ conn, err := dialRetry(ctx, dirPath(dirFD))
469+ if err != nil {
470+ // A dead owner leaves the socket behind; only the next server, which
471+ // holds the lock, removes it.
472+ if errors.Is(err, unix.ECONNREFUSED) || errors.Is(err, unix.ENOENT) {
473+ return false, nil
474+ }
475+ return false, err
476+ }
477+ defer conn.Close()
478+ uc := conn.(*net.UnixConn)
479+ stop := context.AfterFunc(ctx, func() { uc.Close() })
480+ defer stop()
481+ if dl, ok := ctx.Deadline(); ok {
482+ uc.SetDeadline(dl)
483+ }
484+ if uid, err := peerUID(uc); err != nil {
485+ return false, fmt.Errorf("control: peer credentials: %w", err)
486+ } else if uid != euid {
487+ return false, fmt.Errorf("%w: socket served by another user", ErrInsecure)
488+ }
489+ if _, err := io.WriteString(uc, req); err != nil {
490+ return false, ctxErr(ctx, err)
491+ }
492+ var buf [len(recLine)]byte // the longest reply
493+ n, err := io.ReadFull(uc, buf[:len(want)])
494+ if err != nil {
495+ return false, ctxErr(ctx, err)
496+ }
497+ if string(buf[:n]) != want {
498+ return false, ErrProtocol
499+ }
500+ return true, nil
501+}
502+
503+// dialRetry connects, retrying while the server's small accept backlog is
504+// full (EAGAIN) until ctx ends.
505+func dialRetry(ctx context.Context, path string) (net.Conn, error) {
506+ var d net.Dialer
507+ for {
508+ conn, err := d.DialContext(ctx, "unix", path)
509+ if !errors.Is(err, unix.EAGAIN) {
510+ return conn, err
511+ }
512+ t := time.NewTimer(5 * time.Millisecond)
513+ select {
514+ case <-t.C:
515+ case <-ctx.Done():
516+ t.Stop()
517+ return nil, ctx.Err()
518+ }
519+ }
520+}
521+
522+func ctxErr(ctx context.Context, err error) error {
523+ if cerr := ctx.Err(); cerr != nil {
524+ return cerr
525+ }
526+ return err
527+}
A internal/adapters/control/control_test.go +606 -0
@@ -0,0 +1,606 @@
1+package control
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "net"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "runtime"
12+ "strings"
13+ "sync"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+ "golang.org/x/sys/unix"
20+)
21+
22+const helperEnv = "NEFERCAP_CONTROL_TEST_HELPER"
23+
24+// TestMain doubles as a helper process: with helperEnv set, the test binary
25+// runs a real recording owner instead of the tests.
26+func TestMain(m *testing.M) {
27+ switch os.Getenv(helperEnv) {
28+ case "":
29+ os.Exit(m.Run())
30+ case "owner":
31+ os.Exit(helperOwner())
32+ }
33+ os.Exit(64)
34+}
35+
36+func helperOwner() int {
37+ s, err := Listen()
38+ if errors.Is(err, ErrAlreadyRecording) {
39+ return 3
40+ }
41+ if err != nil {
42+ return 4
43+ }
44+ os.Stdout.WriteString("ready\n")
45+ if os.Getenv(helperEnv+"_HANG") != "" {
46+ select {} // killed by the test
47+ }
48+ <-s.Requests()
49+ if s.Close() != nil {
50+ return 5
51+ }
52+ return 0
53+}
54+
55+// setup points XDG_RUNTIME_DIR at a fresh private temp directory.
56+func setup(t *testing.T) string {
57+ t.Helper()
58+ rd := t.TempDir()
59+ t.Setenv("XDG_RUNTIME_DIR", rd)
60+ return rd
61+}
62+
63+func listenT(t *testing.T) *Server {
64+ t.Helper()
65+ s, err := Listen()
66+ require.NoError(t, err)
67+ t.Cleanup(func() { s.Close() })
68+ return s
69+}
70+
71+func ctx(t *testing.T) context.Context {
72+ t.Helper()
73+ c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
74+ t.Cleanup(cancel)
75+ return c
76+}
77+
78+func sockPath(rd string) string { return filepath.Join(rd, dirName, sockName) }
79+
80+func dial(t *testing.T, rd string) *net.UnixConn {
81+ t.Helper()
82+ c, err := net.Dial("unix", sockPath(rd))
83+ require.NoError(t, err)
84+ t.Cleanup(func() { c.Close() })
85+ return c.(*net.UnixConn)
86+}
87+
88+func startHelper(t *testing.T, extra ...string) (*exec.Cmd, io.ReadCloser) {
89+ t.Helper()
90+ cmd := exec.Command(os.Args[0], "-test.run=^$")
91+ cmd.Env = append(os.Environ(), append([]string{helperEnv + "=owner"}, extra...)...)
92+ out, err := cmd.StdoutPipe()
93+ require.NoError(t, err)
94+ require.NoError(t, cmd.Start())
95+ t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
96+ return cmd, out
97+}
98+
99+func waitReady(t *testing.T, out io.Reader) {
100+ t.Helper()
101+ buf := make([]byte, len("ready\n"))
102+ _, err := io.ReadFull(out, buf)
103+ require.NoError(t, err)
104+ require.Equal(t, "ready\n", string(buf))
105+}
106+
107+func TestStopAndStatusRoundTrip(t *testing.T) {
108+ setup(t)
109+ s := listenT(t)
110+
111+ ok, err := Query(ctx(t))
112+ require.NoError(t, err)
113+ assert.True(t, ok)
114+ select {
115+ case r := <-s.Requests():
116+ t.Fatalf("status was delivered as %v", r)
117+ default:
118+ }
119+
120+ ok, err = Stop(ctx(t))
121+ require.NoError(t, err)
122+ assert.True(t, ok)
123+ select {
124+ case r := <-s.Requests():
125+ assert.Equal(t, RequestStop, r)
126+ case <-time.After(5 * time.Second):
127+ t.Fatal("stop request not delivered")
128+ }
129+}
130+
131+func TestNoServer(t *testing.T) {
132+ rd := setup(t)
133+ ok, err := Stop(ctx(t))
134+ assert.NoError(t, err)
135+ assert.False(t, ok)
136+ ok, err = Query(ctx(t))
137+ assert.NoError(t, err)
138+ assert.False(t, ok)
139+ // Clients never create anything.
140+ _, err = os.Lstat(filepath.Join(rd, dirName))
141+ assert.True(t, os.IsNotExist(err))
142+}
143+
144+func TestRuntimeDirValidation(t *testing.T) {
145+ for _, v := range []string{"", "relative/dir", strings.Repeat("/a", maxPath)} {
146+ t.Setenv("XDG_RUNTIME_DIR", v)
147+ _, err := Listen()
148+ assert.ErrorIs(t, err, ErrNoRuntimeDir, "%q", v)
149+ _, err = Stop(ctx(t))
150+ assert.ErrorIs(t, err, ErrNoRuntimeDir)
151+ _, err = Query(ctx(t))
152+ assert.ErrorIs(t, err, ErrNoRuntimeDir)
153+ }
154+}
155+
156+func TestListenRejectsRawRuntimeDir(t *testing.T) {
157+ for _, v := range []string{"", "relative", "/tmp/a\x00b"} {
158+ _, err := listen(v, uint32(os.Geteuid()))
159+ assert.ErrorIs(t, err, ErrNoRuntimeDir, "%q", v)
160+ }
161+}
162+
163+func TestPermissions(t *testing.T) {
164+ rd := setup(t)
165+ old := unix.Umask(0o000)
166+ defer unix.Umask(old)
167+ listenT(t)
168+
169+ for path, mode := range map[string]os.FileMode{
170+ filepath.Join(rd, dirName): 0o700,
171+ sockPath(rd): 0o600,
172+ filepath.Join(rd, dirName, lockName): 0o600,
173+ } {
174+ fi, err := os.Lstat(path)
175+ require.NoError(t, err)
176+ assert.Equal(t, mode, fi.Mode().Perm(), filepath.Base(path))
177+ }
178+ fi, err := os.Lstat(sockPath(rd))
179+ require.NoError(t, err)
180+ assert.NotZero(t, fi.Mode()&os.ModeSocket)
181+}
182+
183+func TestSingleInstance(t *testing.T) {
184+ rd := setup(t)
185+ first := listenT(t)
186+
187+ second, err := Listen()
188+ require.ErrorIs(t, err, ErrAlreadyRecording)
189+ assert.Nil(t, second)
190+
191+ // The failed attempt must not have disturbed the live socket.
192+ ok, err := Query(ctx(t))
193+ require.NoError(t, err)
194+ assert.True(t, ok)
195+
196+ require.NoError(t, first.Close())
197+ _, err = os.Lstat(sockPath(rd))
198+ assert.True(t, os.IsNotExist(err))
199+
200+ third, err := Listen()
201+ require.NoError(t, err)
202+ require.NoError(t, third.Close())
203+}
204+
205+func TestSingleInstanceAcrossProcesses(t *testing.T) {
206+ rd := setup(t)
207+ cmd, out := startHelper(t)
208+ waitReady(t, out)
209+
210+ // The other process holds the lock, so we neither serve nor unlink.
211+ _, err := Listen()
212+ require.ErrorIs(t, err, ErrAlreadyRecording)
213+ _, err = os.Lstat(sockPath(rd))
214+ require.NoError(t, err)
215+
216+ second := exec.Command(os.Args[0], "-test.run=^$")
217+ second.Env = append(os.Environ(), helperEnv+"=owner")
218+ err = second.Run()
219+ var ee *exec.ExitError
220+ require.ErrorAs(t, err, &ee)
221+ assert.Equal(t, 3, ee.ExitCode())
222+
223+ ok, err := Stop(ctx(t))
224+ require.NoError(t, err)
225+ assert.True(t, ok)
226+ require.NoError(t, cmd.Wait())
227+ _, err = os.Lstat(sockPath(rd))
228+ assert.True(t, os.IsNotExist(err), "owner removes its socket on close")
229+}
230+
231+func TestStaleSocketAfterKilledOwner(t *testing.T) {
232+ rd := setup(t)
233+ cmd, out := startHelper(t, helperEnv+"_HANG=1")
234+ waitReady(t, out)
235+ require.NoError(t, cmd.Process.Kill())
236+ _ = cmd.Wait()
237+
238+ _, err := os.Lstat(sockPath(rd))
239+ require.NoError(t, err, "killed owner leaves a stale socket")
240+
241+ // Clients report "not recording" and leave the stale inode alone.
242+ ok, err := Query(ctx(t))
243+ assert.NoError(t, err)
244+ assert.False(t, ok)
245+ ok, err = Stop(ctx(t))
246+ assert.NoError(t, err)
247+ assert.False(t, ok)
248+ _, err = os.Lstat(sockPath(rd))
249+ require.NoError(t, err, "clients must not unlink")
250+
251+ // The next lock holder replaces it.
252+ s := listenT(t)
253+ ok, err = Query(ctx(t))
254+ require.NoError(t, err)
255+ assert.True(t, ok)
256+ require.NoError(t, s.Close())
257+}
258+
259+func TestStaleLeftoverWithoutLockedOwner(t *testing.T) {
260+ rd := setup(t)
261+ s, err := Listen()
262+ require.NoError(t, err)
263+ // Simulate a crash: drop the lock but leave the socket inode behind.
264+ require.NoError(t, unix.Close(s.lockFD))
265+ s.lockFD = -1
266+ require.NoError(t, s.ln.Close())
267+ _, err = os.Lstat(sockPath(rd))
268+ require.NoError(t, err)
269+
270+ s2 := listenT(t)
271+ ok, err := Query(ctx(t))
272+ require.NoError(t, err)
273+ assert.True(t, ok)
274+ require.NoError(t, s2.Close())
275+}
276+
277+func TestListenRefusesForeignObjects(t *testing.T) {
278+ t.Run("non-socket at socket path", func(t *testing.T) {
279+ rd := setup(t)
280+ require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
281+ require.NoError(t, os.WriteFile(sockPath(rd), []byte("keep"), 0o600))
282+ _, err := Listen()
283+ require.ErrorIs(t, err, ErrInsecure)
284+ b, err := os.ReadFile(sockPath(rd))
285+ require.NoError(t, err)
286+ assert.Equal(t, "keep", string(b))
287+ })
288+ t.Run("symlink at socket path", func(t *testing.T) {
289+ rd := setup(t)
290+ require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
291+ target := filepath.Join(rd, "target")
292+ require.NoError(t, os.WriteFile(target, []byte("keep"), 0o600))
293+ require.NoError(t, os.Symlink(target, sockPath(rd)))
294+ _, err := Listen()
295+ require.ErrorIs(t, err, ErrInsecure)
296+ _, err = os.Stat(target)
297+ assert.NoError(t, err)
298+ })
299+ t.Run("symlinked lock file", func(t *testing.T) {
300+ rd := setup(t)
301+ require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
302+ target := filepath.Join(rd, "target")
303+ require.NoError(t, os.WriteFile(target, nil, 0o644))
304+ require.NoError(t, os.Symlink(target, filepath.Join(rd, dirName, lockName)))
305+ _, err := Listen()
306+ require.Error(t, err)
307+ fi, err := os.Stat(target)
308+ require.NoError(t, err)
309+ assert.Equal(t, os.FileMode(0o644), fi.Mode().Perm(), "target untouched")
310+ })
311+}
312+
313+func TestDirectoryChecks(t *testing.T) {
314+ t.Run("loose mode", func(t *testing.T) {
315+ rd := setup(t)
316+ d := filepath.Join(rd, dirName)
317+ require.NoError(t, os.Mkdir(d, 0o700))
318+ require.NoError(t, os.Chmod(d, 0o755))
319+ _, err := Listen()
320+ assert.ErrorIs(t, err, ErrInsecure)
321+ _, err = Stop(ctx(t))
322+ assert.ErrorIs(t, err, ErrInsecure)
323+ _, err = Query(ctx(t))
324+ assert.ErrorIs(t, err, ErrInsecure)
325+ })
326+ t.Run("symlink", func(t *testing.T) {
327+ rd := setup(t)
328+ real := filepath.Join(rd, "elsewhere")
329+ require.NoError(t, os.Mkdir(real, 0o700))
330+ require.NoError(t, os.Symlink(real, filepath.Join(rd, dirName)))
331+ _, err := Listen()
332+ assert.ErrorIs(t, err, ErrInsecure)
333+ _, err = Stop(ctx(t))
334+ assert.ErrorIs(t, err, ErrInsecure)
335+ entries, err := os.ReadDir(real)
336+ require.NoError(t, err)
337+ assert.Empty(t, entries)
338+ })
339+ t.Run("regular file", func(t *testing.T) {
340+ rd := setup(t)
341+ require.NoError(t, os.WriteFile(filepath.Join(rd, dirName), nil, 0o600))
342+ _, err := Listen()
343+ assert.ErrorIs(t, err, ErrInsecure)
344+ })
345+ t.Run("other owner", func(t *testing.T) {
346+ rd := setup(t)
347+ require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
348+ fd, err := openSecureDir(rd, false, uint32(os.Geteuid())+1)
349+ if err == nil {
350+ unix.Close(fd)
351+ }
352+ assert.ErrorIs(t, err, ErrInsecure)
353+ })
354+}
355+
356+func TestClientRejectsLooseSocket(t *testing.T) {
357+ rd := setup(t)
358+ listenT(t)
359+ require.NoError(t, os.Chmod(sockPath(rd), 0o666))
360+ _, err := Stop(ctx(t))
361+ assert.ErrorIs(t, err, ErrInsecure)
362+}
363+
364+func TestForeignPeerRejected(t *testing.T) {
365+ setup(t)
366+ s, err := listen(os.Getenv("XDG_RUNTIME_DIR"), uint32(os.Geteuid())+1)
367+ require.NoError(t, err)
368+ t.Cleanup(func() { s.Close() })
369+
370+ ok, err := Stop(ctx(t))
371+ assert.Error(t, err)
372+ assert.False(t, ok)
373+ select {
374+ case <-s.Requests():
375+ t.Fatal("foreign peer delivered a request")
376+ case <-time.After(100 * time.Millisecond):
377+ }
378+}
379+
380+func TestRequestLimitsAndProtocol(t *testing.T) {
381+ rd := setup(t)
382+ s := listenT(t)
383+
384+ expectClosedSilently := func(t *testing.T, payload string) {
385+ t.Helper()
386+ c := dial(t, rd)
387+ _, _ = c.Write([]byte(payload))
388+ c.SetReadDeadline(time.Now().Add(3 * time.Second))
389+ b, err := io.ReadAll(c)
390+ if err != nil {
391+ // Unread request bytes make close report a reset; still no reply.
392+ require.ErrorIs(t, err, unix.ECONNRESET)
393+ }
394+ assert.Empty(t, b)
395+ }
396+ t.Run("oversized without newline", func(t *testing.T) {
397+ expectClosedSilently(t, strings.Repeat("x", 4096))
398+ })
399+ t.Run("oversized with newline late", func(t *testing.T) {
400+ expectClosedSilently(t, strings.Repeat("x", maxRequest)+"stop\n")
401+ })
402+ t.Run("unknown", func(t *testing.T) { expectClosedSilently(t, "quit\n") })
403+ t.Run("case sensitive", func(t *testing.T) { expectClosedSilently(t, "STOP\n") })
404+ t.Run("crlf", func(t *testing.T) { expectClosedSilently(t, "stop\r\n") })
405+ t.Run("stop in oversized junk", func(t *testing.T) {
406+ expectClosedSilently(t, "status\nstop\n"+strings.Repeat("x", 100))
407+ })
408+
409+ select {
410+ case r := <-s.Requests():
411+ t.Fatalf("invalid request delivered: %v", r)
412+ default:
413+ }
414+
415+ // Replies are exactly the two fixed lines.
416+ c := dial(t, rd)
417+ _, err := c.Write([]byte("status\n"))
418+ require.NoError(t, err)
419+ b, err := io.ReadAll(c)
420+ require.NoError(t, err)
421+ assert.Equal(t, "recording\n", string(b))
422+}
423+
424+func TestDeadlineAndConnectionBound(t *testing.T) {
425+ rd := setup(t)
426+ listenT(t)
427+
428+ // Fill every slot with silent clients.
429+ start := time.Now()
430+ var idle []*net.UnixConn
431+ for i := 0; i < connLimit; i++ {
432+ idle = append(idle, dial(t, rd))
433+ }
434+ // While all slots are taken nothing is answered.
435+ short, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
436+ ok, err := Query(short)
437+ cancel()
438+ assert.Error(t, err)
439+ assert.False(t, ok)
440+
441+ // The server drops the silent clients at its 1s deadline.
442+ for _, c := range idle {
443+ c.SetReadDeadline(time.Now().Add(3 * time.Second))
444+ b, err := io.ReadAll(c)
445+ require.NoError(t, err, "server closes timed-out clients")
446+ assert.Empty(t, b)
447+ }
448+ assert.GreaterOrEqual(t, time.Since(start), ioTimeout-100*time.Millisecond)
449+ assert.Less(t, time.Since(start), 3*time.Second)
450+
451+ // Slots are free again.
452+ ok, err = Query(ctx(t))
453+ require.NoError(t, err)
454+ assert.True(t, ok)
455+}
456+
457+func TestClientContext(t *testing.T) {
458+ rd := setup(t)
459+ listenT(t)
460+ // Occupy every slot so the client's request stays unanswered.
461+ for i := 0; i < connLimit; i++ {
462+ dial(t, rd)
463+ }
464+ c, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
465+ defer cancel()
466+ start := time.Now()
467+ ok, err := Query(c)
468+ assert.Error(t, err)
469+ assert.False(t, ok)
470+ assert.Less(t, time.Since(start), 900*time.Millisecond)
471+
472+ cancelled, cancel2 := context.WithCancel(context.Background())
473+ cancel2()
474+ _, err = Stop(cancelled)
475+ assert.Error(t, err)
476+}
477+
478+func TestConcurrentStopAndStatus(t *testing.T) {
479+ setup(t)
480+ s := listenT(t)
481+ base := runtime.NumGoroutine()
482+
483+ const n = 32
484+ var wg sync.WaitGroup
485+ errs := make(chan error, 2*n)
486+ for i := 0; i < n; i++ {
487+ wg.Add(2)
488+ go func() {
489+ defer wg.Done()
490+ ok, err := Stop(ctx(t))
491+ if err == nil && !ok {
492+ err = errors.New("stop not acknowledged")
493+ }
494+ errs <- err
495+ }()
496+ go func() {
497+ defer wg.Done()
498+ ok, err := Query(ctx(t))
499+ if err == nil && !ok {
500+ err = errors.New("status false")
501+ }
502+ errs <- err
503+ }()
504+ }
505+ wg.Wait()
506+ close(errs)
507+ for err := range errs {
508+ assert.NoError(t, err)
509+ }
510+
511+ // Stops coalesce into a single pending request.
512+ require.Equal(t, RequestStop, <-s.Requests())
513+ select {
514+ case <-s.Requests():
515+ t.Fatal("stops must coalesce")
516+ default:
517+ }
518+
519+ require.NoError(t, s.Close())
520+ assert.Eventually(t, func() bool { return runtime.NumGoroutine() <= base }, 3*time.Second, 20*time.Millisecond,
521+ "server goroutines must exit on Close")
522+}
523+
524+func TestStopAckDoesNotBlockOnUnreadRequests(t *testing.T) {
525+ setup(t)
526+ listenT(t) // nobody reads Requests
527+ for i := 0; i < 3; i++ {
528+ ok, err := Stop(ctx(t))
529+ require.NoError(t, err)
530+ assert.True(t, ok)
531+ }
532+}
533+
534+func TestCloseLifecycle(t *testing.T) {
535+ rd := setup(t)
536+ s, err := Listen()
537+ require.NoError(t, err)
538+
539+ idle := dial(t, rd) // an in-flight connection must not delay Close
540+ done := make(chan struct{})
541+ var wg sync.WaitGroup
542+ for i := 0; i < 4; i++ {
543+ wg.Add(1)
544+ go func() { defer wg.Done(); assert.NoError(t, s.Close()) }()
545+ }
546+ go func() { wg.Wait(); close(done) }()
547+ select {
548+ case <-done:
549+ case <-time.After(3 * time.Second):
550+ t.Fatal("Close blocked")
551+ }
552+ assert.NoError(t, s.Close(), "idempotent")
553+
554+ idle.SetReadDeadline(time.Now().Add(time.Second))
555+ _, err = io.ReadAll(idle)
556+ if err != nil { // a not-yet-accepted connection is reset by the listener close
557+ assert.ErrorIs(t, err, unix.ECONNRESET)
558+ }
559+
560+ _, err = os.Lstat(sockPath(rd))
561+ assert.True(t, os.IsNotExist(err))
562+ fi, err := os.Lstat(filepath.Join(rd, dirName, lockName))
563+ require.NoError(t, err, "lock file persists")
564+ assert.Equal(t, int64(0), fi.Size(), "lock file stays empty")
565+
566+ ok, err := Stop(ctx(t))
567+ assert.NoError(t, err)
568+ assert.False(t, ok)
569+}
570+
571+func TestCloseKeepsReplacedSocketPath(t *testing.T) {
572+ rd := setup(t)
573+ s, err := Listen()
574+ require.NoError(t, err)
575+ // Someone swaps our inode for something else; Close must not remove it.
576+ require.NoError(t, os.Remove(sockPath(rd)))
577+ require.NoError(t, os.WriteFile(sockPath(rd), []byte("other"), 0o600))
578+ require.NoError(t, s.Close())
579+ b, err := os.ReadFile(sockPath(rd))
580+ require.NoError(t, err)
581+ assert.Equal(t, "other", string(b))
582+}
583+
584+func TestListenFailureLeavesNothingBehind(t *testing.T) {
585+ rd := setup(t)
586+ require.NoError(t, os.Mkdir(filepath.Join(rd, dirName), 0o700))
587+ require.NoError(t, os.WriteFile(sockPath(rd), nil, 0o600))
588+ _, err := Listen()
589+ require.Error(t, err)
590+ // The lock must have been released by the failed attempt.
591+ require.NoError(t, os.Remove(sockPath(rd)))
592+ s, err := Listen()
593+ require.NoError(t, err)
594+ require.NoError(t, s.Close())
595+}
596+
597+func TestLongRuntimeDir(t *testing.T) {
598+ long := filepath.Join(t.TempDir(), strings.Repeat("d", 120), strings.Repeat("e", 120))
599+ require.NoError(t, os.MkdirAll(long, 0o700))
600+ t.Setenv("XDG_RUNTIME_DIR", long)
601+ s := listenT(t) // longer than sun_path, works through the directory fd
602+ ok, err := Query(ctx(t))
603+ require.NoError(t, err)
604+ assert.True(t, ok)
605+ require.NoError(t, s.Close())
606+}
A internal/adapters/destination/destination.go +157 -0
@@ -0,0 +1,157 @@
1+// Package destination chooses default capture locations.
2+//
3+// Screenshot and Video return a fresh, timestamped file path inside the user's
4+// default directory, which exists on return. The path is only a proposal: it is not
5+// reserved, so the writer must still create the file with O_EXCL (all capture
6+// writers do) and report a conflict instead of overwriting.
7+package destination
8+
9+import (
10+ "bufio"
11+ "errors"
12+ "fmt"
13+ "io"
14+ "os"
15+ "path/filepath"
16+ "strings"
17+ "time"
18+)
19+
20+const (
21+ dirMode = 0o700
22+ // stampLayout has nanosecond precision so two captures started in the
23+ // same second get different names.
24+ stampLayout = "20060102-150405.000000000"
25+ prefix = "nefercap-"
26+ maxPath = 4096
27+ maxConfig = 64 << 10 // user-dirs.dirs is a handful of short lines
28+)
29+
30+// ErrNoHome means no absolute home directory is available for a default.
31+var ErrNoHome = errors.New("destination: no absolute home directory")
32+
33+// Screenshot returns a new .png path in the user's XDG pictures directory
34+// (Screenshots subdirectory), falling back to $HOME/Pictures/Screenshots.
35+func Screenshot() (string, error) {
36+ return propose(".png", func() (string, error) {
37+ return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots")
38+ })
39+}
40+
41+// Video returns a new .mp4 path in the user's XDG videos directory, falling
42+// back to $HOME/Videos.
43+func Video() (string, error) {
44+ return propose(".mp4", func() (string, error) {
45+ return userDir("XDG_VIDEOS_DIR", "Videos")
46+ })
47+}
48+
49+func propose(ext string, auto func() (string, error)) (string, error) {
50+ dir, err := auto()
51+ if err != nil {
52+ return "", err
53+ }
54+ if err := os.MkdirAll(dir, dirMode); err != nil {
55+ return "", fmt.Errorf("destination: create directory: %w", err)
56+ }
57+ fi, err := os.Stat(dir)
58+ if err != nil {
59+ return "", fmt.Errorf("destination: stat directory: %w", err)
60+ }
61+ if !fi.IsDir() {
62+ return "", fmt.Errorf("destination: %w: not a directory", os.ErrInvalid)
63+ }
64+ return filepath.Join(dir, prefix+time.Now().Format(stampLayout)+ext), nil
65+}
66+
67+// userDir resolves the XDG user directory named by key: the environment
68+// variable first, then user-dirs.dirs, then $HOME/<fallback...>. Elements of
69+// fallback after the first (Screenshots) are also appended to an XDG-provided
70+// directory.
71+func userDir(key string, fallback ...string) (string, error) {
72+ home, err := os.UserHomeDir()
73+ if err != nil || !filepath.IsAbs(home) {
74+ return "", ErrNoHome
75+ }
76+ home = filepath.Clean(home)
77+ sub := fallback[1:]
78+
79+ if d, ok := expand(os.Getenv(key), home); ok {
80+ return filepath.Join(append([]string{d}, sub...)...), nil
81+ }
82+ if d, ok := expand(fromConfig(key, home), home); ok {
83+ return filepath.Join(append([]string{d}, sub...)...), nil
84+ }
85+ return filepath.Join(append([]string{home}, fallback...)...), nil
86+}
87+
88+// expand accepts an absolute path, optionally starting with $HOME or ${HOME}.
89+// No shell, no other variables, no command substitution. The home directory
90+// itself is rejected: user-dirs uses it to mean "disabled".
91+func expand(v, home string) (string, bool) {
92+ if v == "" || len(v) > maxPath || strings.IndexByte(v, 0) >= 0 {
93+ return "", false
94+ }
95+ switch {
96+ case v == "$HOME" || strings.HasPrefix(v, "$HOME/"):
97+ v = home + v[len("$HOME"):]
98+ case v == "${HOME}" || strings.HasPrefix(v, "${HOME}/"):
99+ v = home + v[len("${HOME}"):]
100+ }
101+ if strings.ContainsAny(v, "$`") || !filepath.IsAbs(v) {
102+ return "", false
103+ }
104+ v = filepath.Clean(v)
105+ if v == home || v == string(filepath.Separator) {
106+ return "", false
107+ }
108+ return v, true
109+}
110+
111+// fromConfig reads KEY="value" from the user-dirs.dirs file, bounded in size.
112+func fromConfig(key, home string) string {
113+ cfg := os.Getenv("XDG_CONFIG_HOME")
114+ if !filepath.IsAbs(cfg) {
115+ cfg = filepath.Join(home, ".config")
116+ }
117+ f, err := os.Open(filepath.Join(cfg, "user-dirs.dirs"))
118+ if err != nil {
119+ return ""
120+ }
121+ defer f.Close()
122+ sc := bufio.NewScanner(io.LimitReader(f, maxConfig))
123+ sc.Buffer(make([]byte, 0, 1024), maxPath+256)
124+ var val string
125+ for sc.Scan() {
126+ line := strings.TrimSpace(sc.Text())
127+ k, v, ok := strings.Cut(line, "=")
128+ if !ok || strings.TrimSpace(k) != key {
129+ continue
130+ }
131+ val = unquote(strings.TrimSpace(v)) // the last assignment wins
132+ }
133+ return val
134+}
135+
136+// unquote handles the double-quoted, backslash-escaped form written by
137+// xdg-user-dirs-update, and bare values.
138+func unquote(v string) string {
139+ if len(v) < 2 || v[0] != '"' {
140+ return v
141+ }
142+ var b strings.Builder
143+ for i := 1; i < len(v); i++ {
144+ switch c := v[i]; c {
145+ case '\\':
146+ if i+1 < len(v) {
147+ i++
148+ b.WriteByte(v[i])
149+ }
150+ case '"':
151+ return b.String()
152+ default:
153+ b.WriteByte(c)
154+ }
155+ }
156+ return "" // unterminated
157+}
A internal/adapters/destination/destination_test.go +196 -0
@@ -0,0 +1,196 @@
1+package destination
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// env gives each test an isolated home and config with no XDG overrides.
14+func env(t *testing.T) (home string) {
15+ t.Helper()
16+ home = t.TempDir()
17+ t.Setenv("HOME", home)
18+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "cfg"))
19+ t.Setenv("XDG_PICTURES_DIR", "")
20+ t.Setenv("XDG_VIDEOS_DIR", "")
21+ return home
22+}
23+
24+func writeDirs(t *testing.T, content string) {
25+ t.Helper()
26+ cfg := os.Getenv("XDG_CONFIG_HOME")
27+ require.NoError(t, os.MkdirAll(cfg, 0o700))
28+ require.NoError(t, os.WriteFile(filepath.Join(cfg, "user-dirs.dirs"), []byte(content), 0o600))
29+}
30+
31+var nameRE = regexp.MustCompile(`^nefercap-\d{8}-\d{6}\.\d{9}\.(png|mp4)$`)
32+
33+func TestFallbackDefaults(t *testing.T) {
34+ home := env(t)
35+
36+ shot, err := Screenshot()
37+ require.NoError(t, err)
38+ assert.Equal(t, filepath.Join(home, "Pictures", "Screenshots"), filepath.Dir(shot))
39+ assert.Regexp(t, nameRE, filepath.Base(shot))
40+ assert.Equal(t, ".png", filepath.Ext(shot))
41+
42+ vid, err := Video()
43+ require.NoError(t, err)
44+ assert.Equal(t, filepath.Join(home, "Videos"), filepath.Dir(vid))
45+ assert.Equal(t, ".mp4", filepath.Ext(vid))
46+ assert.Regexp(t, nameRE, filepath.Base(vid))
47+
48+ for _, d := range []string{filepath.Dir(shot), filepath.Dir(vid)} {
49+ fi, err := os.Stat(d)
50+ require.NoError(t, err)
51+ assert.True(t, fi.IsDir())
52+ }
53+ _, err = os.Lstat(shot)
54+ assert.True(t, os.IsNotExist(err), "the file itself is not created")
55+}
56+
57+func TestDirectoryMode(t *testing.T) {
58+ home := env(t)
59+ old := umaskZero()
60+ defer old()
61+ shot, err := Screenshot()
62+ require.NoError(t, err)
63+ fi, err := os.Stat(filepath.Dir(shot))
64+ require.NoError(t, err)
65+ assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm())
66+ fi, err = os.Stat(filepath.Join(home, "Pictures"))
67+ require.NoError(t, err)
68+ assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm())
69+}
70+
71+func TestEnvironmentDirectories(t *testing.T) {
72+ home := env(t)
73+ pics := t.TempDir()
74+ vids := filepath.Join(home, "Movies")
75+ t.Setenv("XDG_PICTURES_DIR", pics)
76+ t.Setenv("XDG_VIDEOS_DIR", "$HOME/Movies")
77+
78+ shot, err := Screenshot()
79+ require.NoError(t, err)
80+ assert.Equal(t, filepath.Join(pics, "Screenshots"), filepath.Dir(shot))
81+
82+ vid, err := Video()
83+ require.NoError(t, err)
84+ assert.Equal(t, vids, filepath.Dir(vid))
85+
86+ t.Setenv("XDG_VIDEOS_DIR", "${HOME}/Clips/../Movies2")
87+ vid, err = Video()
88+ require.NoError(t, err)
89+ assert.Equal(t, filepath.Join(home, "Movies2"), filepath.Dir(vid))
90+}
91+
92+func TestConfigFile(t *testing.T) {
93+ home := env(t)
94+ writeDirs(t, `# comment
95+XDG_DESKTOP_DIR="$HOME/Desktop"
96+XDG_PICTURES_DIR="$HOME/My \"Pics\""
97+XDG_VIDEOS_DIR="$HOME/Films"
98+`)
99+ shot, err := Screenshot()
100+ require.NoError(t, err)
101+ assert.Equal(t, filepath.Join(home, `My "Pics"`, "Screenshots"), filepath.Dir(shot))
102+ vid, err := Video()
103+ require.NoError(t, err)
104+ assert.Equal(t, filepath.Join(home, "Films"), filepath.Dir(vid))
105+
106+ // The environment variable wins over the file.
107+ other := t.TempDir()
108+ t.Setenv("XDG_VIDEOS_DIR", other)
109+ vid, err = Video()
110+ require.NoError(t, err)
111+ assert.Equal(t, other, filepath.Dir(vid))
112+}
113+
114+func TestConfigFileDefaultLocation(t *testing.T) {
115+ home := env(t)
116+ t.Setenv("XDG_CONFIG_HOME", "")
117+ require.NoError(t, os.MkdirAll(filepath.Join(home, ".config"), 0o700))
118+ require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "user-dirs.dirs"),
119+ []byte("XDG_VIDEOS_DIR=\"$HOME/Reels\"\n"), 0o600))
120+ vid, err := Video()
121+ require.NoError(t, err)
122+ assert.Equal(t, filepath.Join(home, "Reels"), filepath.Dir(vid))
123+}
124+
125+func TestUnsafeConfiguredValuesFallBack(t *testing.T) {
126+ home := env(t)
127+ for _, v := range []string{
128+ "relative/dir",
129+ "$OTHER/dir",
130+ "$(id)/x",
131+ "`id`",
132+ "$HOME", // xdg-user-dirs uses HOME to mean "disabled"
133+ "$HOME/",
134+ "/",
135+ "$HOMEDIR/x",
136+ } {
137+ t.Setenv("XDG_VIDEOS_DIR", v)
138+ vid, err := Video()
139+ require.NoError(t, err, v)
140+ assert.Equal(t, filepath.Join(home, "Videos"), filepath.Dir(vid), v)
141+ }
142+ // Same for the file, including an unterminated quote.
143+ t.Setenv("XDG_VIDEOS_DIR", "")
144+ writeDirs(t, "XDG_VIDEOS_DIR=\"$HOME/Broken\n")
145+ vid, err := Video()
146+ require.NoError(t, err)
147+ assert.Equal(t, filepath.Join(home, "Videos"), filepath.Dir(vid))
148+ // Nothing outside the home was created.
149+ _, err = os.Lstat(filepath.Join(home, "Broken"))
150+ assert.True(t, os.IsNotExist(err))
151+}
152+
153+func TestOversizedConfigIgnored(t *testing.T) {
154+ home := env(t)
155+ big := make([]byte, maxConfig)
156+ for i := range big {
157+ big[i] = '#'
158+ }
159+ writeDirs(t, string(big)+"\nXDG_VIDEOS_DIR=\"$HOME/Late\"\n")
160+ vid, err := Video()
161+ require.NoError(t, err)
162+ assert.Equal(t, filepath.Join(home, "Videos"), filepath.Dir(vid))
163+}
164+
165+func TestConfiguredNonDirectoryFails(t *testing.T) {
166+ env(t)
167+ file := filepath.Join(t.TempDir(), "f")
168+ require.NoError(t, os.WriteFile(file, nil, 0o600))
169+ t.Setenv("XDG_VIDEOS_DIR", file)
170+ _, err := Video()
171+ assert.Error(t, err)
172+ t.Setenv("XDG_VIDEOS_DIR", filepath.Join(file, "sub"))
173+ _, err = Video()
174+ assert.Error(t, err)
175+}
176+
177+func TestNoHome(t *testing.T) {
178+ t.Setenv("HOME", "")
179+ t.Setenv("XDG_PICTURES_DIR", "")
180+ _, err := Screenshot()
181+ assert.ErrorIs(t, err, ErrNoHome)
182+ t.Setenv("HOME", "relative")
183+ _, err = Video()
184+ assert.ErrorIs(t, err, ErrNoHome)
185+}
186+
187+func TestNamesDoNotCollide(t *testing.T) {
188+ env(t)
189+ seen := map[string]bool{}
190+ for i := 0; i < 200; i++ {
191+ p, err := Screenshot()
192+ require.NoError(t, err)
193+ seen[p] = true
194+ }
195+ assert.Len(t, seen, 200)
196+}
A internal/adapters/destination/umask_test.go +9 -0
@@ -0,0 +1,9 @@
1+package destination
2+
3+import "syscall"
4+
5+// umaskZero clears the umask and returns the restore function.
6+func umaskZero() func() {
7+ old := syscall.Umask(0)
8+ return func() { syscall.Umask(old) }
9+}
D internal/adapters/gui/form.go +0 -187
@@ -1,187 +0,0 @@
1-package gui
2-
3-import (
4- "errors"
5- "fmt"
6- "strconv"
7- "strings"
8- "time"
9-
10- "github.com/bnema/nefercap/internal/ports"
11-)
12-
13-// Form limits. Dimension, frame-size and fps bounds come from ports so the
14-// panel cannot accept what the capture pipeline rejects.
15-const maxDuration = 24 * time.Hour
16-
17-// Validation messages are built once, so status rendering never allocates.
18-var (
19- errOutput = errors.New("! select an output")
20- errMode = errors.New("! select screenshot or record")
21- errPath = errors.New("! file must name a path (not empty, not a directory)")
22- errRegion = fmt.Errorf("! region must be X,Y,WxH with X,Y >= 0, W,H > 0 and X+W, Y+H <= %d, or empty", ports.MaxDimension)
23- errFPS = fmt.Errorf("! fps must be a whole number from 1 to %d", ports.MaxFPS)
24- errSize = fmt.Errorf("! resolution must be WxH with both from 1 to %d, or empty for source size", ports.MaxDimension)
25- errSizeOdd = errors.New("! resolution width and height must be even (video encoding)")
26- errSizeSize = fmt.Errorf("! resolution is too large: at most %d MiB per frame", ports.MaxFrameBytes>>20)
27- errDuration = errors.New("! duration must be seconds or e.g. 1m30s (max 24h); 0 = until Ctrl+C")
28-
29- fpsPlaceholder = "1-" + strconv.Itoa(ports.MaxFPS)
30-)
31-
32-// parse validates the form and, on success, stores the result in m.sel. It
33-// reuses m's storage and does not allocate for valid or invalid input.
34-func (m *model) parse() error {
35- oi := m.outputIndex()
36- if oi < 0 {
37- return errOutput
38- }
39- path := strings.TrimSpace(m.path)
40- if path == "" || strings.HasSuffix(path, "/") {
41- return errPath
42- }
43- region, ok := parseRegion(m.region)
44- if !ok {
45- return errRegion
46- }
47- sel := ports.Selection{
48- Target: ports.Target{OutputID: m.outputs[oi].id, Region: region},
49- Path: path,
50- }
51- switch m.mode {
52- case modeScreenshot:
53- sel.Mode = ports.Screenshot
54- case modeRecord:
55- sel.Mode = ports.Record
56- fps, ok := parseUint(m.fps, ports.MaxFPS)
57- if !ok || fps < 1 {
58- return errFPS
59- }
60- w, h, ok := parseSize(m.size)
61- if !ok {
62- return errSize
63- }
64- if err := checkVideoSize(w, h); err != nil {
65- return err
66- }
67- dur, ok := m.parsedDuration()
68- if !ok {
69- return errDuration
70- }
71- sel.Video = ports.VideoSettings{FPS: fps, Width: w, Height: h}
72- sel.Duration = dur
73- default:
74- return errMode
75- }
76- m.sel = sel
77- return nil
78-}
79-
80-// checkVideoSize applies encoder rules to an explicit video resolution (zero
81-// means source size). Region sizes are not checked: captured physical
82-// dimensions can differ from the logical region by the output scale, so an odd
83-// region does not imply odd video. If the source itself is odd, the user must
84-// set an even resolution; the record note says so.
85-func checkVideoSize(w, h int) error {
86- if w == 0 {
87- return nil
88- }
89- if w%2 != 0 || h%2 != 0 {
90- return errSizeOdd
91- }
92- if w > ports.MaxFrameBytes/ports.BytesPerPixel/h {
93- return errSizeSize
94- }
95- return nil
96-}
97-
98-// parsedDuration parses m.duration, remembering the last input and result so
99-// an unchanged (even invalid) field costs no allocation per frame.
100-func (m *model) parsedDuration() (time.Duration, bool) {
101- if !m.durCached || m.durSrc != m.duration {
102- m.durSrc = m.duration
103- m.durVal, m.durOK = parseDuration(m.duration)
104- m.durCached = true
105- }
106- return m.durVal, m.durOK
107-}
108-
109-// parseUint parses plain decimal digits no greater than limit.
110-func parseUint(s string, limit int) (int, bool) {
111- s = strings.TrimSpace(s)
112- if s == "" {
113- return 0, false
114- }
115- n := 0
116- for i := 0; i < len(s); i++ {
117- c := s[i]
118- if c < '0' || c > '9' {
119- return 0, false
120- }
121- n = n*10 + int(c-'0')
122- if n > limit {
123- return 0, false
124- }
125- }
126- return n, true
127-}
128-
129-// parseRegion parses "X,Y,WxH". Empty means the full output (zero Region).
130-// It is deliberately stricter than the coordinate bound alone: the far edges
131-// X+W and Y+H must also stay within ports.MaxDimension. Whether the region fits
132-// the chosen output is only known to the capture adapter.
133-func parseRegion(s string) (ports.Region, bool) {
134- s = strings.TrimSpace(s)
135- if s == "" {
136- return ports.Region{}, true
137- }
138- xs, rest, ok := strings.Cut(s, ",")
139- if !ok {
140- return ports.Region{}, false
141- }
142- ys, size, ok := strings.Cut(rest, ",")
143- if !ok {
144- return ports.Region{}, false
145- }
146- x, ok1 := parseUint(xs, ports.MaxDimension)
147- y, ok2 := parseUint(ys, ports.MaxDimension)
148- w, h, ok3 := parseSize(size)
149- if !ok1 || !ok2 || !ok3 || w == 0 || x+w > ports.MaxDimension || y+h > ports.MaxDimension {
150- return ports.Region{}, false
151- }
152- return ports.Region{X: x, Y: y, Width: w, Height: h}, true
153-}
154-
155-// parseSize parses "WxH" with both positive, or empty as 0x0.
156-func parseSize(s string) (w, h int, ok bool) {
157- s = strings.TrimSpace(s)
158- if s == "" {
159- return 0, 0, true
160- }
161- i := strings.IndexAny(s, "xX")
162- if i < 0 {
163- return 0, 0, false
164- }
165- w, ok1 := parseUint(s[:i], ports.MaxDimension)
166- h, ok2 := parseUint(s[i+1:], ports.MaxDimension)
167- if !ok1 || !ok2 || w < 1 || h < 1 {
168- return 0, 0, false
169- }
170- return w, h, true
171-}
172-
173-// parseDuration accepts whole seconds or a Go duration; empty means zero.
174-func parseDuration(s string) (time.Duration, bool) {
175- s = strings.TrimSpace(s)
176- if s == "" {
177- return 0, true
178- }
179- if n, ok := parseUint(s, int(maxDuration/time.Second)); ok {
180- return time.Duration(n) * time.Second, true
181- }
182- d, err := time.ParseDuration(s)
183- if err != nil || d < 0 || d > maxDuration {
184- return 0, false
185- }
186- return d, true
187-}
D internal/adapters/gui/form_test.go +0 -234
@@ -1,234 +0,0 @@
1-package gui
2-
3-import (
4- "strconv"
5- "testing"
6- "time"
7-
8- "github.com/stretchr/testify/assert"
9- "github.com/stretchr/testify/require"
10-
11- "github.com/bnema/nefercap/internal/ports"
12-)
13-
14-var testOutputs = []ports.Output{
15- {ID: 7, Name: "DP-1", Width: 2560, Height: 1440, Scale: 1},
16- {ID: 9, Name: "", Width: 1920, Height: 1080, Scale: 2},
17- {ID: 11},
18-}
19-
20-func newTestModel(t testing.TB) *model {
21- t.Helper()
22- m, err := newModel(testOutputs, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
23- require.NoError(t, err)
24- return m
25-}
26-
27-func TestParseScreenshotDefaults(t *testing.T) {
28- m := newTestModel(t)
29- require.NoError(t, m.parse())
30- assert.Equal(t, ports.Selection{
31- Mode: ports.Screenshot,
32- Target: ports.Target{OutputID: 7},
33- Path: "nefercap-20260102-030405.png",
34- }, m.sel)
35-}
36-
37-func TestParseRecordDefaults(t *testing.T) {
38- m := newTestModel(t)
39- m.mode = modeRecord
40- require.NoError(t, m.parse())
41- assert.Equal(t, ports.Selection{
42- Mode: ports.Record,
43- Target: ports.Target{OutputID: 7},
44- Path: "nefercap-20260102-030405.png",
45- Video: ports.VideoSettings{FPS: 30},
46- Duration: 10 * time.Second,
47- }, m.sel)
48-}
49-
50-func TestParseRecordFull(t *testing.T) {
51- m := newTestModel(t)
52- m.mode = modeRecord
53- m.output = m.outputs[1].key
54- m.path = " out.mp4 "
55- m.region = " 10, 20, 640x480 "
56- m.fps = "60"
57- m.size = "1280X720"
58- m.duration = "1m30s"
59- require.NoError(t, m.parse())
60- assert.Equal(t, ports.Selection{
61- Mode: ports.Record,
62- Target: ports.Target{OutputID: 9, Region: ports.Region{X: 10, Y: 20, Width: 640, Height: 480}},
63- Path: "out.mp4",
64- Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720},
65- Duration: 90 * time.Second,
66- }, m.sel)
67-}
68-
69-func TestParseInvalid(t *testing.T) {
70- tests := []struct {
71- name string
72- edit func(*model)
73- want error
74- }{
75- {"unknown output", func(m *model) { m.output = "nope" }, errOutput},
76- {"empty path", func(m *model) { m.path = " " }, errPath},
77- {"directory path", func(m *model) { m.path = "dir/" }, errPath},
78- {"bad mode", func(m *model) { m.mode = "" }, errMode},
79- {"region fields", func(m *model) { m.region = "1,2" }, errRegion},
80- {"region no comma", func(m *model) { m.region = "1x2" }, errRegion},
81- {"region zero width", func(m *model) { m.region = "0,0,0x5" }, errRegion},
82- {"region zero height", func(m *model) { m.region = "0,0,5x0" }, errRegion},
83- {"region negative", func(m *model) { m.region = "-1,0,5x5" }, errRegion},
84- {"region junk", func(m *model) { m.region = "a,b,cxd" }, errRegion},
85- {"region huge", func(m *model) { m.region = "0,0,99999999999999999999x5" }, errRegion},
86- {"region far edge x", func(m *model) { m.region = "16000,0,385x5" }, errRegion},
87- {"region far edge y", func(m *model) { m.region = "0,16000,5x385" }, errRegion},
88- {"region empty size", func(m *model) { m.region = "0,0," }, errRegion},
89- {"fps empty", func(m *model) { m.mode = modeRecord; m.fps = "" }, errFPS},
90- {"fps zero", func(m *model) { m.mode = modeRecord; m.fps = "0" }, errFPS},
91- {"fps high", func(m *model) { m.mode = modeRecord; m.fps = "121" }, errFPS},
92- {"fps fraction", func(m *model) { m.mode = modeRecord; m.fps = "29.97" }, errFPS},
93- {"fps sign", func(m *model) { m.mode = modeRecord; m.fps = "+30" }, errFPS},
94- {"size one dim", func(m *model) { m.mode = modeRecord; m.size = "1280" }, errSize},
95- {"size zero", func(m *model) { m.mode = modeRecord; m.size = "0x720" }, errSize},
96- {"size missing height", func(m *model) { m.mode = modeRecord; m.size = "1280x" }, errSize},
97- {"size odd width", func(m *model) { m.mode = modeRecord; m.size = "1281x720" }, errSizeOdd},
98- {"size odd height", func(m *model) { m.mode = modeRecord; m.size = "1280x721" }, errSizeOdd},
99- {"size over frame bytes", func(m *model) { m.mode = modeRecord; m.size = "16384x16384" }, errSizeSize},
100- {"duration junk", func(m *model) { m.mode = modeRecord; m.duration = "soon" }, errDuration},
101- {"duration negative", func(m *model) { m.mode = modeRecord; m.duration = "-5s" }, errDuration},
102- {"duration too long", func(m *model) { m.mode = modeRecord; m.duration = "25h" }, errDuration},
103- {"duration huge seconds", func(m *model) { m.mode = modeRecord; m.duration = "999999999999999999999" }, errDuration},
104- }
105- for _, tt := range tests {
106- t.Run(tt.name, func(t *testing.T) {
107- m := newTestModel(t)
108- tt.edit(m)
109- assert.ErrorIs(t, m.parse(), tt.want)
110- })
111- }
112-}
113-
114-func TestFPSBoundIsPortsMax(t *testing.T) {
115- m := newTestModel(t)
116- m.mode = modeRecord
117- m.fps = strconv.Itoa(ports.MaxFPS)
118- require.NoError(t, m.parse())
119- assert.Equal(t, ports.MaxFPS, m.sel.Video.FPS)
120- m.fps = strconv.Itoa(ports.MaxFPS + 1)
121- assert.ErrorIs(t, m.parse(), errFPS)
122- assert.Contains(t, errFPS.Error(), strconv.Itoa(ports.MaxFPS))
123- assert.Equal(t, "1-"+strconv.Itoa(ports.MaxFPS), fpsPlaceholder)
124-}
125-
126-func TestOddRegionAllowedEvenSizeChecked(t *testing.T) {
127- m := newTestModel(t)
128- m.mode = modeRecord
129- m.region = "1,3,641x481"
130- require.NoError(t, m.parse())
131- assert.Equal(t, ports.Region{X: 1, Y: 3, Width: 641, Height: 481}, m.sel.Target.Region)
132- m.mode = modeScreenshot
133- m.size = "1281x721"
134- require.NoError(t, m.parse(), "screenshot ignores video size")
135-}
136-
137-func TestVideoSizeAtFrameLimit(t *testing.T) {
138- m := newTestModel(t)
139- m.mode = modeRecord
140- m.size = "16384x4096"
141- require.NoError(t, m.parse())
142- m.size = "16384x4098"
143- assert.ErrorIs(t, m.parse(), errSizeSize)
144-}
145-
146-func TestRegionAtMaxDimension(t *testing.T) {
147- m := newTestModel(t)
148- m.region = "0,0,16384x16384"
149- require.NoError(t, m.parse())
150- m.region = "1,0,16384x1"
151- assert.ErrorIs(t, m.parse(), errRegion)
152-}
153-
154-func TestScreenshotIgnoresRecordFields(t *testing.T) {
155- m := newTestModel(t)
156- m.fps, m.size, m.duration = "bad", "bad", "bad"
157- require.NoError(t, m.parse())
158- assert.Equal(t, ports.VideoSettings{}, m.sel.Video)
159- assert.Zero(t, m.sel.Duration)
160-}
161-
162-func TestInvalidParseKeepsPreviousSelection(t *testing.T) {
163- m := newTestModel(t)
164- require.NoError(t, m.parse())
165- want := m.sel
166- m.path = ""
167- require.Error(t, m.parse())
168- assert.Equal(t, want, m.sel)
169-}
170-
171-func TestParseDuration(t *testing.T) {
172- for in, want := range map[string]time.Duration{
173- "": 0, "0": 0, "5": 5 * time.Second, " 90 ": 90 * time.Second,
174- "1.5s": 1500 * time.Millisecond, "2m": 2 * time.Minute, "24h": 24 * time.Hour, "86400": 24 * time.Hour,
175- } {
176- got, ok := parseDuration(in)
177- assert.True(t, ok, in)
178- assert.Equal(t, want, got, in)
179- }
180-}
181-
182-func TestParseUintBounds(t *testing.T) {
183- n, ok := parseUint("16384", ports.MaxDimension)
184- assert.True(t, ok)
185- assert.Equal(t, 16384, n)
186- _, ok = parseUint("16385", ports.MaxDimension)
187- assert.False(t, ok)
188- _, ok = parseUint("", ports.MaxDimension)
189- assert.False(t, ok)
190-}
191-
192-// The status line runs every frame and must not allocate for valid or invalid
193-// forms: parsing uses only the standard library on the model's own strings.
194-func TestFormAllocations(t *testing.T) {
195- m := newTestModel(t)
196- m.mode = modeRecord
197- m.region = "10,20,640x480"
198- m.size = "1280x720"
199- m.duration = "1m30s"
200- require.NoError(t, m.parse())
201- assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "valid form")
202- m.size = "1281x720"
203- assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "odd size")
204- m.size = "1280x720"
205- assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "status")
206- m.fps = "abc"
207- assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid form")
208- m.fps = "30"
209- m.duration = "0"
210- assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "seconds duration")
211- m.duration = "soon"
212- require.ErrorIs(t, m.parse(), errDuration)
213- assert.Zero(t, testing.AllocsPerRun(100, func() { _ = m.parse() }), "invalid duration")
214- assert.Zero(t, testing.AllocsPerRun(100, func() { _, _, _ = m.status() }), "invalid duration status")
215- m.duration = "-5s"
216- require.ErrorIs(t, m.parse(), errDuration)
217- m.duration = "1m"
218- require.NoError(t, m.parse(), "cache follows edits")
219- assert.Equal(t, time.Minute, m.sel.Duration)
220-}
221-
222-func BenchmarkFormParse(b *testing.B) {
223- m := newTestModel(b)
224- m.mode = modeRecord
225- m.region = "10,20,640x480"
226- m.size = "1280x720"
227- m.duration = "1m30s"
228- b.ReportAllocs()
229- for b.Loop() {
230- if err := m.parse(); err != nil {
231- b.Fatal(err)
232- }
233- }
234-}
D internal/adapters/gui/gui.go +0 -139
@@ -1,139 +0,0 @@
1-// Package gui is the NeferGUI control panel adapter: a synchronous window that
2-// returns the user's capture selection before any capture begins.
3-package gui
4-
5-import (
6- "context"
7- _ "embed"
8- "errors"
9- "fmt"
10- "os"
11- "time"
12-
13- "github.com/bnema/nefergui"
14-
15- "github.com/bnema/nefercap/internal/ports"
16-)
17-
18-//go:embed style.css
19-var styleSheet string
20-
21-var errNoOutputs = fmt.Errorf("gui: no outputs to select: %w", ports.ErrOutputNotFound)
22-
23-// Selector shows the control panel. Its zero value is ready to use.
24-type Selector struct{}
25-
26-var _ ports.Selector = (*Selector)(nil)
27-
28-// New returns a control panel selector.
29-func New() *Selector { return &Selector{} }
30-
31-// Select blocks until the user accepts a selection (true), closes the window
32-// (false, nil) or ctx ends or the window system fails (error). The window is
33-// destroyed by the panel's own Wayland session before an accepted selection is
34-// returned. The compositor may still process that teardown after Select
35-// returns, so a caller that must not capture the panel should confirm through
36-// a fresh capture connection before capturing. Select must be called from one
37-// goroutine at a time.
38-func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
39- if err := ctx.Err(); err != nil {
40- return ports.Selection{}, false, err
41- }
42- m, err := newModel(outputs, time.Now())
43- if err != nil {
44- return ports.Selection{}, false, err
45- }
46- sheet, removeSheet, err := writeStyleSheet()
47- if err != nil {
48- return ports.Selection{}, false, err
49- }
50- defer removeSheet()
51-
52- // The child context ends Run once the user decides; the parent stays
53- // authoritative for real cancellation.
54- child, cancel := context.WithCancel(ctx)
55- defer cancel()
56- m.cancel = cancel
57- runErr := nefergui.Run(child, m, view,
58- nefergui.Title("NeferCap"),
59- nefergui.Size(640, m.windowHeight()),
60- nefergui.Styles(sheet),
61- )
62- return m.result(ctx, runErr)
63-}
64-
65-// result maps Run's outcome. The parent context is checked first: if it has
66-// ended, nothing is accepted and its error is returned, joined with any real
67-// Run error. Otherwise a Canceled error caused by our own accept or dismiss is
68-// not a failure, but any other error (including one joined with it, such as a
69-// surface Close failure) is reported and rejects the selection.
70-func (m *model) result(parent context.Context, runErr error) (ports.Selection, bool, error) {
71- if perr := parent.Err(); perr != nil {
72- if rest := dropErrors(runErr, context.Canceled, perr); rest != nil {
73- return ports.Selection{}, false, errors.Join(perr, rest)
74- }
75- return ports.Selection{}, false, perr
76- }
77- if m.accepted || m.closed {
78- runErr = dropErrors(runErr, context.Canceled)
79- }
80- if runErr != nil {
81- return ports.Selection{}, false, runErr
82- }
83- if m.accepted {
84- return m.sel, true, nil
85- }
86- return ports.Selection{}, false, nil
87-}
88-
89-// dropErrors removes leaves equal to any target from err and from joined
90-// errors, keeping everything else. It returns nil if nothing real remains.
91-func dropErrors(err error, targets ...error) error {
92- if err == nil {
93- return nil
94- }
95- if j, ok := err.(interface{ Unwrap() []error }); ok {
96- var keep []error
97- for _, e := range j.Unwrap() {
98- if e = dropErrors(e, targets...); e != nil {
99- keep = append(keep, e)
100- }
101- }
102- switch len(keep) {
103- case 0:
104- return nil
105- case 1:
106- return keep[0]
107- }
108- return errors.Join(keep...)
109- }
110- for _, t := range targets {
111- if err == t {
112- return nil
113- }
114- }
115- return err
116-}
117-
118-// writeStyleSheet stores the embedded stylesheet in a private temporary file
119-// because NeferGUI loads styles by path. It is read once when Run starts, so
120-// removal after Run returns is enough; a removal failure leaves only a small
121-// file in the temporary directory and is not worth failing a capture over.
122-func writeStyleSheet() (path string, remove func(), err error) {
123- f, err := os.CreateTemp("", "nefercap-gui-*.css")
124- if err != nil {
125- return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
126- }
127- path = f.Name()
128- remove = func() { _ = os.Remove(path) }
129- if _, err := f.WriteString(styleSheet); err != nil {
130- err = errors.Join(err, f.Close())
131- remove()
132- return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
133- }
134- if err := f.Close(); err != nil {
135- remove()
136- return "", nil, fmt.Errorf("gui: stylesheet: %w", err)
137- }
138- return path, remove, nil
139-}
D internal/adapters/gui/gui_test.go +0 -293
@@ -1,293 +0,0 @@
1-package gui
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "testing"
10- "time"
11-
12- "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/nefercap/internal/ports"
16-)
17-
18-func TestNewModelLabelsAndDefaults(t *testing.T) {
19- m := newTestModel(t)
20- require.Len(t, m.outputs, 3)
21- assert.Equal(t, "DP-1 2560x1440", m.outputs[0].label)
22- assert.Equal(t, "output 9 1920x1080 scale 2", m.outputs[1].label)
23- assert.Equal(t, "output 11", m.outputs[2].label)
24- assert.Equal(t, m.outputs[0].key, m.output)
25- assert.Equal(t, "nefercap-20260102-030405.png", m.defaultShot)
26- assert.Equal(t, "nefercap-20260102-030405.mp4", m.defaultRec)
27- assert.Equal(t, "30", m.fps)
28- assert.Equal(t, "10", m.duration)
29- keys := map[string]bool{}
30- for _, o := range m.outputs {
31- assert.False(t, keys[o.key])
32- keys[o.key] = true
33- }
34-}
35-
36-func TestNewModelNoOutputs(t *testing.T) {
37- _, err := newModel(nil, time.Now())
38- assert.ErrorIs(t, err, ports.ErrOutputNotFound)
39-}
40-
41-func TestSelectRejectsBeforeOpeningWindow(t *testing.T) {
42- ctx, cancel := context.WithCancel(context.Background())
43- cancel()
44- _, ok, err := New().Select(ctx, testOutputs)
45- assert.False(t, ok)
46- assert.ErrorIs(t, err, context.Canceled)
47-
48- _, ok, err = New().Select(context.Background(), nil)
49- assert.False(t, ok)
50- assert.ErrorIs(t, err, ports.ErrOutputNotFound)
51-}
52-
53-func TestModeChangeSwapsOnlyUntouchedDefault(t *testing.T) {
54- m := newTestModel(t)
55- m.mode = modeRecord
56- m.modeChanged()
57- assert.Equal(t, m.defaultRec, m.path)
58- m.mode = modeScreenshot
59- m.modeChanged()
60- assert.Equal(t, m.defaultShot, m.path)
61-
62- m.path = "mine.png"
63- m.mode = modeRecord
64- m.modeChanged()
65- assert.Equal(t, "mine.png", m.path)
66-}
67-
68-func TestSubmitAccepts(t *testing.T) {
69- m := newTestModel(t)
70- ctx, cancel := context.WithCancel(context.Background())
71- defer cancel()
72- m.cancel = cancel
73- m.path = filepath.Join(t.TempDir(), "shot.png")
74- m.submit()
75- assert.True(t, m.accepted)
76- assert.Error(t, ctx.Err())
77- assert.Equal(t, m.path, m.sel.Path)
78- assert.Equal(t, ports.Screenshot, m.sel.Mode)
79-
80- // Later dismissal cannot revoke an accepted selection.
81- m.dismiss()
82- assert.False(t, m.closed)
83-}
84-
85-func TestSubmitInvalidStaysOpen(t *testing.T) {
86- m := newTestModel(t)
87- ctx, cancel := context.WithCancel(context.Background())
88- defer cancel()
89- m.cancel = cancel
90- m.path = ""
91- m.submit()
92- assert.False(t, m.accepted)
93- assert.NoError(t, ctx.Err())
94-}
95-
96-func TestSubmitExistingPathStaysOpen(t *testing.T) {
97- m := newTestModel(t)
98- ctx, cancel := context.WithCancel(context.Background())
99- defer cancel()
100- m.cancel = cancel
101- m.path = filepath.Join(t.TempDir(), "taken.png")
102- require.NoError(t, os.WriteFile(m.path, nil, 0o600))
103- m.submit()
104- assert.False(t, m.accepted)
105- assert.NoError(t, ctx.Err())
106- text, submittable, ok := m.status()
107- assert.Equal(t, noticeExists, text)
108- assert.True(t, submittable)
109- assert.False(t, ok)
110-
111- m.pathChanged()
112- text, submittable, ok = m.status()
113- assert.Equal(t, statusReady, text)
114- assert.True(t, submittable)
115- assert.True(t, ok)
116-}
117-
118-func TestStatusInvalid(t *testing.T) {
119- m := newTestModel(t)
120- m.region = "bad"
121- text, submittable, ok := m.status()
122- assert.Equal(t, errRegion.Error(), text)
123- assert.False(t, submittable)
124- assert.False(t, ok)
125-}
126-
127-func TestDismiss(t *testing.T) {
128- m := newTestModel(t)
129- ctx, cancel := context.WithCancel(context.Background())
130- defer cancel()
131- m.cancel = cancel
132- m.dismiss()
133- assert.True(t, m.closed)
134- assert.Error(t, ctx.Err())
135- m.submit()
136- assert.False(t, m.accepted)
137-}
138-
139-func TestResult(t *testing.T) {
140- closeErr := errors.New("close surface")
141- canceledParent, cancelParent := context.WithCancel(context.Background())
142- cancelParent()
143- tests := []struct {
144- name string
145- parent context.Context
146- accepted bool
147- closed bool
148- runErr error
149- wantOK bool
150- wantErr error
151- wantNoErr bool
152- wantNotErr error
153- }{
154- {name: "accepted canceled child", parent: context.Background(), accepted: true, runErr: context.Canceled, wantOK: true, wantNoErr: true},
155- {name: "accepted nil run", parent: context.Background(), accepted: true, wantOK: true, wantNoErr: true},
156- {name: "closed by button", parent: context.Background(), closed: true, runErr: context.Canceled, wantNoErr: true},
157- {name: "closed by window", parent: context.Background(), wantNoErr: true},
158- {name: "accepted but close failed", parent: context.Background(), accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr, wantNotErr: context.Canceled},
159- {name: "accepted but run failed", parent: context.Background(), accepted: true, runErr: closeErr, wantErr: closeErr},
160- {name: "closed with failure", parent: context.Background(), closed: true, runErr: fmt.Errorf("x: %w", closeErr), wantErr: closeErr},
161- {name: "setup failure", parent: context.Background(), runErr: closeErr, wantErr: closeErr},
162- {name: "unexplained cancel", parent: context.Background(), runErr: context.Canceled, wantErr: context.Canceled},
163- {name: "parent canceled", parent: canceledParent, runErr: context.Canceled, wantErr: context.Canceled},
164- {name: "parent canceled nil run", parent: canceledParent, wantErr: context.Canceled},
165- {name: "parent canceled after accept", parent: canceledParent, accepted: true, runErr: context.Canceled, wantErr: context.Canceled},
166- {name: "parent canceled with close failure", parent: canceledParent, accepted: true, runErr: errors.Join(context.Canceled, closeErr), wantErr: closeErr},
167- }
168- for _, tt := range tests {
169- t.Run(tt.name, func(t *testing.T) {
170- m := newTestModel(t)
171- m.accepted, m.closed = tt.accepted, tt.closed
172- m.sel = ports.Selection{Mode: ports.Record, Path: "x.mp4"}
173- sel, ok, err := m.result(tt.parent, tt.runErr)
174- assert.Equal(t, tt.wantOK, ok)
175- if tt.wantOK {
176- assert.Equal(t, m.sel, sel)
177- } else {
178- assert.Equal(t, ports.Selection{}, sel)
179- }
180- if tt.wantNoErr {
181- assert.NoError(t, err)
182- }
183- if tt.wantErr != nil {
184- assert.ErrorIs(t, err, tt.wantErr)
185- }
186- if tt.wantNotErr != nil {
187- assert.NotErrorIs(t, err, tt.wantNotErr)
188- }
189- })
190- }
191-}
192-
193-// A parent deadline or cancel with only cancellation noise from Run reports
194-// the parent's own error bare, so callers can exit cleanly on it.
195-func TestResultReturnsBareParentError(t *testing.T) {
196- m := newTestModel(t)
197- parent, cancel := context.WithCancel(context.Background())
198- cancel()
199- _, _, err := m.result(parent, errors.Join(context.Canceled, context.Canceled))
200- assert.True(t, err == context.Canceled, "bare canceled error")
201-
202- deadline, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
203- defer stop()
204- _, _, err = m.result(deadline, context.DeadlineExceeded)
205- assert.True(t, err == context.DeadlineExceeded, "bare deadline error")
206-}
207-
208-func TestDropErrors(t *testing.T) {
209- real := errors.New("real")
210- assert.NoError(t, dropErrors(nil, context.Canceled))
211- assert.NoError(t, dropErrors(context.Canceled, context.Canceled))
212- assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.Canceled), context.Canceled))
213- assert.Same(t, real, dropErrors(errors.Join(context.Canceled, real), context.Canceled))
214- wrapped := fmt.Errorf("wrapped: %w", context.Canceled)
215- assert.Equal(t, wrapped, dropErrors(wrapped, context.Canceled), "wrapped cancellation is not ours")
216- both := dropErrors(errors.Join(real, errors.New("second")), context.Canceled)
217- assert.ErrorIs(t, both, real)
218- assert.NoError(t, dropErrors(errors.Join(context.Canceled, context.DeadlineExceeded), context.Canceled, context.DeadlineExceeded))
219-}
220-
221-func TestCheckPath(t *testing.T) {
222- dir := t.TempDir()
223- file := filepath.Join(dir, "taken.png")
224- require.NoError(t, os.WriteFile(file, nil, 0o600))
225- assert.Empty(t, checkPath(filepath.Join(dir, "new.png")))
226- assert.Empty(t, checkPath(filepath.Join(dir, "new.mp4")), "extension does not dictate format")
227- assert.Equal(t, noticeExists, checkPath(file))
228- assert.Equal(t, noticeDir, checkPath(filepath.Join(dir, "missing", "new.png")))
229- assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "new.png")), "parent is a file")
230- assert.Empty(t, checkPath("relative-new-name.png"))
231-
232- link := filepath.Join(dir, "dangling.png")
233- require.NoError(t, os.Symlink(filepath.Join(dir, "nowhere"), link))
234- assert.Equal(t, noticeExists, checkPath(link), "dangling symlink is not overwritten")
235-
236- // A path component that is a regular file yields ENOTDIR, not ErrNotExist.
237- assert.Equal(t, noticeDir, checkPath(filepath.Join(file, "x", "y.png")))
238-}
239-
240-func TestRecordWithImageExtensionAccepted(t *testing.T) {
241- m := newTestModel(t)
242- m.mode = modeRecord
243- m.path = filepath.Join(t.TempDir(), "clip.png")
244- require.NoError(t, m.parse())
245- assert.Equal(t, ports.Record, m.sel.Mode)
246-}
247-
248-func TestWriteStyleSheet(t *testing.T) {
249- t.Setenv("TMPDIR", t.TempDir())
250- path, remove, err := writeStyleSheet()
251- require.NoError(t, err)
252- data, err := os.ReadFile(path)
253- require.NoError(t, err)
254- assert.Equal(t, styleSheet, string(data))
255- info, err := os.Stat(path)
256- require.NoError(t, err)
257- assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
258- remove()
259- _, err = os.Stat(path)
260- assert.ErrorIs(t, err, os.ErrNotExist)
261-}
262-
263-func TestStyleSheetUsesMonospace(t *testing.T) {
264- assert.Contains(t, styleSheet, "monospace")
265-}
266-
267-func TestStyleSheetScrollsRootAndOmitsDeadRules(t *testing.T) {
268- assert.Contains(t, styleSheet, "overflow-y: auto")
269- assert.NotContains(t, styleSheet, "separator")
270-}
271-
272-func TestWriteStyleSheetFailure(t *testing.T) {
273- t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
274- _, _, err := writeStyleSheet()
275- assert.Error(t, err)
276-}
277-
278-func TestWindowHeightIsBounded(t *testing.T) {
279- m := newTestModel(t)
280- assert.Equal(t, 600+26*3, m.windowHeight())
281- m.outputs = make([]outputChoice, 100)
282- assert.Equal(t, 1000, m.windowHeight())
283-}
284-
285-func TestModeChangeNoop(t *testing.T) {
286- m := newTestModel(t)
287- m.notice = "keep"
288- m.modeChanged()
289- assert.Equal(t, "keep", m.notice)
290- m.mode = modeRecord
291- m.modeChanged()
292- assert.Empty(t, m.notice)
293-}
D internal/adapters/gui/model.go +0 -220
@@ -1,220 +0,0 @@
1-package gui
2-
3-import (
4- "context"
5- "errors"
6- "io/fs"
7- "os"
8- "path/filepath"
9- "strconv"
10- "strings"
11- "time"
12-
13- "github.com/bnema/nefergui"
14-
15- "github.com/bnema/nefercap/internal/ports"
16-)
17-
18-const (
19- modeScreenshot = string(ports.Screenshot)
20- modeRecord = string(ports.Record)
21-
22- statusReady = "ready: press Capture (or Enter in a field)"
23- noticeExists = "! file exists: choose another path"
24-
25- // The panel is a regular toplevel: the compositor places it, and capture
26- // support is limited to what the capture adapter offers.
27- warning = "note: regular window, not a layer-shell overlay; it opens on the monitor " +
28- "of the current workspace. Off-screen sources and the cursor cannot be captured."
29- recordNote = "duration 0 records until Ctrl+C in the terminal that started nefercap. " +
30- "Video width and height must be even: set an even resolution if the source is odd."
31-
32- noticeStat = "! cannot check the file path"
33- noticeDir = "! folder does not exist"
34- stampLayout = "20060102-150405"
35-
36- // A finite default lets a desktop launch stop without a terminal; enter 0
37- // to record until interrupted.
38- defaultDuration = "10"
39-)
40-
41-// outputChoice is one precomputed radio entry.
42-type outputChoice struct {
43- id uint32
44- key, label string
45- opts []nefergui.ButtonOption
46-}
47-
48-// model is owned by the single Run view; nothing else touches it while the
49-// window is open.
50-type model struct {
51- outputs []outputChoice
52- output string // key of the selected outputChoice
53- mode string
54- // lastMode detects mode changes to swap an untouched default path.
55- lastMode string
56-
57- path, region, fps, size, duration string
58- defaultShot, defaultRec string
59-
60- // notice is a submit-time problem shown until the path or mode changes.
61- notice string
62-
63- // Last duration parse, so an unchanged field is not re-parsed per frame.
64- durSrc string
65- durVal time.Duration
66- durOK bool
67- durCached bool
68-
69- sel ports.Selection
70- accepted bool
71- closed bool
72- cancel context.CancelFunc
73-}
74-
75-// newModel precomputes output labels, keys and default names once.
76-func newModel(outputs []ports.Output, now time.Time) (*model, error) {
77- if len(outputs) == 0 {
78- return nil, errNoOutputs
79- }
80- stamp := now.Format(stampLayout)
81- m := &model{
82- outputs: make([]outputChoice, len(outputs)),
83- mode: modeScreenshot,
84- lastMode: modeScreenshot,
85- defaultShot: "nefercap-" + stamp + ".png",
86- defaultRec: "nefercap-" + stamp + ".mp4",
87- fps: "30",
88- duration: defaultDuration,
89- }
90- m.path = m.defaultShot
91- var b strings.Builder
92- for i, o := range outputs {
93- b.Reset()
94- if o.Name != "" {
95- b.WriteString(o.Name)
96- } else {
97- b.WriteString("output ")
98- b.WriteString(strconv.FormatUint(uint64(o.ID), 10))
99- }
100- if o.Width > 0 && o.Height > 0 {
101- b.WriteString(" ")
102- b.WriteString(strconv.Itoa(o.Width))
103- b.WriteByte('x')
104- b.WriteString(strconv.Itoa(o.Height))
105- }
106- if o.Scale > 1 {
107- b.WriteString(" scale ")
108- b.WriteString(strconv.Itoa(o.Scale))
109- }
110- key := strconv.Itoa(i)
111- m.outputs[i] = outputChoice{
112- id: o.ID,
113- key: key,
114- label: b.String(),
115- opts: []nefergui.ButtonOption{nefergui.Key("out-" + key)},
116- }
117- }
118- m.output = m.outputs[0].key
119- return m, nil
120-}
121-
122-// outputIndex returns the selected output's index, or -1.
123-func (m *model) outputIndex() int {
124- for i := range m.outputs {
125- if m.outputs[i].key == m.output {
126- return i
127- }
128- }
129- return -1
130-}
131-
132-// windowHeight sizes the initial window for the output list.
133-func (m *model) windowHeight() int {
134- return min(600+26*len(m.outputs), 1000)
135-}
136-
137-// status reports the live validation result. submittable is false while the
138-// form is invalid; ok is false whenever the message is a problem, including a
139-// submit-time notice that still lets the user retry.
140-func (m *model) status() (text string, submittable, ok bool) {
141- if err := m.parse(); err != nil {
142- return err.Error(), false, false
143- }
144- if m.notice != "" {
145- return m.notice, true, false
146- }
147- return statusReady, true, true
148-}
149-
150-// modeChanged swaps the default file name when the user has not edited it.
151-func (m *model) modeChanged() {
152- if m.mode == m.lastMode {
153- return
154- }
155- if m.path == m.defaultFor(m.lastMode) {
156- m.path = m.defaultFor(m.mode)
157- }
158- m.lastMode = m.mode
159- m.notice = ""
160-}
161-
162-func (m *model) defaultFor(mode string) string {
163- if mode == modeRecord {
164- return m.defaultRec
165- }
166- return m.defaultShot
167-}
168-
169-// pathChanged clears stale submit notices.
170-func (m *model) pathChanged() { m.notice = "" }
171-
172-// submit validates the form; on success it records the selection and closes
173-// the window by cancelling Run's child context. Repeated calls are ignored.
174-func (m *model) submit() {
175- if m.accepted || m.closed {
176- return
177- }
178- if err := m.parse(); err != nil {
179- return
180- }
181- if n := checkPath(m.sel.Path); n != "" {
182- m.notice = n
183- return
184- }
185- m.notice = ""
186- m.accepted = true
187- m.stop()
188-}
189-
190-// checkPath returns a static notice when path cannot be created, or "". The
191-// folder must exist; write permission is left to the capture adapter.
192-func checkPath(path string) string {
193- if info, err := os.Stat(filepath.Dir(path)); err != nil || !info.IsDir() {
194- return noticeDir
195- }
196- _, err := os.Lstat(path)
197- switch {
198- case err == nil:
199- return noticeExists
200- case errors.Is(err, fs.ErrNotExist):
201- return ""
202- default:
203- return noticeStat
204- }
205-}
206-
207-// dismiss closes the window without a selection.
208-func (m *model) dismiss() {
209- if m.accepted || m.closed {
210- return
211- }
212- m.closed = true
213- m.stop()
214-}
215-
216-func (m *model) stop() {
217- if m.cancel != nil {
218- m.cancel()
219- }
220-}
D internal/adapters/gui/smoke_test.go +0 -31
@@ -1,31 +0,0 @@
1-package gui
2-
3-import (
4- "context"
5- "os"
6- "testing"
7- "time"
8-
9- "github.com/bnema/nefergui"
10-)
11-
12-// TestSmokeRunFrames opens the real panel for a few frames. It needs a Wayland
13-// session with Vulkan, so it runs only when NEFERCAP_GUI_SMOKE=1.
14-func TestSmokeRunFrames(t *testing.T) {
15- if os.Getenv("NEFERCAP_GUI_SMOKE") != "1" {
16- t.Skip("set NEFERCAP_GUI_SMOKE=1 on a Wayland session to open the panel")
17- }
18- m := newTestModel(t)
19- m.mode = modeRecord
20- sheet, remove, err := writeStyleSheet()
21- if err != nil {
22- t.Fatal(err)
23- }
24- defer remove()
25- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
26- defer cancel()
27- if err := nefergui.RunFrames(ctx, 3, m, view, nil,
28- nefergui.Title("NeferCap smoke"), nefergui.Size(640, m.windowHeight()), nefergui.Styles(sheet)); err != nil {
29- t.Fatal(err)
30- }
31-}
D internal/adapters/gui/style.css +0 -39
@@ -1,39 +0,0 @@
1-/* Dark, monospace, thin borders, one accent (#4fd1a5). */
2-app {
3- display: flex; flex-direction: column; width: 100%; height: 100%;
4- box-sizing: border-box; padding: 16px 20px; row-gap: 6px;
5- background: #0d1014; color: #c9d1d9;
6- font-family: monospace; font-size: 14px; line-height: 1.4;
7- accent-color: #4fd1a5;
8- /* Short windows scroll with the wheel; NeferGUI 0.1.0 does not scroll focus into view. */
9- overflow-y: auto;
10-}
11-/* Children keep their natural height so overflow scrolls instead of squashing. */
12-heading, text, radio, row, column, button { flex-shrink: 0; }
13-heading { margin: 0 0 4px 0; font-size: 16px; font-weight: 700; color: #4fd1a5; }
14-text.section { margin-top: 8px; color: #8b949e; }
15-text.note { color: #8b949e; font-size: 12px; }
16-text.status { margin-top: 8px; color: #8b949e; }
17-text.status.bad { color: #e6edf3; font-weight: 700; }
18-text.field-label { width: 96px; flex-shrink: 0; color: #8b949e; }
19-column.group {
20- display: flex; flex-direction: column; row-gap: 2px;
21- box-sizing: border-box; padding: 6px 8px; border: 1px solid #2a313b;
22-}
23-row.field { display: flex; flex-direction: row; align-items: center; column-gap: 8px; }
24-row.actions { display: flex; flex-direction: row; column-gap: 8px; margin-top: 8px; }
25-radio { padding-top: 2px; padding-bottom: 2px; color: #c9d1d9; }
26-radio:hover { color: #e6edf3; }
27-radio:focus-visible, button:focus-visible { outline: 1px solid #4fd1a5; outline-offset: 1px; }
28-input {
29- box-sizing: border-box; flex-grow: 1; min-width: 0; padding: 4px 8px;
30- background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
31-}
32-input:focus { border-color: #4fd1a5; }
33-button {
34- box-sizing: border-box; padding: 6px 16px;
35- background: #12161c; color: #e6edf3; border: 1px solid #2a313b;
36-}
37-button:hover { border-color: #4fd1a5; }
38-button.primary { background: #4fd1a5; color: #0d1014; border-color: #4fd1a5; font-weight: 700; }
39-button:disabled, button.primary:disabled { background: #12161c; color: #5b6570; border-color: #2a313b; }
D internal/adapters/gui/view.go +0 -137
@@ -1,137 +0,0 @@
1-package gui
2-
3-import "github.com/bnema/nefergui"
4-
5-// Option slices are built once at init; the view only passes them through.
6-var (
7- rootOpts = []nefergui.ContainerOption{nefergui.Key("root")}
8- titleOpts = []nefergui.HeadingOption{nefergui.Key("title"), nefergui.Level(1)}
9- outputTitle = sectionOpts("output-title")
10- modeTitle = sectionOpts("mode-title")
11- targetTitle = sectionOpts("target-title")
12- outputsGroup = groupOpts("outputs")
13- modeGroup = groupOpts("modes")
14- targetGroup = groupOpts("target")
15- recordGroup = groupOpts("record")
16- shotOpts = []nefergui.ButtonOption{nefergui.Key("mode-screenshot")}
17- recOpts = []nefergui.ButtonOption{nefergui.Key("mode-record")}
18- recordNoteOp = noteOpts("record-note")
19- warningOpts = noteOpts("warning")
20- statusGood = []nefergui.ContainerOption{nefergui.Key("status"), nefergui.Class("status")}
21- statusBad = []nefergui.ContainerOption{nefergui.Key("status"), nefergui.Class("status"), nefergui.Class("bad")}
22- actionsOpts = []nefergui.ContainerOption{nefergui.Key("actions"), nefergui.Class("actions")}
23- captureOn = []nefergui.ButtonOption{nefergui.Key("capture"), nefergui.Class("primary")}
24- captureOff = []nefergui.ButtonOption{nefergui.Key("capture"), nefergui.Class("primary"), nefergui.Disabled(true)}
25- closeOpts = []nefergui.ButtonOption{nefergui.Key("close")}
26-
27- fileField = newField("file", "file", "path of the new capture file")
28- regionField = newField("region", "region", "X,Y,WxH (empty = full output)")
29- fpsField = newField("fps", "fps", fpsPlaceholder)
30- sizeField = newField("size", "resolution", "WxH (empty = source size)")
31- durationField = newField("duration", "duration", "seconds or 1m30s; 0 = until Ctrl+C")
32-)
33-
34-func sectionOpts(key string) []nefergui.ContainerOption {
35- return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("section")}
36-}
37-
38-func groupOpts(key string) []nefergui.ContainerOption {
39- return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("group")}
40-}
41-
42-func noteOpts(key string) []nefergui.ContainerOption {
43- return []nefergui.ContainerOption{nefergui.Key(key), nefergui.Class("note")}
44-}
45-
46-// field is a labelled text input row with precomputed options.
47-type field struct {
48- label, key string
49- row, name []nefergui.ContainerOption
50- input []nefergui.EditOption
51-}
52-
53-func newField(key, label, placeholder string) *field {
54- return &field{
55- label: label,
56- key: key,
57- row: []nefergui.ContainerOption{nefergui.Key(key + "-row"), nefergui.Class("field")},
58- name: []nefergui.ContainerOption{nefergui.Key(key + "-label"), nefergui.Class("field-label")},
59- input: []nefergui.EditOption{nefergui.Key(key), nefergui.Placeholder(placeholder)},
60- }
61-}
62-
63-// show declares one row and reports edit and Enter events.
64-func (f *field) show(parent nefergui.Node, value *string) (changed, submitted bool) {
65- row := parent.Row(f.row...)
66- row.Text(f.label, f.name...)
67- ev := row.Input(f.label, value, f.input...)
68- return ev.Changed(), ev.Submitted()
69-}
70-
71-// view builds the whole panel from the model. The model is owned by this view
72-// alone; events mutate it while the frame is built.
73-func view(f *nefergui.Frame, m *model) {
74- root := f.Root(rootOpts...)
75- submit := false
76- root.Heading("NeferCap", titleOpts...)
77-
78- text, submittable, ok := m.status()
79- if ok {
80- root.Text(text, statusGood...)
81- } else {
82- root.Text(text, statusBad...)
83- }
84-
85- actions := root.Row(actionsOpts...)
86- capture := captureOff
87- if submittable {
88- capture = captureOn
89- }
90- if actions.Button("Capture", capture...).Activated() && submittable {
91- submit = true
92- }
93- if actions.Button("Close", closeOpts...).Activated() {
94- m.dismiss()
95- }
96-
97- root.Text("output", outputTitle...)
98- outs := root.Column(outputsGroup...)
99- for i := range m.outputs {
100- o := &m.outputs[i]
101- outs.Radio(o.label, o.key, &m.output, o.opts...)
102- }
103-
104- root.Text("capture", modeTitle...)
105- modes := root.Column(modeGroup...)
106- modes.Radio("screenshot", modeScreenshot, &m.mode, shotOpts...)
107- modes.Radio("record", modeRecord, &m.mode, recOpts...)
108- m.modeChanged()
109-
110- root.Text("target", targetTitle...)
111- target := root.Column(targetGroup...)
112- changed, entered := fileField.show(target, &m.path)
113- if changed {
114- m.pathChanged()
115- }
116- submit = submit || entered
117- _, entered = regionField.show(target, &m.region)
118- submit = submit || entered
119-
120- if m.mode == modeRecord {
121- rec := root.Column(recordGroup...)
122- for _, e := range [...]struct {
123- f *field
124- v *string
125- }{{fpsField, &m.fps}, {sizeField, &m.size}, {durationField, &m.duration}} {
126- _, entered = e.f.show(rec, e.v)
127- submit = submit || entered
128- }
129- rec.Text(recordNote, recordNoteOp...)
130- }
131-
132- root.Text(warning, warningOpts...)
133-
134- if submit {
135- m.submit()
136- }
137-}
A internal/adapters/indicator/hud.go +107 -0
@@ -0,0 +1,107 @@
1+package indicator
2+
3+import (
4+ "strconv"
5+ "time"
6+ "unicode"
7+)
8+
9+// prefix starts the recording label; the elapsed time follows it.
10+const prefix = "● REC "
11+
12+// maxLabelRunes bounds the optional target label so the line fits beside the
13+// Stop button: 228 px of 13 px monospace is about 29 cells, and "● REC h:mm:ss · "
14+// takes 16.
15+const maxLabelRunes = 12
16+
17+// suffixFor returns " · label" with the label cut to maxLabelRunes (ending in
18+// an ellipsis when cut), or "" for an empty label. Anything that is not a
19+// printable character is dropped, so a workspace name cannot break the single
20+// line: controls, unassigned and private-use code points, the line and
21+// paragraph separators U+2028 and U+2029, and the bidirectional controls
22+// (Unicode Bidi_Control) that could reorder the timer beside it.
23+func suffixFor(label string) string {
24+ var r []rune
25+ for _, c := range label {
26+ if keepRune(c) {
27+ r = append(r, c)
28+ }
29+ }
30+ if len(r) == 0 {
31+ return ""
32+ }
33+ if len(r) > maxLabelRunes {
34+ r = append(r[:maxLabelRunes-1], '…')
35+ }
36+ return " · " + string(r)
37+}
38+
39+// keepRune reports whether c may appear in the label.
40+func keepRune(c rune) bool {
41+ if c == 0x2028 || c == 0x2029 || unicode.Is(unicode.Bidi_Control, c) {
42+ return false
43+ }
44+ // IsPrint admits only letters, marks, numbers, punctuation, symbols and the
45+ // ASCII space; other spaces, controls and format characters are dropped.
46+ return unicode.IsPrint(c)
47+}
48+
49+// hud is the indicator model. The view and every write run on the NeferGUI
50+// owner loop, so nothing is locked. The elapsed time is computed from the
51+// monotonic clock reading the view passes to refresh, never from a counter
52+// another goroutine advances. start carries a monotonic reading (time.Now), so
53+// wall-clock steps do not move the timer.
54+type hud struct {
55+ start time.Time
56+ stop chan<- struct{}
57+
58+ suffix string // fixed for the session, built once
59+ secs int64 // seconds shown in text; -1 before the first view
60+ text string
61+ buf []byte
62+}
63+
64+func newHUD(start time.Time, stop chan<- struct{}, label string) *hud {
65+ return &hud{start: start, stop: stop, suffix: suffixFor(label), secs: -1}
66+}
67+
68+// refresh returns the label for the reading now, rebuilding it only when the
69+// displayed second changed. A reading before start shows zero.
70+func (h *hud) refresh(now time.Time) string {
71+ s := int64(now.Sub(h.start) / time.Second)
72+ if s < 0 {
73+ s = 0
74+ }
75+ if s == h.secs {
76+ return h.text
77+ }
78+ h.secs = s
79+ h.buf = append(appendElapsed(append(h.buf[:0], prefix...), s), h.suffix...)
80+ h.text = string(h.buf)
81+ return h.text
82+}
83+
84+// requestStop notifies the owner without blocking. A full channel already
85+// holds a pending stop request.
86+func (h *hud) requestStop() {
87+ select {
88+ case h.stop <- struct{}{}:
89+ default:
90+ }
91+}
92+
93+// appendElapsed appends mm:ss, or h:mm:ss from one hour.
94+func appendElapsed(b []byte, secs int64) []byte {
95+ h, m, s := secs/3600, secs/60%60, secs%60
96+ if h > 0 {
97+ b = strconv.AppendInt(b, h, 10)
98+ b = append(b, ':')
99+ }
100+ b = appendTwo(b, m)
101+ b = append(b, ':')
102+ return appendTwo(b, s)
103+}
104+
105+func appendTwo(b []byte, n int64) []byte {
106+ return append(b, byte('0'+n/10), byte('0'+n%10))
107+}
A internal/adapters/indicator/indicator.go +171 -0
@@ -0,0 +1,171 @@
1+// Package indicator is the recording HUD: a small layer surface at the top
2+// centre of the recorded output showing a REC timer and a Stop button.
3+//
4+// It is one thin surface, not a full-output overlay: it takes no keyboard
5+// focus, and only the Stop button receives pointer input; the rest of the HUD
6+// and everything around it pass clicks through. The red border around the
7+// recorded area is not drawn here; the compositor owns it.
8+//
9+// Capture exclusion is explicit authorization, not a property of how the HUD is
10+// built. The caller supplies authorize, which NeferGUI calls with the surface
11+// once it is configured and before its first buffer, to register whatever
12+// exclusion the compositor contract requires on that same client; Run refuses
13+// to start without it. The layer namespace is only a role hint and authorizes
14+// nothing. If authorize fails, Run fails and nothing is shown.
15+package indicator
16+
17+import (
18+ "context"
19+ _ "embed"
20+ "errors"
21+ "fmt"
22+ "sync"
23+ "time"
24+
25+ "github.com/bnema/nefergui"
26+
27+ "github.com/bnema/nefercap/internal/adapters/uierrors"
28+ "github.com/bnema/nefercap/internal/adapters/uistyle"
29+ "github.com/bnema/nefercap/internal/ports"
30+)
31+
32+//go:embed style.css
33+var styleSheet string
34+
35+// HUD geometry in logical pixels. The Stop button is the only input region.
36+const (
37+ Width, Height = 300, 32
38+ stopWidth = 72
39+ topMargin = 8
40+ namespace = "nefercap-indicator"
41+ tick = time.Second
42+)
43+
44+var errOverlayClosed = errors.New("overlay closed by the compositor")
45+
46+// Setup errors, returned before anything is shown.
47+var (
48+ // ErrNoAuthorize means a HUD was built without an exclusion hook.
49+ ErrNoAuthorize = errors.New("indicator: capture exclusion hook is required")
50+ // ErrNoStop means a HUD was built without a stop channel.
51+ ErrNoStop = errors.New("indicator: stop channel is required")
52+)
53+
54+// Indicator shows the recording HUD.
55+type Indicator struct {
56+ // TargetLabel optionally names what is recorded (a workspace or output
57+ // name). It is shown after the timer, cut to 12 characters, and only as
58+ // text: the HUD never draws geometry for it. Set it before Run.
59+ TargetLabel string
60+
61+ authorize func(context.Context, nefergui.WaylandSurface) error
62+ stop chan<- struct{}
63+}
64+
65+// New returns an indicator. authorize is required and runs once per Run, on
66+// the window's owner loop; its context ends when Run's does, so a blocking wait
67+// for the compositor's acknowledgement is interrupted by cancellation. stop
68+// should be buffered; a click sends one non-blocking value. The caller reacts
69+// by ending the recording.
70+func New(authorize func(context.Context, nefergui.WaylandSurface) error, stop chan<- struct{}) *Indicator {
71+ return &Indicator{authorize: authorize, stop: stop}
72+}
73+
74+// Run shows the HUD on output until ctx ends or the window system fails. It
75+// returns nil when ctx was canceled by the caller, and an error otherwise; the
76+// error names the output and its size. The elapsed time starts when Run is
77+// called, before the window exists. Run blocks; call it from its own goroutine
78+// and cancel ctx to remove the HUD.
79+func (i *Indicator) Run(ctx context.Context, output ports.Output) error {
80+ switch {
81+ case i == nil || i.authorize == nil:
82+ return ErrNoAuthorize
83+ case i.stop == nil:
84+ return ErrNoStop
85+ case output.Name == "":
86+ return fmt.Errorf("indicator: output %d has no name: %w", output.ID, ports.ErrOutputNotFound)
87+ }
88+ if err := ctx.Err(); err != nil {
89+ return err
90+ }
91+ if err := i.run(ctx, output, time.Now()); err != nil {
92+ return fmt.Errorf("indicator: output %s (%dx%d): %w", output.Name, output.Width, output.Height, err)
93+ }
94+ return nil
95+}
96+
97+func (i *Indicator) run(ctx context.Context, output ports.Output, start time.Time) error {
98+ sheet, remove, err := uistyle.Write(styleSheet, "nefercap-indicator-*.css")
99+ if err != nil {
100+ return err
101+ }
102+ defer remove()
103+
104+ child, cancel := context.WithCancel(ctx)
105+ wake := make(chan struct{}, 1)
106+ var wg sync.WaitGroup
107+ wg.Add(1)
108+ go func() { defer wg.Done(); ticker(child, wake) }()
109+ defer func() { cancel(); wg.Wait() }()
110+
111+ runErr := nefergui.Run(child, newHUD(start, i.stop, i.TargetLabel), view,
112+ nefergui.Title("NeferCap recording"),
113+ nefergui.Size(Width, Height),
114+ nefergui.Styles(sheet),
115+ nefergui.Transparent(),
116+ nefergui.Layer(layerConfig(output.Name)),
117+ nefergui.OnSurface(i.authorize),
118+ nefergui.Wake(wake),
119+ )
120+ // Run is only ever ended by ctx: the HUD never decides by itself. Read the
121+ // parent once; a bare Canceled is then our own cancellation, and anything
122+ // else, including an end with a live parent, is a failure that keeps every
123+ // real error.
124+ perr := ctx.Err()
125+ return uierrors.Ended(runErr, perr != nil, perr, errOverlayClosed)
126+}
127+
128+// layerConfig is a top-centre HUD that never takes keyboard focus and accepts
129+// pointer input only inside the Stop button.
130+func layerConfig(output string) nefergui.LayerConfig {
131+ return nefergui.LayerConfig{
132+ Output: output,
133+ Namespace: namespace,
134+ Level: nefergui.LayerOverlay,
135+ Anchors: nefergui.AnchorTop,
136+ Keyboard: nefergui.KeyboardNone,
137+ ExclusiveZone: -1,
138+ Margin: [4]int32{topMargin, 0, 0, 0},
139+ InputRects: []nefergui.Rect{stopRect()},
140+ }
141+}
142+
143+func stopRect() nefergui.Rect {
144+ return nefergui.Rect{X: Width - stopWidth, Y: 0, Width: stopWidth, Height: Height}
145+}
146+
147+// ticker sends a non-blocking wake token every second until ctx ends.
148+func ticker(ctx context.Context, wake chan<- struct{}) {
149+ t := time.NewTicker(tick)
150+ defer t.Stop()
151+ for {
152+ select {
153+ case <-ctx.Done():
154+ return
155+ case <-t.C:
156+ select {
157+ case wake <- struct{}{}:
158+ default:
159+ }
160+ }
161+ }
162+}
163+
164+func view(f *nefergui.Frame, m *hud) {
165+ root := f.Root(nefergui.Class("hud"))
166+ row := root.Row(nefergui.Class("bar"))
167+ row.Text(m.refresh(time.Now()), nefergui.Key("timer"), nefergui.Class("timer"))
168+ if row.Button("Stop", nefergui.Key("stop"), nefergui.Class("stop")).Activated() {
169+ m.requestStop()
170+ }
171+}
A internal/adapters/indicator/indicator_test.go +116 -0
@@ -0,0 +1,116 @@
1+package indicator
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefergui"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/nefercap/internal/ports"
13+)
14+
15+func TestAppendElapsed(t *testing.T) {
16+ for secs, want := range map[int64]string{
17+ 0: "00:00", 5: "00:05", 65: "01:05", 3599: "59:59", 3600: "1:00:00", 36061: "10:01:01",
18+ } {
19+ assert.Equal(t, want, string(appendElapsed(nil, secs)), "%d", secs)
20+ }
21+}
22+
23+func TestRefreshFollowsTheReadingAndCachesSecond(t *testing.T) {
24+ start := time.Unix(100, 0)
25+ h := newHUD(start, make(chan struct{}, 1), "")
26+ assert.Equal(t, "● REC 00:00", h.refresh(start))
27+ assert.Equal(t, "● REC 00:00", h.refresh(start.Add(999*time.Millisecond)))
28+ assert.Equal(t, "● REC 01:01", h.refresh(start.Add(61*time.Second)))
29+ assert.Equal(t, "● REC 1:00:00", h.refresh(start.Add(time.Hour)))
30+ assert.Equal(t, "● REC 00:00", h.refresh(start.Add(-time.Second)), "a reading before start never shows negative time")
31+}
32+
33+func TestTargetLabel(t *testing.T) {
34+ start := time.Unix(100, 0)
35+ at := start.Add(5 * time.Second)
36+ for label, want := range map[string]string{
37+ "": "● REC 00:05",
38+ "DP-1": "● REC 00:05 · DP-1",
39+ "web": "● REC 00:05 · web",
40+ "123456789012": "● REC 00:05 · 123456789012",
41+ "1234567890123": "● REC 00:05 · 12345678901…",
42+ "espace de travail": "● REC 00:05 · espace de t…",
43+ "a\nb\x00c": "● REC 00:05 · abc",
44+ "\n\t": "● REC 00:05",
45+ } {
46+ assert.Equal(t, want, newHUD(start, make(chan struct{}, 1), label).refresh(at), "%q", label)
47+ }
48+ // The longest line still fits the 228 px text area (about 29 cells).
49+ long := newHUD(start, make(chan struct{}, 1), "wwwwwwwwwwwwwwwwww").refresh(start.Add(time.Hour))
50+ assert.LessOrEqual(t, len([]rune(long)), 29)
51+}
52+
53+func TestTargetLabelIsSingleLinePrintable(t *testing.T) {
54+ for name, label := range map[string]string{
55+ "line separator": "a\u2028b",
56+ "paragraph separator": "a\u2029b",
57+ "bidi override": "a\u202eb",
58+ "bidi isolate": "a\u2066b\u2069",
59+ "bidi mark": "a\u200fb\u061c",
60+ "nbsp": "a\u00a0b",
61+ "zero width": "a\u200bb",
62+ "private use": "a\ue000b",
63+ "next line": "a\u0085b",
64+ "unassigned": "a\U0010ffffb",
65+ } {
66+ got := suffixFor(label)
67+ assert.Equal(t, " · ab", got, name)
68+ }
69+ assert.Equal(t, " · café 東京", suffixFor("café 東京"), "printable non-ASCII stays")
70+ assert.Equal(t, "", suffixFor("\u202e\u2028"))
71+}
72+
73+func TestRefreshAllocations(t *testing.T) {
74+ if raceEnabled {
75+ t.Skip("the race detector adds allocations")
76+ }
77+ start := time.Unix(100, 0)
78+ h := newHUD(start, make(chan struct{}, 1), "workspace-one")
79+ at := start.Add(7 * time.Second)
80+ h.refresh(at)
81+ assert.Equal(t, 0.0, testing.AllocsPerRun(100, func() { h.refresh(at) }))
82+}
83+
84+func TestRequestStopNeverBlocks(t *testing.T) {
85+ stop := make(chan struct{}, 1)
86+ h := newHUD(time.Now(), stop, "")
87+ h.requestStop()
88+ h.requestStop() // channel full: the second request is dropped, not blocked
89+ assert.Len(t, stop, 1)
90+}
91+
92+func TestLayerConfigTakesNoFocusAndOnlyStopInput(t *testing.T) {
93+ c := layerConfig("DP-1")
94+ assert.Equal(t, nefergui.KeyboardNone, c.Keyboard)
95+ assert.Equal(t, nefergui.LayerOverlay, c.Level)
96+ assert.Equal(t, nefergui.AnchorTop, c.Anchors)
97+ assert.Equal(t, int32(-1), c.ExclusiveZone)
98+ require.Len(t, c.InputRects, 1)
99+ r := c.InputRects[0]
100+ assert.Equal(t, nefergui.Rect{X: Width - stopWidth, Width: stopWidth, Height: Height}, r)
101+ assert.LessOrEqual(t, r.X+r.Width, int32(Width))
102+}
103+
104+func TestRunRejectsBadSetup(t *testing.T) {
105+ stop := make(chan struct{}, 1)
106+ ok := func(context.Context, nefergui.WaylandSurface) error { return nil }
107+ out := ports.Output{ID: 1, Name: "DP-1"}
108+
109+ assert.ErrorIs(t, New(nil, stop).Run(context.Background(), out), ErrNoAuthorize)
110+ assert.ErrorIs(t, New(ok, nil).Run(context.Background(), out), ErrNoStop)
111+ assert.NotErrorIs(t, New(ok, nil).Run(context.Background(), out), ErrNoAuthorize)
112+ assert.ErrorIs(t, New(ok, stop).Run(context.Background(), ports.Output{ID: 1}), ports.ErrOutputNotFound)
113+ ctx, cancel := context.WithCancel(context.Background())
114+ cancel()
115+ assert.ErrorIs(t, New(ok, stop).Run(ctx, out), context.Canceled)
116+}
A internal/adapters/indicator/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package indicator
4+
5+const raceEnabled = false
A internal/adapters/indicator/race_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package indicator
4+
5+// The race detector adds allocations, so allocation budgets do not apply.
6+const raceEnabled = true
A internal/adapters/indicator/smoke_test.go +55 -0
@@ -0,0 +1,55 @@
1+package indicator
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefergui"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/nefercap/internal/ports"
14+)
15+
16+// TestSmokeRun shows the real HUD. It needs a Wayland compositor with
17+// layer-shell and Vulkan, so it runs only when NEFERCAP_INDICATOR_SMOKE is
18+// "click" (an input driver presses Stop) or "cancel" (the context ends).
19+// NEFERCAP_INDICATOR_OUTPUT names the wl_output to show it on (for example
20+// HEADLESS-1 on a NeferWL headless compositor) and is required with it.
21+func TestSmokeRun(t *testing.T) {
22+ mode := os.Getenv("NEFERCAP_INDICATOR_SMOKE")
23+ if mode == "" {
24+ t.Skip("set NEFERCAP_INDICATOR_SMOKE=click|cancel on a Wayland session")
25+ }
26+ name := os.Getenv("NEFERCAP_INDICATOR_OUTPUT")
27+ if name == "" {
28+ t.Skip("set NEFERCAP_INDICATOR_OUTPUT to the output name")
29+ }
30+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
31+ defer cancel()
32+ stop := make(chan struct{}, 1)
33+ var authorized bool
34+ ind := New(func(_ context.Context, s nefergui.WaylandSurface) error {
35+ authorized = s.Display != nil && s.Surface != nil
36+ return nil
37+ }, stop)
38+ done := make(chan error, 1)
39+ go func() { done <- ind.Run(ctx, ports.Output{ID: 1, Name: name}) }()
40+ switch mode {
41+ case "click":
42+ select {
43+ case <-stop:
44+ case err := <-done:
45+ t.Fatalf("Run ended before Stop: %v", err)
46+ case <-ctx.Done():
47+ t.Fatal("Stop was never requested")
48+ }
49+ case "cancel":
50+ time.Sleep(3 * time.Second)
51+ }
52+ cancel()
53+ require.NoError(t, <-done)
54+ assert.True(t, authorized, "authorize must run with the live surface")
55+}
A internal/adapters/indicator/style.css +16 -0
@@ -0,0 +1,16 @@
1+/* Recording HUD: 300x32, monospace, one red accent. */
2+app {
3+ width: 100%; height: 100%; background-color: transparent;
4+ font-family: monospace; font-size: 13px; color: #e6edf3;
5+}
6+row.bar {
7+ display: flex; flex-direction: row; align-items: center;
8+ width: 100%; height: 100%; box-sizing: border-box;
9+ background-color: rgba(13, 16, 20, 0.92); border: 1px solid #e5484d;
10+}
11+text.timer { flex-grow: 1; min-width: 0; overflow: hidden; padding-left: 10px; color: #e6edf3; }
12+button.stop {
13+ width: 72px; height: 100%; flex-shrink: 0; box-sizing: border-box;
14+ background-color: #e5484d; color: #ffffff; border: 0; text-align: center;
15+}
16+button.stop:hover { background-color: #ff6369; }
A internal/adapters/selection/model.go +228 -0
@@ -0,0 +1,228 @@
1+package selection
2+
3+import (
4+ "fmt"
5+ "strconv"
6+
7+ "github.com/bnema/nefergui"
8+
9+ "github.com/bnema/nefercap/internal/core"
10+ "github.com/bnema/nefercap/internal/ports"
11+)
12+
13+// btnLeft is the Linux evdev BTN_LEFT code. The picker models the left button.
14+const btnLeft = 0x110
15+
16+type endKind uint8
17+
18+const (
19+ endNone endKind = iota
20+ endAccepted // this overlay recorded the accepted pick in the session
21+ endCanceled // Escape, or a failure
22+)
23+
24+// model is the state of one output's selector overlay. All methods run on that
25+// overlay's NeferGUI owner loop (OnResize, OnInput and the view), so nothing
26+// here is locked. The decision leaves through the shared session, never through
27+// this struct.
28+type model struct {
29+ mode ports.Mode
30+ outputs []ports.Output
31+ index int // the output this overlay covers; every output has its own model
32+
33+ picker core.Picker
34+ sized bool
35+ w, h int
36+
37+ end endKind
38+ sess *session // shared first-decision record; the only cross-overlay state
39+
40+ workspace ports.Workspace // zero ID: none
41+ header string // static per surface
42+ footerText string
43+ label label
44+}
45+
46+func newModel(mode ports.Mode, outputs []ports.Output, index int, sess *session) *model {
47+ return &model{mode: mode, outputs: outputs, index: index, sess: sess, header: headerText(outputs, index), footerText: footerText(mode)}
48+}
49+
50+// resize (re)starts the picker when the logical output size changes. It is
51+// the OnResize callback; scale-only changes keep the current pick.
52+func (m *model) resize(w, h int, _ float64) {
53+ if m.sized && w == m.w && h == m.h {
54+ return
55+ }
56+ if err := m.picker.Begin(m.outputs[m.index].ID, w, h); err != nil {
57+ m.fail(fmt.Errorf("resize to %dx%d: %w", w, h, err))
58+ return
59+ }
60+ m.sized, m.w, m.h = true, w, h
61+ m.label.clear()
62+ m.picker.SetWorkspace(m.workspace.ID, m.workspace.Region)
63+}
64+
65+// setWorkspaces keeps the workspace that is currently displayed on this
66+// output, and only that one. The picker acts instantly on a click or Enter, so
67+// offering a hidden workspace would highlight and capture something the user
68+// cannot see. Workspaces without an ID, on other outputs or not Active are
69+// never offered; with none, W does nothing.
70+func (m *model) setWorkspaces(ws []ports.Workspace) {
71+ m.workspace = ports.Workspace{}
72+ id := m.outputs[m.index].ID
73+ for _, w := range ws {
74+ if w.OutputID == id && w.ID != 0 && w.Active {
75+ m.workspace = w
76+ return
77+ }
78+ }
79+}
80+
81+// fail ends this overlay with a failure that names its output.
82+func (m *model) fail(err error) {
83+ if m.end == endNone {
84+ m.end = endCanceled
85+ m.sess.fail(fmt.Errorf("output %s: %w", m.outputs[m.index].Name, err))
86+ }
87+}
88+
89+// visible is everything the scene draws that input can change. Comparing two
90+// snapshots tells the framework whether a redraw is needed; it is a plain value
91+// and never allocates.
92+type visible struct {
93+ rect ports.Region
94+ dragging bool
95+ kind core.PickKind
96+ grid bool
97+ status core.PickStatus
98+}
99+
100+func (m *model) snapshot() visible {
101+ r, _ := m.picker.Rect()
102+ return visible{r, m.picker.Dragging(), m.picker.Kind(), m.picker.Grid(), m.picker.Status()}
103+}
104+
105+// input is the OnInput callback. It reports whether the drawn state changed,
106+// so pointer motion that moves nothing visible (hover, a clamped edge, axis
107+// events, other buttons, key releases and repeats) costs no redraw. Accepting
108+// or canceling reports true: the overlay is closing. It allocates only when the
109+// dimension label text changes.
110+func (m *model) input(ev nefergui.InputEvent) bool {
111+ if m.end != endNone || !m.sized {
112+ return false
113+ }
114+ before, endBefore := m.snapshot(), m.end
115+ switch ev.Kind {
116+ case nefergui.InputPointerMotion:
117+ m.picker.MouseMove(ev.X, ev.Y)
118+ case nefergui.InputPointerPress:
119+ if ev.Button == btnLeft {
120+ m.picker.MouseDown(ev.X, ev.Y)
121+ }
122+ case nefergui.InputPointerRelease:
123+ if ev.Button == btnLeft {
124+ m.picker.MouseUp(ev.X, ev.Y)
125+ }
126+ case nefergui.InputReset:
127+ // Input was dropped: a release may never arrive. Abandon any press or
128+ // drag, keeping the chosen kind, grid and workspace.
129+ m.picker.AbandonPress()
130+ case nefergui.InputKey:
131+ if ev.Pressed && !ev.Repeat && ev.Modifiers&nefergui.ModCtrl == 0 {
132+ m.key(ev.KeyName)
133+ }
134+ }
135+ m.settle()
136+ return m.end != endBefore || m.snapshot() != before
137+}
138+
139+func (m *model) key(name string) {
140+ switch name {
141+ case "Escape":
142+ m.picker.Cancel()
143+ case "m", "M":
144+ m.picker.SelectMonitor()
145+ case "w", "W":
146+ m.picker.SelectWorkspace() // a no-op until a real workspace is supplied
147+ case "r", "R":
148+ m.picker.SelectRegion()
149+ case "g", "G":
150+ m.picker.ToggleGrid()
151+ case "Return", "KP_Enter":
152+ m.picker.Confirm()
153+ default:
154+ // A digit picks that whole monitor from any overlay, without reopening.
155+ if i, ok := outputKey(name, len(m.outputs)); ok && !m.picker.Dragging() {
156+ m.end = endAccepted
157+ m.sess.accept(core.PickResult{OutputID: m.outputs[i].ID, Kind: core.PickMonitor})
158+ }
159+ }
160+}
161+
162+// outputKey maps "1".."9" to an output index below n.
163+func outputKey(name string, n int) (int, bool) {
164+ if len(name) != 1 || name[0] < '1' || name[0] > '9' {
165+ return 0, false
166+ }
167+ i := int(name[0] - '1')
168+ return i, i < n
169+}
170+
171+// settle mirrors the picker's terminal state and refreshes the label.
172+func (m *model) settle() {
173+ switch m.picker.Status() {
174+ case core.PickAccepted:
175+ r, _ := m.picker.Result()
176+ m.end = endAccepted
177+ m.sess.accept(r)
178+ return
179+ case core.PickCanceled:
180+ m.end = endCanceled
181+ m.sess.abort()
182+ return
183+ }
184+ if r, ok := m.picker.Rect(); ok {
185+ m.label.set(r.Width, r.Height)
186+ } else if m.picker.Kind() == core.PickMonitor {
187+ m.label.set(m.w, m.h)
188+ } else {
189+ m.label.clear()
190+ }
191+}
192+
193+// label caches the "W × H" text so it is rebuilt only when the size changes.
194+type label struct {
195+ w, h int
196+ text string
197+ buf []byte
198+}
199+
200+func (l *label) set(w, h int) {
201+ if l.text != "" && l.w == w && l.h == h {
202+ return
203+ }
204+ l.w, l.h = w, h
205+ b := strconv.AppendInt(l.buf[:0], int64(w), 10)
206+ b = append(b, " × "...)
207+ b = strconv.AppendInt(b, int64(h), 10)
208+ l.buf = b
209+ l.text = string(b)
210+}
211+
212+func (l *label) clear() { l.text = "" }
213+
214+// headerText names the output and, with several outputs, the digit keys that
215+// pick a whole monitor.
216+func headerText(outputs []ports.Output, index int) string {
217+ b := append([]byte(nil), outputs[index].Name...)
218+ if n := len(outputs); n > 1 {
219+ b = append(b, " · "...)
220+ b = strconv.AppendInt(b, int64(index+1), 10)
221+ b = append(b, '/')
222+ b = strconv.AppendInt(b, int64(n), 10)
223+ b = append(b, " · keys 1-"...)
224+ b = strconv.AppendInt(b, int64(n), 10)
225+ b = append(b, " pick monitor"...)
226+ }
227+ return string(b)
228+}
A internal/adapters/selection/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package selection
4+
5+const raceEnabled = false
A internal/adapters/selection/race_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package selection
4+
5+// The race detector adds allocations, so allocation budgets do not apply.
6+const raceEnabled = true
A internal/adapters/selection/scene.go +126 -0
@@ -0,0 +1,126 @@
1+package selection
2+
3+import (
4+ "github.com/bnema/nefercap/internal/core"
5+ "github.com/bnema/nefercap/internal/ports"
6+)
7+
8+// Fixed overlay element sizes in logical pixels.
9+const (
10+ labelW, labelH = 120.0, 20.0
11+ footerW, footerH = 400.0, 28.0
12+ headerH = 28.0
13+ edge = 1.0 // thin selection lines
14+ margin = 16.0
15+)
16+
17+// Footer hints; the first word names the mode.
18+const (
19+ footerShot = "shot · drag region · click monitor · Esc"
20+ footerRec = "rec · drag region · click monitor · Esc"
21+)
22+
23+func footerText(mode ports.Mode) string {
24+ if mode == ports.Record {
25+ return footerRec
26+ }
27+ return footerShot
28+}
29+
30+// frect is a float rectangle in surface-local logical pixels, the unit
31+// Node.Rect takes.
32+type frect struct{ x, y, w, h float64 }
33+
34+// scene is the complete geometry of one frame, derived from the picker. It is
35+// a plain value so computing it never allocates.
36+type scene struct {
37+ dim [4]frect // top, bottom, left, right of the selection; dim[0] is all when none
38+ edges [4]frect // top, bottom, left, right lines
39+ grid [4]frect // two vertical then two horizontal thirds lines
40+ label frect
41+ header frect
42+ footer frect
43+}
44+
45+// highlight returns the rectangle to outline and whether the rest is dimmed.
46+// A drag highlights the region and dims around it. The monitor kind outlines
47+// the whole output without dimming. Otherwise the whole output is dimmed.
48+func highlight(p *core.Picker) (r ports.Region, outline, dim bool) {
49+ w, h := p.Bounds()
50+ if r, ok := p.Rect(); ok {
51+ return r, true, true
52+ }
53+ switch p.Kind() {
54+ case core.PickMonitor:
55+ return ports.Region{Width: w, Height: h}, true, false
56+ case core.PickWorkspace:
57+ if _, r, ok := p.Workspace(); ok {
58+ return r, true, true
59+ }
60+ }
61+ return ports.Region{Width: w, Height: h}, false, true
62+}
63+
64+func (m *model) scene() scene {
65+ var s scene
66+ w, h := float64(m.w), float64(m.h)
67+ r, outline, dim := highlight(&m.picker)
68+ fr := frect{float64(r.X), float64(r.Y), float64(r.Width), float64(r.Height)}
69+ switch {
70+ case outline && dim:
71+ s.dim = [4]frect{
72+ {0, 0, w, fr.y},
73+ {0, fr.y + fr.h, w, h - fr.y - fr.h},
74+ {0, fr.y, fr.x, fr.h},
75+ {fr.x + fr.w, fr.y, w - fr.x - fr.w, fr.h},
76+ }
77+ case dim:
78+ s.dim[0] = frect{0, 0, w, h}
79+ }
80+ if outline {
81+ s.edges = [4]frect{
82+ {fr.x, fr.y, fr.w, edge},
83+ {fr.x, fr.y + fr.h - edge, fr.w, edge},
84+ {fr.x, fr.y, edge, fr.h},
85+ {fr.x + fr.w - edge, fr.y, edge, fr.h},
86+ }
87+ }
88+ if m.picker.Grid() {
89+ g := frect{0, 0, w, h}
90+ if outline {
91+ g = fr
92+ }
93+ s.grid = [4]frect{
94+ {g.x + g.w/3, g.y, edge, g.h},
95+ {g.x + 2*g.w/3, g.y, edge, g.h},
96+ {g.x, g.y + g.h/3, g.w, edge},
97+ {g.x, g.y + 2*g.h/3, g.w, edge},
98+ }
99+ }
100+ if m.label.text != "" && outline {
101+ s.label = labelRect(fr, w, h)
102+ }
103+ s.header = frect{margin, margin, headerWidth(m.header), headerH}
104+ s.footer = frect{(w - footerW) / 2, h - footerH - margin, footerW, footerH}
105+ if w < footerW+2*margin { // narrow surface: keep the hints on screen
106+ s.footer.x, s.footer.w = 0, w
107+ }
108+ return s
109+}
110+
111+// headerWidth is a monospace estimate of the header text plus padding.
112+func headerWidth(text string) float64 {
113+ return float64(len([]rune(text)))*8 + 20
114+}
115+
116+// labelRect places the size label above the region, or just inside it when
117+// there is no room above, and keeps it on the surface.
118+func labelRect(r frect, w, h float64) frect {
119+ x, y := r.x, r.y-labelH-4
120+ if y < 0 {
121+ x, y = r.x+4, r.y+4
122+ }
123+ x = min(max(x, 0), max(w-labelW, 0))
124+ y = min(max(y, 0), max(h-labelH, 0))
125+ return frect{x, y, labelW, labelH}
126+}
A internal/adapters/selection/selection_test.go +551 -0
@@ -0,0 +1,551 @@
1+package selection
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "testing"
8+
9+ "github.com/bnema/nefergui"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/nefercap/internal/core"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+var testOutputs = []ports.Output{
18+ {ID: 7, Name: "DP-1", Width: 1920, Height: 1080, Scale: 1},
19+ {ID: 9, Name: "HDMI-A-1", Width: 1280, Height: 720, Scale: 1},
20+}
21+
22+// testSession is a real session whose cancel is observable.
23+func testSession() (*session, context.Context) {
24+ ctx, cancel := context.WithCancel(context.Background())
25+ return newSession(cancel), ctx
26+}
27+
28+func newTestModel(t *testing.T) *model {
29+ t.Helper()
30+ sess, _ := testSession()
31+ m := newModel(ports.Screenshot, testOutputs, 0, sess)
32+ m.resize(1920, 1080, 1)
33+ require.Equal(t, core.PickActive, m.picker.Status())
34+ return m
35+}
36+
37+// newBareModel has no size yet, like an overlay before its first configure.
38+func newBareModel() (*model, context.Context) {
39+ sess, ctx := testSession()
40+ return newModel(ports.Screenshot, testOutputs, 0, sess), ctx
41+}
42+
43+func motion(x, y float64) nefergui.InputEvent {
44+ return nefergui.InputEvent{Kind: nefergui.InputPointerMotion, X: x, Y: y}
45+}
46+
47+func button(x, y float64, pressed bool) nefergui.InputEvent {
48+ k := nefergui.InputPointerRelease
49+ if pressed {
50+ k = nefergui.InputPointerPress
51+ }
52+ return nefergui.InputEvent{Kind: k, X: x, Y: y, Button: btnLeft, Pressed: pressed}
53+}
54+
55+func key(name string) nefergui.InputEvent {
56+ return nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: name, Pressed: true}
57+}
58+
59+func TestDragReleaseAcceptsRegion(t *testing.T) {
60+ m := newTestModel(t)
61+ m.input(button(100, 50, true))
62+ m.input(motion(300, 250))
63+ assert.Equal(t, "200 × 200", m.label.text)
64+ assert.Equal(t, endNone, m.end)
65+ m.input(button(300, 250, false))
66+ require.Equal(t, endAccepted, m.end)
67+ res, ok, err := m.sess.outcome(context.Background())
68+ require.NoError(t, err)
69+ require.True(t, ok)
70+ assert.Equal(t, core.PickRegion, res.Kind)
71+ assert.Equal(t, ports.Region{X: 100, Y: 50, Width: 200, Height: 200}, res.Region)
72+ assert.Equal(t, uint32(7), res.OutputID)
73+}
74+
75+func TestClickAcceptsMonitor(t *testing.T) {
76+ m := newTestModel(t)
77+ m.input(button(500, 500, true))
78+ m.input(button(500, 500, false))
79+ require.Equal(t, endAccepted, m.end)
80+ res, _, _ := m.sess.outcome(context.Background())
81+ assert.Equal(t, core.PickMonitor, res.Kind)
82+ sel := New(ports.Screenshot, ports.VideoSettings{}, nil).selection(res)
83+ assert.Equal(t, ports.Target{OutputID: 7}, sel.Target)
84+}
85+
86+func TestMonitorKeyThenEnter(t *testing.T) {
87+ m := newTestModel(t)
88+ m.input(key("m"))
89+ assert.Equal(t, "1920 × 1080", m.label.text)
90+ assert.Equal(t, endNone, m.end)
91+ m.input(key("Return"))
92+ require.Equal(t, endAccepted, m.end)
93+ res, _, _ := m.sess.outcome(context.Background())
94+ assert.Equal(t, core.PickMonitor, res.Kind)
95+}
96+
97+func TestEscapeAbortsEveryOverlay(t *testing.T) {
98+ sess, ctx := testSession()
99+ m := newModel(ports.Screenshot, testOutputs, 1, sess)
100+ m.resize(1280, 720, 1)
101+ m.input(key("Escape"))
102+ assert.Equal(t, endCanceled, m.end)
103+ assert.Error(t, ctx.Err(), "the shared context ends every overlay")
104+ _, ok, err := sess.outcome(context.Background())
105+ assert.False(t, ok)
106+ assert.NoError(t, err)
107+}
108+
109+func TestDigitPicksMonitorFromAnyOverlay(t *testing.T) {
110+ m := newTestModel(t) // overlay on output 1 (ID 7)
111+ m.input(key("3")) // no such output
112+ assert.Equal(t, endNone, m.end)
113+ m.input(key("2"))
114+ require.Equal(t, endAccepted, m.end)
115+ res, ok, err := m.sess.outcome(context.Background())
116+ require.NoError(t, err)
117+ require.True(t, ok)
118+ assert.Equal(t, core.PickResult{OutputID: 9, Kind: core.PickMonitor}, res)
119+ sel := New(ports.Screenshot, ports.VideoSettings{}, nil).selection(res)
120+ assert.Equal(t, ports.Target{OutputID: 9}, sel.Target)
121+
122+ own := newTestModel(t)
123+ own.input(key("1"))
124+ res, _, _ = own.sess.outcome(context.Background())
125+ assert.Equal(t, uint32(7), res.OutputID, "a digit may also pick the overlay's own monitor")
126+}
127+
128+func TestOutputKeyIgnoredWhileDragging(t *testing.T) {
129+ m := newTestModel(t)
130+ m.input(button(10, 10, true))
131+ m.input(motion(100, 100))
132+ m.input(key("2"))
133+ assert.Equal(t, endNone, m.end)
134+}
135+
136+func TestOtherButtonsAndReleasedKeysIgnored(t *testing.T) {
137+ m := newTestModel(t)
138+ ev := button(10, 10, true)
139+ ev.Button = 0x111
140+ m.input(ev)
141+ m.input(motion(200, 200))
142+ assert.False(t, m.picker.Dragging())
143+ up := key("Escape")
144+ up.Pressed = false
145+ m.input(up)
146+ rep := key("Escape")
147+ rep.Repeat = true
148+ m.input(rep)
149+ assert.Equal(t, endNone, m.end)
150+}
151+
152+func TestInputResetDropsDrag(t *testing.T) {
153+ m := newTestModel(t)
154+ m.input(button(10, 10, true))
155+ m.input(motion(200, 200))
156+ require.True(t, m.picker.Dragging())
157+ m.input(nefergui.InputEvent{Kind: nefergui.InputReset})
158+ assert.False(t, m.picker.Dragging())
159+ assert.Empty(t, m.label.text)
160+ assert.Equal(t, endNone, m.end)
161+ assert.Equal(t, core.PickActive, m.picker.Status())
162+}
163+
164+func TestInputResetBeforeThresholdAndKeepsChoices(t *testing.T) {
165+ ws := []ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}}
166+ m, _ := newBareModel()
167+ m.setWorkspaces(ws)
168+ m.resize(1920, 1080, 1)
169+ m.input(key("w"))
170+ m.input(key("g"))
171+ require.Equal(t, core.PickWorkspace, m.picker.Kind())
172+
173+ // A press that never reached the drag threshold is still abandoned, so a
174+ // missing release cannot turn the next release into a click-accept.
175+ m.input(button(500, 500, true))
176+ m.input(nefergui.InputEvent{Kind: nefergui.InputReset})
177+ m.input(button(500, 500, false)) // stray release: nothing is pressed
178+ assert.Equal(t, endNone, m.end)
179+
180+ assert.Equal(t, core.PickWorkspace, m.picker.Kind(), "kind survives a reset")
181+ assert.True(t, m.picker.Grid(), "grid survives a reset")
182+ id, _, ok := m.picker.Workspace()
183+ assert.True(t, ok)
184+ assert.Equal(t, uint64(11), id, "workspace survives a reset")
185+
186+ m.input(key("Return"))
187+ require.Equal(t, endAccepted, m.end)
188+ res, ok, err := m.sess.outcome(context.Background())
189+ require.NoError(t, err)
190+ require.True(t, ok)
191+ sel := New(ports.Screenshot, ports.VideoSettings{}, ws).selection(res)
192+ assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "a workspace has a stable ID and a zero Region")
193+}
194+
195+func TestResizeRestartsOnSizeChangeOnly(t *testing.T) {
196+ m := newTestModel(t)
197+ m.input(button(10, 10, true))
198+ m.input(motion(200, 200))
199+ m.resize(1920, 1080, 2) // scale only
200+ assert.True(t, m.picker.Dragging())
201+ m.resize(1280, 720, 2)
202+ assert.False(t, m.picker.Dragging())
203+ w, h := m.picker.Bounds()
204+ assert.Equal(t, [2]int{1280, 720}, [2]int{w, h})
205+}
206+
207+func TestResizeInvalidSizeFails(t *testing.T) {
208+ m, ctx := newBareModel()
209+ m.resize(0, 0, 1)
210+ assert.Equal(t, endCanceled, m.end)
211+ assert.Error(t, ctx.Err())
212+ _, ok, err := m.sess.outcome(context.Background())
213+ assert.False(t, ok)
214+ assert.ErrorIs(t, err, core.ErrInvalidBounds)
215+ assert.ErrorContains(t, err, "output DP-1", "the failing output is named")
216+ assert.ErrorContains(t, err, "resize to 0x0", "the rejected size is reported")
217+}
218+
219+func TestWorkspaceOnlyWhenRealAndOnThisOutput(t *testing.T) {
220+ ws := []ports.Workspace{
221+ {ID: 0, OutputID: 7, Active: true, Region: ports.Region{Width: 100, Height: 100}}, // no identity
222+ {ID: 5, OutputID: 9, Active: true, Region: ports.Region{Width: 100, Height: 100}}, // other output
223+ {ID: 6, OutputID: 7, Region: ports.Region{Width: 100, Height: 100}}, // not displayed
224+ {ID: 11, OutputID: 7, Active: true, Region: ports.Region{X: 0, Y: 30, Width: 1920, Height: 1050}},
225+ }
226+ none, _ := newBareModel()
227+ none.resize(1920, 1080, 1)
228+ none.input(key("w"))
229+ none.input(key("Return"))
230+ assert.Equal(t, endNone, none.end, "W without workspace metadata must do nothing")
231+
232+ m, _ := newBareModel()
233+ m.setWorkspaces(ws)
234+ m.resize(1920, 1080, 1)
235+ m.input(key("w"))
236+ m.input(key("Return"))
237+ require.Equal(t, endAccepted, m.end)
238+ res, _, _ := m.sess.outcome(context.Background())
239+ assert.Equal(t, core.PickWorkspace, res.Kind)
240+ assert.Equal(t, uint64(11), res.WorkspaceID)
241+ sel := New(ports.Screenshot, ports.VideoSettings{}, ws).selection(res)
242+ assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11}, sel.Target, "workspace geometry is live from the compositor, not a cached crop")
243+}
244+
245+func TestOnlyTheActiveWorkspaceIsOffered(t *testing.T) {
246+ m, _ := newBareModel()
247+ m.setWorkspaces([]ports.Workspace{
248+ {ID: 1, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}},
249+ {ID: 2, OutputID: 7, Region: ports.Region{X: 960, Width: 960, Height: 1080}, Active: true},
250+ {ID: 3, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}},
251+ })
252+ m.resize(1920, 1080, 1)
253+ id, _, ok := m.picker.Workspace()
254+ require.True(t, ok)
255+ assert.Equal(t, uint64(2), id)
256+
257+ hidden, _ := newBareModel()
258+ hidden.setWorkspaces([]ports.Workspace{{ID: 1, OutputID: 7, Region: ports.Region{Width: 960, Height: 1080}}})
259+ hidden.resize(1920, 1080, 1)
260+ _, _, ok = hidden.picker.Workspace()
261+ assert.False(t, ok, "a workspace that is not displayed is never offered")
262+ hidden.input(key("w"))
263+ hidden.input(key("Return"))
264+ assert.Equal(t, endNone, hidden.end)
265+}
266+
267+func TestSelectionConversionUsesCoreTarget(t *testing.T) {
268+ s := New(ports.Screenshot, ports.VideoSettings{}, nil)
269+ region := ports.Region{X: 10, Y: 20, Width: 300, Height: 200}
270+ assert.Equal(t, ports.Target{OutputID: 7, Region: region},
271+ s.selection(core.PickResult{OutputID: 7, Kind: core.PickRegion, Region: region}).Target, "a dragged region keeps its geometry")
272+ assert.Equal(t, ports.Target{OutputID: 7},
273+ s.selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor, Region: ports.Region{Width: 1920, Height: 1080}}).Target)
274+ assert.Equal(t, ports.Target{OutputID: 7, WorkspaceID: 11},
275+ s.selection(core.PickResult{OutputID: 7, Kind: core.PickWorkspace, Region: region, WorkspaceID: 11}).Target)
276+}
277+
278+func TestNewSelectionDefaults(t *testing.T) {
279+ sel := New(ports.Record, ports.VideoSettings{}, nil).selection(core.PickResult{OutputID: 7, Kind: core.PickMonitor})
280+ assert.Equal(t, ports.Record, sel.Mode)
281+ assert.Equal(t, DefaultFPS, sel.Video.FPS)
282+ assert.Empty(t, sel.Path)
283+
284+ custom := ports.VideoSettings{FPS: 60, Width: 640, Height: 360}
285+ assert.Equal(t, custom, New(ports.Record, custom, nil).selection(core.PickResult{}).Video)
286+ assert.Equal(t, ports.VideoSettings{}, New(ports.Screenshot, custom, nil).selection(core.PickResult{}).Video)
287+}
288+
289+func TestSelectRejectsTooManyOutputsWithoutOpeningAnything(t *testing.T) {
290+ outs := make([]ports.Output, MaxOutputs+1)
291+ for i := range outs {
292+ outs[i] = ports.Output{ID: uint32(i + 1), Name: "O"}
293+ }
294+ _, ok, err := New(ports.Screenshot, ports.VideoSettings{}, nil).Select(context.Background(), outs)
295+ assert.False(t, ok)
296+ assert.ErrorIs(t, err, ErrTooManyOutputs)
297+}
298+
299+func TestSelectRejectsBadInput(t *testing.T) {
300+ s := New(ports.Screenshot, ports.VideoSettings{}, nil)
301+ _, ok, err := s.Select(context.Background(), nil)
302+ assert.False(t, ok)
303+ assert.ErrorIs(t, err, ports.ErrOutputNotFound)
304+
305+ ctx, cancel := context.WithCancel(context.Background())
306+ cancel()
307+ _, ok, err = s.Select(ctx, testOutputs)
308+ assert.False(t, ok)
309+ assert.ErrorIs(t, err, context.Canceled)
310+}
311+
312+func TestSessionFirstDecisionWins(t *testing.T) {
313+ sess, ctx := testSession()
314+ first := core.PickResult{OutputID: 7, Kind: core.PickMonitor}
315+ sess.accept(first)
316+ sess.accept(core.PickResult{OutputID: 9, Kind: core.PickMonitor})
317+ sess.abort()
318+ assert.Error(t, ctx.Err())
319+ res, ok, err := sess.outcome(context.Background())
320+ require.NoError(t, err)
321+ assert.True(t, ok)
322+ assert.Equal(t, first, res)
323+}
324+
325+func TestSessionRunEnded(t *testing.T) {
326+ boom := errors.New("boom")
327+
328+ sess, _ := testSession()
329+ sess.accept(core.PickResult{OutputID: 7})
330+ sess.runEnded(context.Background(), "DP-1", context.Canceled) // our own cancel
331+ _, ok, err := sess.outcome(context.Background())
332+ assert.True(t, ok)
333+ assert.NoError(t, err)
334+
335+ sess, _ = testSession()
336+ sess.runEnded(context.Background(), "DP-1", errors.Join(context.Canceled, boom))
337+ _, ok, err = sess.outcome(context.Background())
338+ assert.False(t, ok)
339+ assert.ErrorIs(t, err, boom)
340+
341+ // GO012: a real failure joined with Canceled survives whole, including the
342+ // output name, after our own cancel.
343+ sess, _ = testSession()
344+ sess.accept(core.PickResult{OutputID: 7})
345+ real := fmt.Errorf("surface close: %w", context.Canceled)
346+ sess.runEnded(context.Background(), "DP-2", errors.Join(context.Canceled, real, boom))
347+ _, ok, err = sess.outcome(context.Background())
348+ assert.False(t, ok, "a real teardown failure rejects even an accepted pick")
349+ require.ErrorIs(t, err, boom)
350+ assert.ErrorContains(t, err, "output DP-2")
351+ assert.ErrorContains(t, err, "surface close", "a wrapped Canceled is a real failure, not hidden")
352+
353+ sess, _ = testSession()
354+ sess.runEnded(context.Background(), "DP-1", nil) // closed without any decision
355+ _, _, err = sess.outcome(context.Background())
356+ assert.ErrorIs(t, err, errOverlayClosed)
357+ assert.ErrorContains(t, err, "output DP-1")
358+
359+ sess, _ = testSession()
360+ sess.accept(core.PickResult{OutputID: 7})
361+ sess.runEnded(context.Background(), "DP-1", boom) // a later overlay failing rejects the pick
362+ _, ok, err = sess.outcome(context.Background())
363+ assert.False(t, ok || err == nil)
364+
365+ parent, cancel := context.WithCancel(context.Background())
366+ cancel()
367+ sess, _ = testSession()
368+ sess.runEnded(parent, "DP-1", context.Canceled)
369+ _, ok, err = sess.outcome(parent)
370+ assert.False(t, ok)
371+ assert.ErrorIs(t, err, context.Canceled)
372+ sess, _ = testSession()
373+ sess.runEnded(parent, "DP-1", boom)
374+ _, _, err = sess.outcome(parent)
375+ assert.ErrorIs(t, err, context.Canceled)
376+ assert.ErrorIs(t, err, boom)
377+}
378+
379+func TestLayerConfig(t *testing.T) {
380+ c := layerConfig("DP-1", nefergui.KeyboardExclusive)
381+ assert.Equal(t, "DP-1", c.Output)
382+ assert.Equal(t, nefergui.LayerOverlay, c.Level)
383+ assert.Equal(t, nefergui.AnchorTop|nefergui.AnchorBottom|nefergui.AnchorLeft|nefergui.AnchorRight, c.Anchors)
384+ assert.Equal(t, nefergui.KeyboardExclusive, c.Keyboard)
385+ assert.Equal(t, int32(-1), c.ExclusiveZone)
386+ assert.Nil(t, c.InputRects, "whole surface takes input")
387+ assert.Equal(t, nefergui.KeyboardOnDemand, layerConfig("DP-2", nefergui.KeyboardOnDemand).Keyboard)
388+}
389+
390+func TestSceneGeometry(t *testing.T) {
391+ m := newTestModel(t)
392+ s := m.scene()
393+ assert.Equal(t, frect{0, 0, 1920, 1080}, s.dim[0], "idle: whole output dimmed")
394+ assert.Equal(t, frect{}, s.edges[0])
395+ assert.Equal(t, frect{}, s.label)
396+
397+ m.input(button(100, 50, true))
398+ m.input(motion(300, 250))
399+ s = m.scene()
400+ assert.Equal(t, [4]frect{
401+ {0, 0, 1920, 50}, {0, 250, 1920, 830}, {0, 50, 100, 200}, {300, 50, 1620, 200},
402+ }, s.dim)
403+ assert.Equal(t, frect{100, 50, 200, 1}, s.edges[0])
404+ assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
405+ assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
406+ assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
407+ assert.Equal(t, frect{100, 26, labelW, labelH}, s.label)
408+ // Dim strips and region tile the output exactly.
409+ area := 200.0 * 200.0
410+ for _, d := range s.dim {
411+ area += d.w * d.h
412+ }
413+ assert.Equal(t, 1920.0*1080.0, area)
414+}
415+
416+func TestSceneMonitorOutlineAndGrid(t *testing.T) {
417+ m := newTestModel(t)
418+ m.input(key("m"))
419+ m.input(key("g"))
420+ s := m.scene()
421+ assert.Equal(t, [4]frect{}, s.dim, "monitor choice is not dimmed")
422+ assert.Equal(t, frect{0, 0, 1920, 1}, s.edges[0])
423+ assert.Equal(t, frect{640, 0, 1, 1080}, s.grid[0])
424+ assert.Equal(t, frect{0, 720, 1920, 1}, s.grid[3])
425+}
426+
427+func TestLabelPlacementStaysOnSurface(t *testing.T) {
428+ assert.Equal(t, frect{1800, 986, labelW, labelH}, labelRect(frect{1800, 1010, 100, 50}, 1920, 1080))
429+ assert.Equal(t, frect{4, 4, labelW, labelH}, labelRect(frect{0, 0, 500, 500}, 1920, 1080))
430+}
431+
432+func TestFooterAndHeaderText(t *testing.T) {
433+ assert.Equal(t, "shot · drag region · click monitor · Esc", footerText(ports.Screenshot))
434+ assert.Equal(t, "rec · drag region · click monitor · Esc", footerText(ports.Record))
435+ assert.Equal(t, "DP-1 · 1/2 · keys 1-2 pick monitor", headerText(testOutputs, 0))
436+ assert.Equal(t, "DP-1", headerText(testOutputs[:1], 0))
437+ i, ok := outputKey("2", 2)
438+ assert.True(t, ok)
439+ assert.Equal(t, 1, i)
440+ _, ok = outputKey("0", 2)
441+ assert.False(t, ok)
442+ _, ok = outputKey("12", 2)
443+ assert.False(t, ok)
444+}
445+
446+func TestSelectorAllocations(t *testing.T) {
447+ if raceEnabled {
448+ t.Skip("the race detector adds allocations")
449+ }
450+ m := newTestModel(t)
451+ m.input(button(100, 50, true))
452+ same := []nefergui.InputEvent{motion(300, 250), motion(300, 250), motion(300, 250)}
453+ for _, ev := range same { // warm the label buffer
454+ m.input(ev)
455+ }
456+ var s scene
457+ n := testing.AllocsPerRun(200, func() {
458+ for _, ev := range same {
459+ m.input(ev)
460+ }
461+ s = m.scene()
462+ })
463+ assert.Equal(t, 0.0, n, "unchanged pointer position and scene geometry allocate nothing")
464+
465+ // A changed size builds exactly one label string and nothing else.
466+ i := 0
467+ n = testing.AllocsPerRun(200, func() {
468+ i++
469+ m.input(motion(float64(300+i%2*7), 250))
470+ s = m.scene()
471+ })
472+ assert.LessOrEqual(t, n, 1.0, "a changed size allocates only its label text")
473+ _ = s
474+}
475+
476+func BenchmarkMotion(b *testing.B) {
477+ m, _ := newBareModel()
478+ m.resize(1920, 1080, 1)
479+ m.input(button(100, 50, true))
480+ b.ReportAllocs()
481+ for i := 0; b.Loop(); i++ {
482+ m.input(motion(float64(200+i%300), 250))
483+ _ = m.scene()
484+ }
485+}
486+
487+func TestIdleSceneEmitsNoEmptyChip(t *testing.T) {
488+ m := newTestModel(t)
489+ s := m.scene()
490+ assert.Equal(t, frect{}, s.label, "no label box without a size")
491+ assert.Zero(t, s.label.w)
492+ assert.Equal(t, frect{}, s.edges[0])
493+ assert.Greater(t, s.header.w, 0.0)
494+ assert.Greater(t, s.footer.w, 0.0)
495+}
496+
497+func TestInputReportsOnlyVisibleChanges(t *testing.T) {
498+ m := newTestModel(t)
499+ assert.False(t, m.input(motion(50, 50)), "hover draws nothing")
500+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputPointerAxis, DX: 1, DY: 2}), "axis is ignored")
501+ other := button(50, 50, true)
502+ other.Button = 0x111
503+ assert.False(t, m.input(other), "other buttons are ignored")
504+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputFocusIn}))
505+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputPointerLeave}))
506+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "g", Pressed: false}), "release")
507+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "g", Pressed: true, Repeat: true}), "repeat")
508+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "q", Pressed: true}), "unknown key")
509+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputKey, KeyName: "w", Pressed: true}), "W without a workspace")
510+
511+ assert.False(t, m.input(button(100, 50, true)), "a press alone changes nothing visible")
512+ assert.False(t, m.input(motion(102, 51)), "below the drag threshold")
513+ assert.True(t, m.input(motion(300, 250)), "the drag appears")
514+ assert.False(t, m.input(motion(300, 250)), "same position")
515+ assert.False(t, m.input(motion(300, 250)))
516+ assert.True(t, m.input(motion(300, 251)), "the rectangle grew")
517+ assert.True(t, m.input(nefergui.InputEvent{Kind: nefergui.InputReset}), "a drag preview is dropped")
518+ assert.False(t, m.input(nefergui.InputEvent{Kind: nefergui.InputReset}), "nothing left to drop")
519+
520+ assert.True(t, m.input(key("g")), "grid toggles")
521+ assert.True(t, m.input(key("m")), "monitor outline")
522+ assert.False(t, m.input(key("m")), "already chosen")
523+ assert.True(t, m.input(key("r")))
524+ assert.False(t, m.input(key("r")))
525+}
526+
527+func TestInputClampedEdgeMotionIsNotVisible(t *testing.T) {
528+ m := newTestModel(t)
529+ m.input(button(100, 100, true))
530+ require.True(t, m.input(motion(1920, 200)))
531+ assert.False(t, m.input(motion(2500, 200)), "the clamped rectangle did not change")
532+ assert.False(t, m.input(motion(3000, 200)))
533+}
534+
535+func TestInputReportsClosing(t *testing.T) {
536+ m := newTestModel(t)
537+ assert.True(t, m.input(key("Escape")), "canceling closes the overlay")
538+ assert.False(t, m.input(key("g")), "a closed overlay ignores input")
539+
540+ m = newTestModel(t)
541+ assert.True(t, m.input(key("2")), "a digit accepts")
542+
543+ m = newTestModel(t)
544+ m.input(button(10, 10, true))
545+ assert.True(t, m.input(button(10, 10, false)), "a click accepts the monitor")
546+
547+ m = newTestModel(t)
548+ m.input(button(10, 10, true))
549+ m.input(motion(200, 200))
550+ assert.True(t, m.input(button(200, 200, false)), "a drag release accepts the region")
551+}
A internal/adapters/selection/selector.go +159 -0
@@ -0,0 +1,159 @@
1+// Package selection is the NeferGUI layer-shell capture selector.
2+//
3+// Select opens one transparent overlay layer surface on every output. Each
4+// dims everything except the dragged region, draws thin accent lines and a
5+// size label, and the first decision from any of them is returned. It needs no
6+// screenshot: the live compositor shows through the transparent surface.
7+//
8+// One overlay per output: every monitor is selectable at once. A Wayland
9+// client cannot hold exclusive keyboard focus on several surfaces, so the first
10+// output's overlay is keyboard-exclusive and the others take the keyboard on
11+// demand (on click). Each overlay is a separate NeferGUI window with its own
12+// model on its own owner loop; they share only a first-decision-wins session.
13+package selection
14+
15+import (
16+ "context"
17+ _ "embed"
18+ "errors"
19+ "fmt"
20+ "sync"
21+
22+ "github.com/bnema/nefergui"
23+
24+ "github.com/bnema/nefercap/internal/adapters/uistyle"
25+ "github.com/bnema/nefercap/internal/core"
26+ "github.com/bnema/nefercap/internal/ports"
27+)
28+
29+//go:embed style.css
30+var styleSheet string
31+
32+// DefaultFPS is the recording frame rate assigned to record selections.
33+const DefaultFPS = 30
34+
35+// namespace is an opaque role hint for the compositor, not an authorization.
36+const namespace = "nefercap-selector"
37+
38+// MaxOutputs is the most outputs one selection covers: one per digit key.
39+const MaxOutputs = 9
40+
41+// ErrTooManyOutputs reports more outputs than MaxOutputs. Nothing is opened.
42+var ErrTooManyOutputs = errors.New("selection: too many outputs")
43+
44+var errNoOutputs = fmt.Errorf("selection: no outputs to select: %w", ports.ErrOutputNotFound)
45+
46+// Selector shows the layer-shell selection overlay.
47+type Selector struct {
48+ mode ports.Mode
49+ video ports.VideoSettings
50+ workspaces []ports.Workspace
51+}
52+
53+var _ ports.Selector = (*Selector)(nil)
54+
55+// New returns a selector for mode. Record selections carry video; a zero FPS
56+// becomes DefaultFPS. workspaces is native compositor metadata: the W key is
57+// offered only for the Active workspace with a valid (nonzero) ID on the output
58+// an overlay covers. Hidden workspaces are never offered and nothing is
59+// invented when no workspace qualifies. The caller assigns
60+// Selection.Path. The first output passed to Select opens first, so the caller
61+// orders outputs to put the preferred one first.
62+func New(mode ports.Mode, video ports.VideoSettings, workspaces []ports.Workspace) *Selector {
63+ if mode == ports.Record && video.FPS == 0 {
64+ video.FPS = DefaultFPS
65+ }
66+ if mode != ports.Record {
67+ video = ports.VideoSettings{}
68+ }
69+ return &Selector{mode: mode, video: video, workspaces: append([]ports.Workspace(nil), workspaces...)}
70+}
71+
72+// Select blocks until the user accepts a selection (true), aborts with Escape
73+// (false, nil) or ctx ends or the window system fails (error). One overlay
74+// opens on every output at once, so the whole desktop is selectable without
75+// reopening. The first decision from any overlay wins and ends all of them:
76+// Select returns only after every overlay's Wayland session has closed, so an
77+// accepted selection never races a window that is still mapped. The compositor
78+// may still be processing that teardown, so a caller must confirm through a
79+// fresh capture connection before capturing. Select creates no capture path or
80+// directory and fails with ErrTooManyOutputs rather than dropping outputs. One
81+// goroutine at a time.
82+//
83+// Keyboard: outputs[0] takes exclusive keyboard focus; the other overlays take
84+// it on demand, which compositors grant when the pointer clicks them. Digits
85+// 1..9 pick that whole monitor from whichever overlay has the keyboard.
86+func (s *Selector) Select(ctx context.Context, outputs []ports.Output) (ports.Selection, bool, error) {
87+ if err := ctx.Err(); err != nil {
88+ return ports.Selection{}, false, err
89+ }
90+ if len(outputs) == 0 {
91+ return ports.Selection{}, false, errNoOutputs
92+ }
93+ if len(outputs) > MaxOutputs {
94+ return ports.Selection{}, false, fmt.Errorf("%w: %d outputs, at most %d", ErrTooManyOutputs, len(outputs), MaxOutputs)
95+ }
96+ sheet, remove, err := uistyle.Write(styleSheet, "nefercap-selector-*.css")
97+ if err != nil {
98+ return ports.Selection{}, false, fmt.Errorf("selection: %w", err)
99+ }
100+ defer remove()
101+
102+ child, cancel := context.WithCancel(ctx)
103+ defer cancel()
104+ sess := newSession(cancel)
105+ var wg sync.WaitGroup
106+ for i := range outputs {
107+ m := newModel(s.mode, outputs, i, sess)
108+ m.setWorkspaces(s.workspaces)
109+ wg.Add(1)
110+ go func() {
111+ defer wg.Done()
112+ sess.runEnded(ctx, outputs[i].Name, run(child, sheet, m))
113+ }()
114+ }
115+ wg.Wait()
116+ result, ok, err := sess.outcome(ctx)
117+ if err != nil || !ok {
118+ return ports.Selection{}, false, err
119+ }
120+ return s.selection(result), true, nil
121+}
122+
123+// run shows one overlay on its own output until the context ends.
124+func run(ctx context.Context, sheet string, m *model) error {
125+ kb := nefergui.KeyboardOnDemand
126+ if m.index == 0 {
127+ kb = nefergui.KeyboardExclusive
128+ }
129+ return nefergui.Run(ctx, m, view,
130+ nefergui.Title("NeferCap selector"),
131+ nefergui.Size(1, 1),
132+ nefergui.Styles(sheet),
133+ nefergui.Transparent(),
134+ nefergui.Layer(layerConfig(m.outputs[m.index].Name, kb)),
135+ nefergui.OnResize(m.resize),
136+ nefergui.OnInput(m.input),
137+ )
138+}
139+
140+// layerConfig is one overlay: every edge, above windows, the given keyboard
141+// mode, and an exclusive zone of -1 so no bar shifts it.
142+func layerConfig(output string, keyboard nefergui.KeyboardMode) nefergui.LayerConfig {
143+ return nefergui.LayerConfig{
144+ Output: output,
145+ Namespace: namespace,
146+ Level: nefergui.LayerOverlay,
147+ Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,
148+ Keyboard: keyboard,
149+ ExclusiveZone: -1,
150+ }
151+}
152+
153+// selection converts an accepted pick to a port selection through the core
154+// rules: a monitor and a workspace carry a zero Region (a workspace's geometry
155+// comes live from the compositor, identified by WorkspaceID), a dragged region
156+// keeps its explicit output-local geometry.
157+func (s *Selector) selection(r core.PickResult) ports.Selection {
158+ return ports.Selection{Mode: s.mode, Target: r.Target(), Video: s.video}
159+}
A internal/adapters/selection/session.go +89 -0
@@ -0,0 +1,89 @@
1+package selection
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/nefercap/internal/adapters/uierrors"
10+ "github.com/bnema/nefercap/internal/core"
11+)
12+
13+var errOverlayClosed = errors.New("selection: overlay closed by the compositor")
14+
15+// session is the only state the per-output overlays share. Each overlay owns
16+// its model and window on its own goroutine; they talk to each other only
17+// through this first-decision-wins record and the shared cancel, never through
18+// window or picker state.
19+type session struct {
20+ cancel context.CancelFunc
21+
22+ mu sync.Mutex
23+ decided bool
24+ accepted bool
25+ result core.PickResult
26+ errs []error
27+}
28+
29+func newSession(cancel context.CancelFunc) *session { return &session{cancel: cancel} }
30+
31+// accept records the first decision and ends every overlay.
32+func (s *session) accept(r core.PickResult) {
33+ s.mu.Lock()
34+ if !s.decided {
35+ s.decided, s.accepted, s.result = true, true, r
36+ }
37+ s.mu.Unlock()
38+ s.cancel()
39+}
40+
41+// abort records the first decision as "no selection" and ends every overlay.
42+func (s *session) abort() {
43+ s.mu.Lock()
44+ s.decided = true
45+ s.mu.Unlock()
46+ s.cancel()
47+}
48+
49+// fail records a real error, which rejects the selection, and ends every overlay.
50+func (s *session) fail(err error) {
51+ s.mu.Lock()
52+ s.decided = true
53+ s.errs = append(s.errs, err)
54+ s.mu.Unlock()
55+ s.cancel()
56+}
57+
58+func (s *session) isDecided() bool {
59+ s.mu.Lock()
60+ defer s.mu.Unlock()
61+ return s.decided
62+}
63+
64+// runEnded classifies one overlay's Run result with uierrors.Ended. The state
65+// it depends on (decided, parent error) is read once, so a context that ends
66+// meanwhile cannot flip the answer. An overlay that ended with no decision and
67+// a live parent was removed by the compositor, which is a failure, and any real
68+// error reported with it is kept. Failures name the output.
69+func (s *session) runEnded(parent context.Context, output string, runErr error) {
70+ perr := parent.Err()
71+ explained := perr != nil || s.isDecided()
72+ if err := uierrors.Ended(runErr, explained, perr, errOverlayClosed); err != nil {
73+ s.fail(fmt.Errorf("selection: output %s: %w", output, err))
74+ }
75+}
76+
77+// outcome is read after every overlay has returned. The caller's context wins;
78+// then real errors; then the decision.
79+func (s *session) outcome(parent context.Context) (core.PickResult, bool, error) {
80+ s.mu.Lock()
81+ defer s.mu.Unlock()
82+ if perr := parent.Err(); perr != nil {
83+ return core.PickResult{}, false, errors.Join(append([]error{perr}, s.errs...)...)
84+ }
85+ if len(s.errs) > 0 {
86+ return core.PickResult{}, false, errors.Join(s.errs...)
87+ }
88+ return s.result, s.accepted, nil
89+}
A internal/adapters/selection/smoke_test.go +88 -0
@@ -0,0 +1,88 @@
1+package selection
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "strconv"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+// TestSmokeSelect opens the real overlays. It needs a Wayland compositor with
20+// layer-shell and Vulkan plus an input driver, so it runs only when
21+// NEFERCAP_SELECTION_SMOKE names the expected result: "region" (on the first
22+// output), "monitor" (whole monitor of output number NEFERCAP_SELECTION_WANT,
23+// default 1) or "abort". NEFERCAP_SELECTION_OUTPUTS lists output names,
24+// comma-separated; output IDs are their 1-based positions.
25+func TestSmokeSelect(t *testing.T) {
26+ want := os.Getenv("NEFERCAP_SELECTION_SMOKE")
27+ if want == "" {
28+ t.Skip("set NEFERCAP_SELECTION_SMOKE=region|monitor|abort on a Wayland session")
29+ }
30+ var outs []ports.Output
31+ for i, name := range strings.Split(os.Getenv("NEFERCAP_SELECTION_OUTPUTS"), ",") {
32+ outs = append(outs, ports.Output{ID: uint32(i + 1), Name: name})
33+ }
34+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
35+ defer cancel()
36+ peak := samplePeakRSS(ctx)
37+ sel, ok, err := New(ports.Record, ports.VideoSettings{}, nil).Select(ctx, outs)
38+ cancel()
39+ fmt.Printf("SMOKE outputs=%d peak_rss_kib=%d\n", len(outs), <-peak)
40+ require.NoError(t, err)
41+ switch want {
42+ case "abort":
43+ assert.False(t, ok)
44+ case "monitor":
45+ n, _ := strconv.Atoi(os.Getenv("NEFERCAP_SELECTION_WANT"))
46+ n = max(n, 1)
47+ require.True(t, ok)
48+ assert.Equal(t, ports.Target{OutputID: uint32(n)}, sel.Target)
49+ assert.Equal(t, DefaultFPS, sel.Video.FPS)
50+ case "region":
51+ require.True(t, ok)
52+ assert.Equal(t, ports.Target{OutputID: 1, Region: ports.Region{X: 100, Y: 80, Width: 200, Height: 120}}, sel.Target)
53+ }
54+}
55+
56+// samplePeakRSS reports the highest VmRSS of this process seen until ctx ends.
57+func samplePeakRSS(ctx context.Context) <-chan int {
58+ out := make(chan int, 1)
59+ go func() {
60+ peak := 0
61+ for t := time.NewTicker(50 * time.Millisecond); ; {
62+ select {
63+ case <-ctx.Done():
64+ out <- peak
65+ return
66+ case <-t.C:
67+ peak = max(peak, vmRSS())
68+ }
69+ }
70+ }()
71+ return out
72+}
73+
74+func vmRSS() int {
75+ f, err := os.Open("/proc/self/status")
76+ if err != nil {
77+ return 0
78+ }
79+ defer f.Close()
80+ sc := bufio.NewScanner(f)
81+ for sc.Scan() {
82+ if v, ok := strings.CutPrefix(sc.Text(), "VmRSS:"); ok {
83+ n, _ := strconv.Atoi(strings.Fields(v)[0])
84+ return n
85+ }
86+ }
87+ return 0
88+}
A internal/adapters/selection/style.css +14 -0
@@ -0,0 +1,14 @@
1+/* Transparent selector overlay: dimming, thin accent lines, monospace tags. */
2+app {
3+ width: 100%; height: 100%; background-color: transparent;
4+ color: #e6edf3; font-family: monospace; font-size: 13px;
5+}
6+stack.scene { width: 100%; height: 100%; }
7+box.dim { background-color: rgba(0, 0, 0, 0.5); }
8+box.edge { background-color: #4fd1a5; }
9+box.grid { background-color: rgba(79, 209, 165, 0.45); }
10+box.tag {
11+ box-sizing: border-box; padding: 4px 10px; overflow: hidden;
12+ background-color: rgba(13, 16, 20, 0.92); color: #e6edf3;
13+ font-family: monospace; font-size: 13px; text-align: center;
14+}
A internal/adapters/selection/view.go +58 -0
@@ -0,0 +1,58 @@
1+package selection
2+
3+import (
4+ "github.com/bnema/nefergui"
5+)
6+
7+// view draws the scene. Geometry goes through Node.Rect, so no CSS strings
8+// are built per frame; classes come from the embedded stylesheet. Boxes with no
9+// area are not emitted at all, so an idle surface is one dim box plus the
10+// header and footer, and no empty chip is painted.
11+func view(f *nefergui.Frame, m *model) {
12+ root := f.Root(nefergui.Class("root"))
13+ st := root.Stack(nefergui.Class("scene"))
14+ s := m.scene()
15+ for i, r := range s.dim {
16+ box(st, dimKeys[i], "dim", r)
17+ }
18+ for i, r := range s.grid {
19+ box(st, gridKeys[i], "grid", r)
20+ }
21+ for i, r := range s.edges {
22+ box(st, edgeKeys[i], "edge", r)
23+ }
24+ if n := tag(st, "label", s.label); n.ok {
25+ n.node.Text(m.label.text)
26+ }
27+ if n := tag(st, "header", s.header); n.ok {
28+ n.node.Text(m.header)
29+ }
30+ if n := tag(st, "footer", s.footer); n.ok {
31+ n.node.Text(m.footerText)
32+ }
33+}
34+
35+type tagged struct {
36+ node nefergui.Node
37+ ok bool
38+}
39+
40+func box(st nefergui.Node, key, class string, r frect) {
41+ if r.w > 0 && r.h > 0 {
42+ st.Box(nefergui.Key(key), nefergui.Class(class)).Rect(r.x, r.y, r.w, r.h)
43+ }
44+}
45+
46+func tag(st nefergui.Node, key string, r frect) tagged {
47+ if r.w <= 0 || r.h <= 0 {
48+ return tagged{}
49+ }
50+ return tagged{st.Box(nefergui.Key(key), nefergui.Class("tag")).Rect(r.x, r.y, r.w, r.h), true}
51+}
52+
53+// Static keys avoid building identity strings per frame.
54+var (
55+ dimKeys = [4]string{"dim0", "dim1", "dim2", "dim3"}
56+ gridKeys = [4]string{"grid0", "grid1", "grid2", "grid3"}
57+ edgeKeys = [4]string{"edge0", "edge1", "edge2", "edge3"}
58+)
A internal/adapters/uierrors/uierrors.go +92 -0
@@ -0,0 +1,92 @@
1+// Package uierrors holds the error helper shared by the NeferGUI adapters.
2+package uierrors
3+
4+import (
5+ "context"
6+ "errors"
7+)
8+
9+// Drop removes from err, and recursively from errors.Join trees, every leaf
10+// that is exactly one of targets. Only bare leaves match: a real failure that
11+// merely wraps a target (fmt.Errorf("x: %w", context.Canceled)) is kept, and so
12+// is every sibling of a dropped leaf. It returns nil when nothing else remains.
13+//
14+// Use it to discard the context.Canceled a NeferGUI Run reports after the
15+// adapter itself ended it, without hiding a real error joined to it.
16+func Drop(err error, targets ...error) error {
17+ if err == nil {
18+ return nil
19+ }
20+ if j, ok := err.(interface{ Unwrap() []error }); ok {
21+ var keep []error
22+ for _, e := range j.Unwrap() {
23+ if e = Drop(e, targets...); e != nil {
24+ keep = append(keep, e)
25+ }
26+ }
27+ switch len(keep) {
28+ case 0:
29+ return nil
30+ case 1:
31+ return keep[0]
32+ }
33+ return errors.Join(keep...)
34+ }
35+ for _, t := range targets {
36+ if err == t {
37+ return nil
38+ }
39+ }
40+ return err
41+}
42+
43+// Ended classifies how a NeferGUI Run ended. It is pure: the caller snapshots
44+// its state once and passes it, so a context that ends between two reads cannot
45+// flip the answer.
46+//
47+// explained says that this adapter or its caller ended the run on purpose (a
48+// decision was made, or the parent context is done). parent is the parent
49+// context's error at that snapshot, nil when it is still live. closed is
50+// returned when the run ended without an explanation.
51+//
52+// - explained: a bare context.Canceled (and bare parent) is the expected echo
53+// of our own cancellation and is dropped. Anything else, including a real
54+// error joined with it or an error that merely wraps Canceled, is kept.
55+// - not explained: the run ended on its own. When something real remains
56+// (an initial authorization, capability or font failure, say), exactly that
57+// is returned: calling it a compositor close would mislead. Only when
58+// nothing real remains, including a bare Canceled, is closed returned.
59+func Ended(runErr error, explained bool, parent, closed error) error {
60+ if explained {
61+ return Drop(runErr, context.Canceled, parent)
62+ }
63+ if rest := Drop(runErr, context.Canceled, parent); rest != nil {
64+ return rest
65+ }
66+ return closed
67+}
68+
69+// IsCancellation reports whether err is non-nil and made only of
70+// context.Canceled leaves, possibly wrapped with %w or joined. A failure
71+// joined with, or merely mentioning, a cancellation is not one. Unlike Drop,
72+// wrapped Canceled counts: it is how a caller's own cancellation surfaces
73+// through layers that add context. DeadlineExceeded is deliberately not
74+// included, so a bounded-operation timeout stays a real failure.
75+func IsCancellation(err error) bool {
76+ switch e := err.(type) {
77+ case nil:
78+ return false
79+ case interface{ Unwrap() []error }:
80+ children := e.Unwrap()
81+ for _, child := range children {
82+ if !IsCancellation(child) {
83+ return false
84+ }
85+ }
86+ return len(children) > 0
87+ case interface{ Unwrap() error }:
88+ return err == context.Canceled || IsCancellation(e.Unwrap())
89+ default:
90+ return err == context.Canceled
91+ }
92+}
A internal/adapters/uierrors/uierrors_test.go +111 -0
@@ -0,0 +1,111 @@
1+package uierrors
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestDrop(t *testing.T) {
14+ boom := errors.New("boom")
15+ other := errors.New("other")
16+ c := context.Canceled
17+
18+ assert.NoError(t, Drop(nil, c))
19+ assert.NoError(t, Drop(c, c))
20+ assert.NoError(t, Drop(errors.Join(c, c), c))
21+ assert.Same(t, boom, Drop(errors.Join(c, boom), c), "a lone survivor is returned unwrapped")
22+ assert.Same(t, boom, Drop(boom, c))
23+
24+ both := Drop(errors.Join(c, boom, other), c)
25+ assert.ErrorIs(t, both, boom)
26+ assert.ErrorIs(t, both, other)
27+ assert.NotErrorIs(t, both, c)
28+
29+ nested := Drop(errors.Join(errors.Join(c, boom), c), c)
30+ assert.Same(t, boom, nested)
31+}
32+
33+func TestDropKeepsWrappedTargets(t *testing.T) {
34+ wrapped := fmt.Errorf("surface close: %w", context.Canceled)
35+ got := Drop(errors.Join(context.Canceled, wrapped), context.Canceled)
36+ assert.Same(t, wrapped, got, "wrapping makes it a real failure")
37+}
38+
39+func TestDropMultipleTargets(t *testing.T) {
40+ assert.NoError(t, Drop(errors.Join(context.Canceled, context.DeadlineExceeded), context.Canceled, context.DeadlineExceeded))
41+}
42+
43+var errClosed = errors.New("closed")
44+
45+// liveParent returns a parent context and its cancel, to classify with real
46+// context state rather than hand-built errors.
47+func liveParent() (context.Context, context.CancelFunc) {
48+ return context.WithCancel(context.Background())
49+}
50+
51+func TestEndedCleanAfterOwnDecision(t *testing.T) {
52+ parent, cancel := liveParent()
53+ defer cancel()
54+ // We decided and canceled the child: Run reports a bare Canceled.
55+ child, cancelChild := context.WithCancel(parent)
56+ cancelChild()
57+ assert.NoError(t, Ended(child.Err(), true, parent.Err(), errClosed))
58+ assert.NoError(t, Ended(nil, true, parent.Err(), errClosed))
59+}
60+
61+func TestEndedCleanAfterCallerCancel(t *testing.T) {
62+ parent, cancel := liveParent()
63+ cancel()
64+ // The caller's context is done, which explains the end by itself.
65+ perr := parent.Err()
66+ require.Error(t, perr)
67+ assert.NoError(t, Ended(context.Canceled, true, perr, errClosed))
68+ assert.NoError(t, Ended(perr, true, perr, errClosed))
69+}
70+
71+func TestEndedKeepsRealFailureJoinedWithCancel(t *testing.T) {
72+ boom := errors.New("boom")
73+ parent, cancel := liveParent()
74+ cancel()
75+ for _, explained := range []bool{true, false} {
76+ err := Ended(errors.Join(context.Canceled, boom), explained, parent.Err(), errClosed)
77+ assert.ErrorIs(t, err, boom, "explained=%v", explained)
78+ }
79+ wrapped := fmt.Errorf("surface close: %w", context.Canceled)
80+ err := Ended(errors.Join(context.Canceled, wrapped), true, nil, errClosed)
81+ assert.Same(t, wrapped, err, "a wrapped Canceled is a real failure")
82+}
83+
84+func TestEndedUnexpectedCancelIsClosed(t *testing.T) {
85+ boom := errors.New("boom")
86+ // Nobody decided and the parent is live, yet Run ended.
87+ assert.ErrorIs(t, Ended(context.Canceled, false, nil, errClosed), errClosed)
88+ assert.ErrorIs(t, Ended(nil, false, nil, errClosed), errClosed)
89+ assert.NotErrorIs(t, Ended(context.Canceled, false, nil, errClosed), context.Canceled)
90+
91+ // A real failure is reported as itself, never relabeled as a close.
92+ real := Ended(errors.Join(context.Canceled, boom), false, nil, errClosed)
93+ assert.Same(t, boom, real)
94+ assert.NotErrorIs(t, real, errClosed)
95+ assert.NotErrorIs(t, real, context.Canceled)
96+ assert.Same(t, boom, Ended(boom, false, nil, errClosed))
97+}
98+
99+func TestIsCancellation(t *testing.T) {
100+ boom := errors.New("boom")
101+ c := context.Canceled
102+ assert.False(t, IsCancellation(nil))
103+ assert.True(t, IsCancellation(c))
104+ assert.True(t, IsCancellation(fmt.Errorf("capture first frame: %w", c)))
105+ assert.True(t, IsCancellation(errors.Join(c, fmt.Errorf("x: %w", c))))
106+ assert.False(t, IsCancellation(boom))
107+ assert.False(t, IsCancellation(errors.Join(c, boom)))
108+ assert.False(t, IsCancellation(fmt.Errorf("%w: %w", c, boom)))
109+ assert.False(t, IsCancellation(context.DeadlineExceeded))
110+ assert.False(t, IsCancellation(fmt.Errorf("finalize: %w", context.DeadlineExceeded)))
111+}
A internal/adapters/uistyle/uistyle.go +33 -0
@@ -0,0 +1,33 @@
1+// Package uistyle hands embedded NeferGUI stylesheets to NeferGUI, which loads
2+// styles only by file path.
3+package uistyle
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "os"
9+)
10+
11+// Write stores styles in a private (0600) temporary file named after pattern
12+// (see os.CreateTemp) and returns its path and a remove function. NeferGUI
13+// reads the file once when Run starts, so callers remove it when Run returns.
14+// remove is safe to call more than once; a removal failure only leaves a small
15+// file in the temporary directory and is not worth failing a capture over.
16+func Write(styles, pattern string) (path string, remove func(), err error) {
17+ f, err := os.CreateTemp("", pattern)
18+ if err != nil {
19+ return "", nil, fmt.Errorf("stylesheet: %w", err)
20+ }
21+ path = f.Name()
22+ remove = func() { _ = os.Remove(path) }
23+ if _, err := f.WriteString(styles); err != nil {
24+ err = errors.Join(err, f.Close())
25+ remove()
26+ return "", nil, fmt.Errorf("stylesheet: %w", err)
27+ }
28+ if err := f.Close(); err != nil {
29+ remove()
30+ return "", nil, fmt.Errorf("stylesheet: %w", err)
31+ }
32+ return path, remove, nil
33+}
A internal/adapters/uistyle/uistyle_test.go +34 -0
@@ -0,0 +1,34 @@
1+package uistyle
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestWriteStoresPrivateFileAndRemoves(t *testing.T) {
12+ t.Setenv("TMPDIR", t.TempDir())
13+ path, remove, err := Write("app { color: red; }", "uistyle-test-*.css")
14+ require.NoError(t, err)
15+ data, err := os.ReadFile(path)
16+ require.NoError(t, err)
17+ assert.Equal(t, "app { color: red; }", string(data))
18+ info, err := os.Stat(path)
19+ require.NoError(t, err)
20+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
21+
22+ remove()
23+ remove() // idempotent
24+ _, err = os.Stat(path)
25+ assert.ErrorIs(t, err, os.ErrNotExist)
26+}
27+
28+func TestWriteFailsWithoutTempDir(t *testing.T) {
29+ t.Setenv("TMPDIR", t.TempDir()+"/missing")
30+ path, remove, err := Write("x", "uistyle-test-*.css")
31+ assert.Error(t, err)
32+ assert.Empty(t, path)
33+ assert.Nil(t, remove)
34+}
A internal/adapters/wayland/capture_session.go +951 -0
@@ -0,0 +1,951 @@
1+package wayland
2+
3+import (
4+ "cmp"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "sync"
11+ "time"
12+ "unicode"
13+ "unicode/utf8"
14+
15+ "github.com/bnema/wlturbo"
16+ "github.com/bnema/wlturbo/protocol/core"
17+
18+ "github.com/bnema/nefercap/internal/ports"
19+)
20+
21+// Native private capture sessions.
22+//
23+// A session is a request to the compositor, on the capture connection itself,
24+// to keep the capturing client's own surfaces out of the frames it copies.
25+// The session belongs to the connection that began it: the compositor drops it
26+// when that connection closes. A second connection (the selection indicator)
27+// proves it belongs to the same peer by presenting the opaque session token
28+// through AuthorizeLayer.
29+//
30+// The token is a credential. It is never logged and never printed by
31+// SessionState's String method.
32+
33+const (
34+ captureManagerIface = "neferwl_capture_manager_v1"
35+ captureManagerVersion = 1
36+ tokenLen = 64 // lowercase hexadecimal characters of 32 random bytes
37+ sessionPingInterval = 2 * time.Second
38+ maxRegionRetries = 2
39+ maxWorkspaces = 256 // bound of the workspace inventory
40+ maxNameLen = 256 // bytes, for output and workspace names
41+
42+ // DefaultAttachTimeout bounds AuthorizeLayer.
43+ DefaultAttachTimeout = 5 * time.Second
44+)
45+
46+var (
47+ // ErrSessionUnsupported means the compositor has no private capture
48+ // protocol, or the request cannot be served by the session (for example a
49+ // workspace capture without a session on that workspace).
50+ ErrSessionUnsupported = errors.New("wayland: private capture session unsupported")
51+ // ErrSessionUnavailable means the session is gone or was never started.
52+ // Callers that rely on the exclusion must stop recording.
53+ ErrSessionUnavailable = errors.New("wayland: private capture session unavailable")
54+ // ErrSessionActive reports BeginSession while a session is already active.
55+ ErrSessionActive = errors.New("wayland: private capture session already active")
56+ // ErrWorkspaceNotFound reports an unknown workspace ID.
57+ ErrWorkspaceNotFound = errors.New("wayland: workspace not found")
58+ // ErrSessionInactive means the compositor reported the session paused
59+ // (state active 0). The current compositor never does; the case is handled
60+ // defensively. It is transient: no frame is returned until it resumes.
61+ ErrSessionInactive = errors.New("wayland: private capture session paused")
62+ // ErrSessionGeometryChanging is transient: the session's region kept
63+ // changing during the bounded captures, so no frame is returned.
64+ ErrSessionGeometryChanging = errors.New("wayland: private capture session geometry is changing")
65+ // ErrServerMetadata means the compositor sent private-protocol metadata
66+ // that is out of bounds or hostile. The private capability is then
67+ // disabled and an active session fails closed; standard capture of
68+ // outputs is not affected.
69+ ErrServerMetadata = errors.New("wayland: invalid private capture metadata")
70+ // errLayerProtocol is a violation of the layer attachment state machine.
71+ errLayerProtocol = fmt.Errorf("%w: capture layer protocol violation", ErrSessionUnavailable)
72+ // ErrAttachFailed is matched by every AttachFailedError.
73+ ErrAttachFailed = errors.New("wayland: attach layer failed")
74+ // ErrUnauthorized means the compositor refused the peer.
75+ ErrUnauthorized = errors.New("wayland: capture session peer unauthorized")
76+ errCompositorFailedCapture = errors.New("wayland: compositor failed the capture")
77+)
78+
79+// StopReason is neferwl_capture_session_v1.stop_reason.
80+type StopReason uint32
81+
82+const (
83+ StopRequested StopReason = 0
84+ StopOutputGone StopReason = 1
85+ StopOutputOff StopReason = 2
86+ StopPingTimeout StopReason = 3
87+ StopWorkspaceGone StopReason = 5
88+ StopBusy StopReason = 6
89+ StopInvalidRegion StopReason = 7
90+ StopUnauthorized StopReason = 8
91+ StopTooManyExcluded StopReason = 9
92+)
93+
94+// SessionStoppedError reports that the compositor ended the session, or
95+// refused it. It matches ErrSessionUnavailable.
96+type SessionStoppedError struct{ Reason StopReason }
97+
98+func (e *SessionStoppedError) Error() string {
99+ return fmt.Sprintf("%v: stopped by the compositor (reason %d)", ErrSessionUnavailable, e.Reason)
100+}
101+
102+func (e *SessionStoppedError) Is(target error) bool { return target == ErrSessionUnavailable }
103+
104+// AttachFailure is neferwl_capture_layer_v1.failure.
105+type AttachFailure uint32
106+
107+const (
108+ AttachUnknownToken AttachFailure = 0
109+ AttachUnauthorized AttachFailure = 1
110+ AttachTooManyLayers AttachFailure = 2
111+ AttachAlreadyAttached AttachFailure = 3
112+ AttachSessionEnded AttachFailure = 4
113+ AttachLayerDestroyed AttachFailure = 5
114+)
115+
116+// AttachFailedError is the compositor's refusal of AuthorizeLayer. It matches
117+// ErrAttachFailed; a missing or ended session also matches
118+// ErrSessionUnavailable, and an unauthorized peer ErrUnauthorized.
119+type AttachFailedError struct{ Reason AttachFailure }
120+
121+func (e *AttachFailedError) Error() string {
122+ return fmt.Sprintf("%v (reason %d)", ErrAttachFailed, e.Reason)
123+}
124+
125+func (e *AttachFailedError) Is(target error) bool {
126+ switch target {
127+ case ErrAttachFailed:
128+ return true
129+ case ErrSessionUnavailable:
130+ return e.Reason == AttachUnknownToken || e.Reason == AttachSessionEnded
131+ case ErrUnauthorized:
132+ return e.Reason == AttachUnauthorized
133+ }
134+ return false
135+}
136+
137+// DetachReason is neferwl_capture_layer_v1.detach_reason.
138+type DetachReason uint32
139+
140+const (
141+ // DetachSessionEnded: the session stopped and the compositor closed the
142+ // layer surface, which the client must destroy.
143+ DetachSessionEnded DetachReason = 0
144+ // DetachLayerDestroyed: the layer role was destroyed.
145+ DetachLayerDestroyed DetachReason = 1
146+)
147+
148+// LayerDetachedError is what onDetached receives. A session end also matches
149+// ErrSessionUnavailable.
150+type LayerDetachedError struct{ Reason DetachReason }
151+
152+func (e *LayerDetachedError) Error() string {
153+ return fmt.Sprintf("wayland: layer detached from the capture session (reason %d)", e.Reason)
154+}
155+
156+func (e *LayerDetachedError) Is(target error) bool {
157+ return target == ErrSessionUnavailable && e.Reason == DetachSessionEnded
158+}
159+
160+// SessionState is a snapshot of a session. Token is opaque.
161+type SessionState struct {
162+ // Token is a secret: it is omitted from JSON, String and GoString.
163+ Token string `json:"-"`
164+ // Revision grows whenever the compositor changes the session's
165+ // configuration (layers, popups, target, pause). It starts at 1.
166+ Revision uint64
167+ // Target is directly usable with Capture. For an output session it holds
168+ // the output and the granted Region. For a workspace session it holds the
169+ // output and WorkspaceID with a zero Region: the workspace frame is
170+ // resolved live by the Source, so Region is never a stale crop.
171+ Target ports.Target
172+ // Region is the geometry the compositor granted, output-local logical
173+ // pixels, at the time of this snapshot (for display, e.g. the indicator).
174+ Region ports.Region
175+ Active bool
176+}
177+
178+// String omits the token.
179+func (s SessionState) String() string {
180+ return fmt.Sprintf("session{target:%+v active:%t}", s.Target, s.Active)
181+}
182+
183+// GoString omits the token from %#v as well.
184+func (s SessionState) GoString() string { return s.String() }
185+
186+// MarshalJSON omits the token, also when SessionState is embedded or logged
187+// through a structured logger.
188+func (s SessionState) MarshalJSON() ([]byte, error) {
189+ return json.Marshal(struct {
190+ Revision uint64
191+ Target ports.Target
192+ Region ports.Region
193+ Active bool
194+ }{s.Revision, s.Target, s.Region, s.Active})
195+}
196+
197+// ---------------------------------------------------------------------------
198+// Wire layer: neferwl-capture-v1.xml of the compositor, interface versions 1.
199+//
200+// neferwl_capture_manager_v1
201+// requests: 0 destroy, 1 begin_session(new_id, output, x, y, w, h,
202+// workspace_hi, workspace_lo, record),
203+// 2 attach_surface(new_id layer, token, surface)
204+// events: 0 workspace(hi, lo, output_name, name, x, y, w, h, active)
205+// 1 workspace_removed(hi, lo)
206+// neferwl_capture_layer_v1
207+// requests: 0 destroy
208+// events: 0 attached 1 failed(reason) 2 detached(reason)
209+// neferwl_capture_session_v1
210+// requests: 0 destroy, 1 ping
211+// events: 0 state(token, active, x, y, w, h, workspace_hi, workspace_lo,
212+// revision_hi, revision_lo) 1 stopped(reason)
213+// ---------------------------------------------------------------------------
214+
215+const (
216+ reqBeginSession = 1
217+ reqAttachSurface = 2
218+ reqManagerDestroy = 0
219+ reqLayerDestroy = 0
220+ reqSessionDestroy = 0
221+ reqSessionPing = 1
222+)
223+
224+// captureManager is the bound manager. s is nil on a connection that only
225+// authorizes a layer surface: workspace events are then ignored.
226+type captureManager struct {
227+ wlturbo.BaseProxy
228+ s *Source
229+}
230+
231+func (*captureManager) EventSignature(op uint16) (string, bool) {
232+ switch op {
233+ case 0:
234+ return "uint,uint,string,string,int,int,int,int,uint,", true
235+ case 1:
236+ return "uint,uint,", true
237+ }
238+ return "", false
239+}
240+
241+func (m *captureManager) Dispatch(e *wlturbo.Event) {
242+ if m.s == nil {
243+ return
244+ }
245+ switch e.Opcode {
246+ case 0:
247+ hi, lo := e.Uint32(), e.Uint32()
248+ outName, name := e.String(), e.String()
249+ x, y, w, h := e.Int32(), e.Int32(), e.Int32(), e.Int32()
250+ m.s.onWorkspace(join64(hi, lo), outName, name, ports.Region{X: int(x), Y: int(y), Width: int(w), Height: int(h)}, e.Uint32() != 0)
251+ case 1:
252+ if m.s.metaErr == nil {
253+ delete(m.s.workspaces, join64(e.Uint32(), e.Uint32()))
254+ }
255+ }
256+}
257+
258+// join64 joins the hi and lo halves of a 64-bit wire value.
259+func join64(hi, lo uint32) uint64 { return uint64(hi)<<32 | uint64(lo) }
260+
261+// layerProxy is one neferwl_capture_layer_v1 attachment. Its events are
262+// dispatched by whichever goroutine reads the GUI connection: during
263+// authorizeLayer that is the caller, afterwards the connection's reader.
264+//
265+// State machine: pending -> attached | failed; attached -> detached. Any other
266+// event is a protocol violation: the exclusion cannot be trusted any more.
267+type layerProxy struct {
268+ wlturbo.BaseProxy
269+ display *wlturbo.Display
270+ onDetached func(error)
271+
272+ mu sync.Mutex
273+ state layerState
274+ failReason uint32
275+ violation bool
276+ returned bool // authorizeLayer handed the surface to its caller
277+ notified bool
278+}
279+
280+type layerState int
281+
282+const (
283+ layerPending layerState = iota
284+ layerAttached
285+ layerFailed
286+ layerDetached
287+)
288+
289+func (*layerProxy) EventSignature(op uint16) (string, bool) {
290+ switch op {
291+ case 0:
292+ return "", true
293+ case 1, 2:
294+ return "uint,", true
295+ }
296+ return "", false
297+}
298+
299+func (l *layerProxy) Dispatch(e *wlturbo.Event) {
300+ var arg uint32
301+ if e.Opcode != 0 {
302+ arg = e.Uint32()
303+ }
304+ var notify error
305+ closeDisplay := false
306+ l.mu.Lock()
307+ returned := l.returned
308+ switch {
309+ case l.violation:
310+ // Terminal: the display is closed already.
311+ case e.Opcode == 0 && l.state == layerPending:
312+ l.state = layerAttached
313+ case e.Opcode == 1 && l.state == layerPending:
314+ l.state, l.failReason = layerFailed, arg
315+ case e.Opcode == 2 && l.state == layerAttached:
316+ l.state = layerDetached
317+ if returned {
318+ l.notified, notify = true, &LayerDetachedError{Reason: DetachReason(arg)}
319+ }
320+ default:
321+ l.violation, closeDisplay = true, true
322+ if returned && !l.notified {
323+ l.notified, notify = true, errLayerProtocol
324+ }
325+ }
326+ l.mu.Unlock()
327+ if closeDisplay && returned {
328+ _ = l.display.Close() // the surface could be visible in captures
329+ }
330+ if notify != nil && l.onDetached != nil {
331+ l.onDetached(notify)
332+ }
333+}
334+
335+// settled reports that the answer arrived, or the layer misbehaved.
336+func (l *layerProxy) settled() bool {
337+ l.mu.Lock()
338+ defer l.mu.Unlock()
339+ return l.state != layerPending || l.violation
340+}
341+
342+// hand marks the layer as owned by the caller of authorizeLayer, and reports
343+// its outcome so far.
344+func (l *layerProxy) hand() (state layerState, reason uint32, violation bool) {
345+ l.mu.Lock()
346+ defer l.mu.Unlock()
347+ l.returned = true
348+ return l.state, l.failReason, l.violation
349+}
350+
351+// captureSession is one private capture session proxy.
352+type captureSession struct {
353+ wlturbo.BaseProxy
354+
355+ // Pinned at begin: what the owner asked for and where.
356+ outputID uint32
357+ requestedWorkspace uint64
358+ requestedRegion ports.Region // output targets only; zero is the full output
359+
360+ haveState bool
361+ token string
362+ active bool
363+ region ports.Region // last granted geometry
364+ revision uint64
365+
366+ stopped bool
367+ stopReason uint32
368+ violation bool // the compositor broke the protocol: fail closed
369+
370+ lastPing time.Time // monotonic
371+ destroyed bool
372+}
373+
374+func (*captureSession) EventSignature(op uint16) (string, bool) {
375+ switch op {
376+ case 0:
377+ return "string,uint,int,int,int,int,uint,uint,uint,uint,", true
378+ case 1:
379+ return "uint,", true
380+ }
381+ return "", false
382+}
383+
384+func (c *captureSession) Dispatch(e *wlturbo.Event) {
385+ switch e.Opcode {
386+ case 0:
387+ token, active := e.String(), e.Uint32() != 0
388+ x, y, w, h := e.Int32(), e.Int32(), e.Int32(), e.Int32()
389+ ws := join64(e.Uint32(), e.Uint32())
390+ rev := join64(e.Uint32(), e.Uint32())
391+ if c.lost() {
392+ return // final: later events change nothing
393+ }
394+ grant := ports.Region{X: int(x), Y: int(y), Width: int(w), Height: int(h)}
395+ switch {
396+ case !c.haveState && !validToken(token),
397+ c.haveState && token != c.token,
398+ rev == 0 || rev < c.revision, // revisions start at 1 and never go back
399+ !c.validGrant(grant, ws):
400+ c.violation = true
401+ return
402+ }
403+ c.haveState, c.token = true, token
404+ c.active, c.revision, c.region = active, rev, grant
405+ case 1:
406+ c.stopped, c.stopReason = true, e.Uint32()
407+ }
408+}
409+
410+// validGrant checks a granted geometry against what was requested. A zero,
411+// negative or oversized rectangle is never read as "the full output".
412+func (c *captureSession) validGrant(g ports.Region, workspace uint64) bool {
413+ if workspace != c.requestedWorkspace || !boundedRegion(g) {
414+ return false
415+ }
416+ if c.requestedWorkspace != 0 || c.requestedRegion == (ports.Region{}) {
417+ // The workspace frame may move and resize; the full output has no
418+ // exactly known logical size (scales can be fractional), so only the
419+ // hard bounds apply.
420+ return true
421+ }
422+ r := c.requestedRegion
423+ return g.X >= r.X && g.Y >= r.Y &&
424+ int64(g.X)+int64(g.Width) <= int64(r.X)+int64(r.Width) &&
425+ int64(g.Y)+int64(g.Height) <= int64(r.Y)+int64(r.Height)
426+}
427+
428+// boundedRegion reports a positive rectangle inside the maximum dimensions.
429+func boundedRegion(r ports.Region) bool {
430+ return r.X >= 0 && r.Y >= 0 && r.Width >= 1 && r.Height >= 1 &&
431+ r.Width <= ports.MaxDimension && r.Height <= ports.MaxDimension &&
432+ int64(r.X)+int64(r.Width) <= ports.MaxDimension &&
433+ int64(r.Y)+int64(r.Height) <= ports.MaxDimension
434+}
435+
436+// lost reports that the exclusion can no longer be relied upon.
437+func (c *captureSession) lost() bool { return c.stopped || c.violation }
438+
439+// begun reports that the compositor answered begin_session with a state.
440+func (c *captureSession) begun() bool { return c.haveState }
441+
442+// workspace is the client-side state of one advertised workspace.
443+type workspace struct {
444+ id uint64
445+ output string
446+ name string
447+ region ports.Region
448+ active bool
449+}
450+
451+func (s *Source) onWorkspace(id uint64, outName, name string, r ports.Region, active bool) {
452+ if s.metaErr != nil {
453+ return
454+ }
455+ switch {
456+ case !validName(outName, false), !validName(name, true):
457+ s.failMetadata()
458+ case id == 0 || !boundedRegion(r):
459+ delete(s.workspaces, id) // unusable announcement
460+ case s.workspaces[id] == nil && len(s.workspaces) >= maxWorkspaces:
461+ s.failMetadata()
462+ default:
463+ s.workspaces[id] = &workspace{id: id, output: outName, name: name, region: r, active: active}
464+ }
465+}
466+
467+// failMetadata disables the private capability: the inventory is emptied, and
468+// an active session fails closed. Standard output capture is unaffected.
469+func (s *Source) failMetadata() {
470+ s.metaErr = fmt.Errorf("%w: %w", ErrSessionUnavailable, ErrServerMetadata)
471+ clear(s.workspaces)
472+ if s.session != nil {
473+ s.session.violation = true
474+ }
475+ s.log.Warn().Msg("private capture metadata rejected")
476+}
477+
478+// validName accepts a bounded, valid UTF-8, single-line name without control
479+// or bidirectional formatting characters.
480+func validName(n string, emptyOK bool) bool {
481+ if len(n) > maxNameLen || (n == "" && !emptyOK) || !utf8.ValidString(n) {
482+ return false
483+ }
484+ for _, r := range n {
485+ switch {
486+ case unicode.IsControl(r), r == 0x2028, r == 0x2029, r == 0x061C, r == 0x200E, r == 0x200F,
487+ r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069:
488+ return false
489+ }
490+ }
491+ return true
492+}
493+
494+func outputName(o *output) string {
495+ if o.name == "" { // wl_output older than version 4 has no name
496+ return fmt.Sprintf("output-%d", o.id)
497+ }
498+ return o.name
499+}
500+
501+// bindCaptureManager binds the private manager when the compositor offers it.
502+// Its absence is not an error: the session methods then report
503+// ErrSessionUnsupported.
504+func (s *Source) bindCaptureManager() error {
505+ s.workspaces = make(map[uint64]*workspace)
506+ m := &captureManager{s: s}
507+ m.SetContext(s.wl)
508+ _, err := s.display.Registry().BindNegotiated(captureManagerIface, captureManagerVersion, m)
509+ switch {
510+ case err == nil:
511+ s.private = m
512+ return nil
513+ case errors.Is(err, wlturbo.ErrGlobalNotFound):
514+ return nil
515+ }
516+ return fmt.Errorf("wayland: bind %s: %w", captureManagerIface, err)
517+}
518+
519+func (s *Source) newSessionProxy() *captureSession {
520+ c := &captureSession{}
521+ c.SetContext(s.wl)
522+ return c
523+}
524+
525+func (s *Source) destroySession(c *captureSession) {
526+ if c.destroyed {
527+ return
528+ }
529+ c.destroyed = true
530+ _ = s.wl.Request(wlturbo.Request{Proxy: c, Opcode: reqSessionDestroy, Name: "neferwl_capture_session_v1.destroy", Destructor: true})
531+}
532+
533+// Workspaces lists the workspaces the compositor advertises through the
534+// private capture protocol, by ID. It returns ErrSessionUnsupported when the
535+// compositor has no such protocol: workspace identities are never guessed.
536+func (s *Source) Workspaces(ctx context.Context) ([]ports.Workspace, error) {
537+ s.opMu.Lock()
538+ defer s.opMu.Unlock()
539+ if err := s.begin(ctx); err != nil {
540+ return nil, err
541+ }
542+ err := s.settle()
543+ if ferr := s.finish(ctx, err); ferr != nil {
544+ return nil, ferr
545+ }
546+ if s.private == nil {
547+ return nil, ErrSessionUnsupported
548+ }
549+ if s.metaErr != nil {
550+ return nil, s.metaErr
551+ }
552+ return s.workspaceList(), nil
553+}
554+
555+func (s *Source) workspaceList() []ports.Workspace {
556+ byName := make(map[string]uint32, len(s.outputs))
557+ for _, o := range s.outputs {
558+ byName[outputName(o)] = o.id
559+ }
560+ list := make([]ports.Workspace, 0, len(s.workspaces))
561+ for _, w := range s.workspaces {
562+ id, ok := byName[w.output]
563+ if !ok {
564+ continue // its output is not (or no longer) announced
565+ }
566+ list = append(list, ports.Workspace{ID: w.id, OutputID: id, Name: w.name, Region: w.region, Active: w.active})
567+ }
568+ slices.SortFunc(list, func(a, b ports.Workspace) int { return cmp.Compare(a.ID, b.ID) })
569+ return list
570+}
571+
572+// BeginSession starts the private capture session of this connection for
573+// target and waits until the compositor confirms it active. record tells the
574+// compositor whether the session backs a recording. The Source owns at most
575+// one session; it ends with EndSession, a compositor stop, or Close.
576+//
577+// Output targets (with an optional output-local region) and workspace targets
578+// are accepted. A workspace target needs a workspace advertised by Workspaces,
579+// on screen or not, and no region: the request carries a zero region and the
580+// compositor grants the workspace frame. The output, the workspace and the
581+// requested region are pinned for the session; the compositor's state events
582+// are validated against them.
583+func (s *Source) BeginSession(ctx context.Context, target ports.Target, record bool) (SessionState, error) {
584+ s.opMu.Lock()
585+ defer s.opMu.Unlock()
586+ if err := s.begin(ctx); err != nil {
587+ return SessionState{}, err
588+ }
589+ state, err := s.beginSession(target, record)
590+ if err = s.finish(ctx, err); err != nil {
591+ return SessionState{}, err
592+ }
593+ return state, nil
594+}
595+
596+func (s *Source) beginSession(target ports.Target, record bool) (SessionState, error) {
597+ if s.private == nil {
598+ return SessionState{}, ErrSessionUnsupported
599+ }
600+ if s.session != nil {
601+ return SessionState{}, ErrSessionActive
602+ }
603+ if err := s.settle(); err != nil { // current outputs and workspaces
604+ return SessionState{}, err
605+ }
606+ if s.metaErr != nil {
607+ return SessionState{}, s.metaErr
608+ }
609+ out, region, err := s.sessionTarget(target)
610+ if err != nil {
611+ return SessionState{}, err
612+ }
613+ c := s.newSessionProxy()
614+ c.outputID, c.requestedWorkspace, c.requestedRegion = out.id, target.WorkspaceID, region
615+ hi, lo := uint32(target.WorkspaceID>>32), uint32(target.WorkspaceID)
616+ rec := uint32(0)
617+ if record {
618+ rec = 1
619+ }
620+ err = s.wl.Request(wlturbo.Request{Proxy: s.private, Opcode: reqBeginSession, Name: "neferwl_capture_manager_v1.begin_session", Child: c},
621+ c, out.proxy, int32(region.X), int32(region.Y), int32(region.Width), int32(region.Height), hi, lo, rec)
622+ if err != nil {
623+ s.terminate()
624+ return SessionState{}, fmt.Errorf("wayland: begin session: %w", err)
625+ }
626+ // The answer is a state (possibly paused) or a stop without a state.
627+ for !c.begun() && !c.lost() {
628+ if err := s.dispatch(); err != nil {
629+ return SessionState{}, err
630+ }
631+ }
632+ if c.lost() {
633+ s.destroySession(c)
634+ if err := s.roundtrip(); err != nil {
635+ return SessionState{}, err
636+ }
637+ return SessionState{}, sessionLostError(c)
638+ }
639+ c.lastPing = time.Now()
640+ s.session = c
641+ return snapshot(c), nil
642+}
643+
644+// sessionTarget resolves target to an output and the region to request: zero
645+// for a workspace, the whole frame.
646+func (s *Source) sessionTarget(t ports.Target) (*output, ports.Region, error) {
647+ if t.WorkspaceID != 0 {
648+ ws := s.workspaces[t.WorkspaceID]
649+ if ws == nil {
650+ return nil, ports.Region{}, ErrWorkspaceNotFound
651+ }
652+ if t.Region != (ports.Region{}) {
653+ return nil, ports.Region{}, fmt.Errorf("%w: a workspace target has no region", ports.ErrInvalidRegion)
654+ }
655+ out := s.outputByName(ws.output)
656+ if out == nil || (t.OutputID != 0 && t.OutputID != out.id) {
657+ return nil, ports.Region{}, ports.ErrOutputNotFound
658+ }
659+ return out, ports.Region{}, nil
660+ }
661+ if err := validRegion(t.Region); err != nil {
662+ return nil, ports.Region{}, err
663+ }
664+ out := s.outputs[t.OutputID]
665+ if out == nil {
666+ return nil, ports.Region{}, ports.ErrOutputNotFound
667+ }
668+ return out, t.Region, nil
669+}
670+
671+func (s *Source) outputByName(name string) *output {
672+ for _, o := range s.outputs {
673+ if outputName(o) == name {
674+ return o
675+ }
676+ }
677+ return nil
678+}
679+
680+func snapshot(c *captureSession) SessionState {
681+ target := ports.Target{OutputID: c.outputID, Region: c.region}
682+ if c.requestedWorkspace != 0 {
683+ target = ports.Target{OutputID: c.outputID, WorkspaceID: c.requestedWorkspace}
684+ }
685+ return SessionState{
686+ Token: c.token,
687+ Revision: c.revision,
688+ Target: target,
689+ Region: c.region,
690+ Active: c.active && !c.lost(),
691+ }
692+}
693+
694+// EndSession ends the session. It is a no-op without one and is safe after the
695+// compositor stopped the session.
696+func (s *Source) EndSession(ctx context.Context) error {
697+ s.opMu.Lock()
698+ defer s.opMu.Unlock()
699+ if err := s.begin(ctx); err != nil {
700+ return err
701+ }
702+ var err error
703+ if c := s.session; c != nil {
704+ s.session = nil
705+ s.destroySession(c)
706+ err = s.roundtrip()
707+ }
708+ return s.finish(ctx, err)
709+}
710+
711+// PingSession sends a keep-alive and reports ErrSessionUnavailable when the
712+// compositor has stopped the session, or when none is active.
713+func (s *Source) PingSession(ctx context.Context) error {
714+ s.opMu.Lock()
715+ defer s.opMu.Unlock()
716+ if err := s.begin(ctx); err != nil {
717+ return err
718+ }
719+ err := s.pingSession()
720+ return s.finish(ctx, err)
721+}
722+
723+func (s *Source) pingSession() error {
724+ c := s.session
725+ if c == nil {
726+ return fmt.Errorf("%w: no active session", ErrSessionUnavailable)
727+ }
728+ if c.lost() {
729+ return sessionLostError(c)
730+ }
731+ if err := s.sendPing(c); err != nil {
732+ return err
733+ }
734+ if err := s.roundtrip(); err != nil { // observe a stop that answers the ping
735+ return err
736+ }
737+ if c.lost() {
738+ return sessionLostError(c)
739+ }
740+ return nil
741+}
742+
743+func (s *Source) sendPing(c *captureSession) error {
744+ if err := s.wl.Request(wlturbo.Request{Proxy: c, Opcode: reqSessionPing, Name: "neferwl_capture_session_v1.ping"}); err != nil {
745+ s.terminate()
746+ return fmt.Errorf("wayland: session ping: %w", err)
747+ }
748+ c.lastPing = time.Now()
749+ return nil
750+}
751+
752+func sessionLostError(c *captureSession) error {
753+ if c.violation {
754+ return fmt.Errorf("%w: invalid session state from the compositor", ErrSessionUnavailable)
755+ }
756+ return &SessionStoppedError{Reason: StopReason(c.stopReason)}
757+}
758+
759+// captureTarget is Capture under an active session: it resolves workspace
760+// targets, sends a keep-alive when one is due (a single request, no wait), and
761+// never returns a frame captured while the exclusion was lost. The caller
762+// holds opMu.
763+func (s *Source) captureTarget(t ports.Target) (ports.Frame, error) {
764+ c := s.session
765+ if c != nil && c.lost() {
766+ return ports.Frame{}, sessionLostError(c)
767+ }
768+ if c != nil && !c.active {
769+ // Events are only read inside operations: look once for a resume.
770+ if err := s.roundtrip(); err != nil {
771+ return ports.Frame{}, err
772+ }
773+ if c.lost() {
774+ return ports.Frame{}, sessionLostError(c)
775+ }
776+ if !c.active {
777+ return ports.Frame{}, ErrSessionInactive
778+ }
779+ }
780+ if c != nil && time.Since(c.lastPing) >= s.pingEvery {
781+ if err := s.sendPing(c); err != nil {
782+ return ports.Frame{}, err
783+ }
784+ }
785+ // Events are only read while a request is in flight, so a live geometry
786+ // change is seen during the capture that used the previous region. Such a
787+ // frame is stale for a workspace target: it is never returned. Capture
788+ // again, a bounded number of times and without any extra wait, then give up
789+ // with a transient error.
790+ retriedFailure := false
791+ for attempt := 0; ; attempt++ {
792+ rt, err := s.resolveCapture(t)
793+ if err != nil {
794+ return ports.Frame{}, err
795+ }
796+ used := rt.Region
797+ frame, err := s.capture(rt)
798+ if c != nil && errors.Is(err, ports.ErrOutputNotFound) && !c.lost() {
799+ // An output that went away ends the session: read that stop.
800+ if rerr := s.roundtrip(); rerr != nil {
801+ return ports.Frame{}, rerr
802+ }
803+ }
804+ if c != nil && c.lost() { // a stop was dispatched during the capture
805+ return ports.Frame{}, sessionLostError(c)
806+ }
807+ if errors.Is(err, errCompositorFailedCapture) && c != nil && c.active && !retriedFailure {
808+ // The compositor's scene may lag a session change by one frame and
809+ // fail the copy: retry once, never blindly beyond that.
810+ retriedFailure = true
811+ attempt--
812+ continue
813+ }
814+ if err != nil {
815+ return ports.Frame{}, err
816+ }
817+ if c != nil && !c.active { // paused meanwhile: the frame is not clean
818+ return ports.Frame{}, ErrSessionInactive
819+ }
820+ if t.WorkspaceID == 0 || c == nil || c.region == used {
821+ return frame, nil
822+ }
823+ if attempt >= maxRegionRetries {
824+ return ports.Frame{}, ErrSessionGeometryChanging
825+ }
826+ }
827+}
828+
829+// resolveCapture maps a workspace target to the session's pinned output and
830+// the live region the compositor last granted. The workspace inventory is not
831+// consulted: it can change or lose the workspace at any time, and the session
832+// alone decides what is captured.
833+func (s *Source) resolveCapture(t ports.Target) (ports.Target, error) {
834+ if t.WorkspaceID == 0 {
835+ return t, nil
836+ }
837+ c := s.session
838+ if c == nil || c.requestedWorkspace != t.WorkspaceID {
839+ return t, fmt.Errorf("%w: workspace capture needs a session on that workspace", ErrSessionUnsupported)
840+ }
841+ if t.Region != (ports.Region{}) {
842+ return t, fmt.Errorf("%w: a workspace target has no region", ports.ErrInvalidRegion)
843+ }
844+ if t.OutputID != 0 && t.OutputID != c.outputID {
845+ return t, ports.ErrOutputNotFound
846+ }
847+ return ports.Target{OutputID: c.outputID, Region: c.region}, nil
848+}
849+
850+// AuthorizeLayer tells the compositor, from a connection other than the
851+// capture connection, that surface is a layer surface of the capture peer and
852+// must be excluded from its session. token comes from SessionState. Call it
853+// after the layer-shell role is assigned and before the surface is mapped
854+// (before the first buffer); when it returns nil the compositor core has
855+// confirmed the exclusion for this very attachment, so the surface is safe to
856+// show.
857+//
858+// AuthorizeLayer dispatches display's events itself until the attachment
859+// answers, so it must run before the connection's event loop starts. A
860+// refusal returns an *AttachFailedError. A protocol error is returned as the
861+// display's error. A cancelled ctx, or no answer within DefaultAttachTimeout
862+// (an error wrapping context.DeadlineExceeded), closes display before
863+// returning: the surface must then stay unmapped. So does a violation of the
864+// attachment protocol (an answer twice, a detach before the answer, ...).
865+//
866+// After it returns nil the attachment stays until the session stops or the
867+// layer is destroyed; then onDetached (may be nil) receives a
868+// *LayerDetachedError, once. A violation of the attachment protocol at that
869+// point closes display and calls onDetached with an error matching
870+// ErrSessionUnavailable; the surface may still draw until the caller stops,
871+// so onDetached must make the caller stop. It runs on the goroutine that reads
872+// display, so it must only do thread-safe, non-blocking work such as
873+// cancelling a context. It never sees the token. The attachment object is
874+// never destroyed after a success: that would not release the exclusion, and
875+// closing the connection ends it. After DetachSessionEnded the compositor has
876+// closed the layer surface, which the caller destroys.
877+func AuthorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error)) error {
878+ return authorizeLayer(ctx, display, surface, token, onDetached, DefaultAttachTimeout)
879+}
880+
881+func authorizeLayer(ctx context.Context, display *wlturbo.Display, surface *core.Surface, token string, onDetached func(error), timeout time.Duration) error {
882+ if display == nil || surface == nil || surface.Context() != display.Context() {
883+ return errors.New("wayland: authorize layer: surface does not belong to display")
884+ }
885+ if !validToken(token) {
886+ return errors.New("wayland: authorize layer: invalid token")
887+ }
888+ if err := ctx.Err(); err != nil {
889+ return err
890+ }
891+ m := &captureManager{}
892+ m.SetContext(display.Context())
893+ if _, err := display.Registry().BindNegotiated(captureManagerIface, captureManagerVersion, m); err != nil {
894+ if errors.Is(err, wlturbo.ErrGlobalNotFound) {
895+ return ErrSessionUnsupported
896+ }
897+ return fmt.Errorf("wayland: bind %s: %w", captureManagerIface, err)
898+ }
899+ wctx, cancel := context.WithTimeout(ctx, timeout)
900+ defer cancel()
901+ stop := context.AfterFunc(wctx, func() { _ = display.Close() })
902+ defer stop()
903+
904+ l := &layerProxy{display: display, onDetached: onDetached}
905+ l.SetContext(display.Context())
906+ err := display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqAttachSurface, Name: "neferwl_capture_manager_v1.attach_surface", Child: l}, l, token, surface)
907+ for err == nil && !l.settled() {
908+ err = display.Dispatch()
909+ }
910+ // Detach the watcher before anything else is decided or sent. When stop
911+ // reports false the watcher has fired or is firing: cancellation and the
912+ // deadline win over any outcome, and the display is closed here too so it
913+ // is closed on return whatever the watcher goroutine is doing. When it
914+ // reports true the watcher can never fire, and the caller owns the
915+ // connection; a context that is already done is still caught below.
916+ if !stop() || wctx.Err() != nil {
917+ _ = display.Close()
918+ if ctx.Err() != nil {
919+ return ctx.Err()
920+ }
921+ return fmt.Errorf("wayland: attach surface: %w", wctx.Err())
922+ }
923+ if err != nil {
924+ return fmt.Errorf("wayland: attach surface: %w", err)
925+ }
926+ state, reason, violation := l.hand()
927+ if violation {
928+ _ = display.Close()
929+ return errLayerProtocol
930+ }
931+ // The manager binding is not needed any more.
932+ _ = display.Context().Request(wlturbo.Request{Proxy: m, Opcode: reqManagerDestroy, Name: "neferwl_capture_manager_v1.destroy", Destructor: true})
933+ if state == layerFailed {
934+ _ = display.Context().Request(wlturbo.Request{Proxy: l, Opcode: reqLayerDestroy, Name: "neferwl_capture_layer_v1.destroy", Destructor: true})
935+ return &AttachFailedError{Reason: AttachFailure(reason)}
936+ }
937+ return nil
938+}
939+
940+// validToken accepts exactly the compositor's token format.
941+func validToken(token string) bool {
942+ if len(token) != tokenLen {
943+ return false
944+ }
945+ for i := range len(token) {
946+ if c := token[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') {
947+ return false
948+ }
949+ }
950+ return true
951+}
A internal/adapters/wayland/capture_session_test.go +783 -0
@@ -0,0 +1,783 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "net"
9+ "os"
10+ "strings"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/wlturbo"
16+ "github.com/bnema/wlturbo/protocol/core"
17+ "github.com/stretchr/testify/require"
18+
19+ "github.com/bnema/nefercap/internal/ports"
20+)
21+
22+const (
23+ testToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
24+ testToken2 = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
25+)
26+
27+func privateCfg(p privateConfig) compositorConfig {
28+ p.enabled = true
29+ return compositorConfig{
30+ outputs: []outputSpec{{name: "DP-1", width: 16, height: 8, scale: 1, version: 4}},
31+ private: p,
32+ }
33+}
34+
35+func TestSessionUnsupportedWithoutGlobal(t *testing.T) {
36+ s, _ := newSource(t, compositorConfig{})
37+ ctx := context.Background()
38+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
39+ require.ErrorIs(t, err, ErrSessionUnsupported)
40+ _, err = s.Workspaces(ctx)
41+ require.ErrorIs(t, err, ErrSessionUnsupported)
42+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable)
43+ require.NoError(t, s.EndSession(ctx))
44+ // Standard capture is unaffected and the Source stays usable.
45+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
46+ require.NoError(t, err)
47+}
48+
49+func TestBeginSessionOutputAndRegion(t *testing.T) {
50+ s, comp := newSource(t, privateCfg(privateConfig{}))
51+ ctx := context.Background()
52+ out := firstOutput(t, s)
53+
54+ st, err := s.BeginSession(ctx, ports.Target{OutputID: out.ID, Region: ports.Region{X: 2, Y: 1, Width: 4, Height: 3}}, true)
55+ require.NoError(t, err)
56+ require.True(t, st.Active)
57+ require.Equal(t, testToken, st.Token)
58+ require.EqualValues(t, 1, st.Revision)
59+ require.Equal(t, ports.Target{OutputID: out.ID, Region: ports.Region{X: 2, Y: 1, Width: 4, Height: 3}}, st.Target)
60+ require.Equal(t, ports.Region{X: 2, Y: 1, Width: 4, Height: 3}, st.Region)
61+ require.Equal(t, [4]int32{2, 1, 4, 3}, comp.snap().requested)
62+ require.EqualValues(t, 1, comp.snap().record)
63+
64+ _, err = s.BeginSession(ctx, ports.Target{OutputID: out.ID}, false)
65+ require.ErrorIs(t, err, ErrSessionActive)
66+
67+ require.NoError(t, s.EndSession(ctx))
68+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.privDestroyed }))
69+ require.NoError(t, s.EndSession(ctx)) // idempotent
70+
71+ st, err = s.BeginSession(ctx, ports.Target{OutputID: out.ID}, false) // full output
72+ require.NoError(t, err)
73+ require.Equal(t, ports.Region{X: 0, Y: 0, Width: 16, Height: 8}, st.Target.Region)
74+ require.EqualValues(t, 0, comp.snap().record)
75+}
76+
77+func TestBeginSessionReturnsFirstStateThenResumes(t *testing.T) {
78+ s, _ := newSource(t, privateCfg(privateConfig{inactive: true}))
79+ ctx := context.Background()
80+ st, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
81+ require.NoError(t, err)
82+ require.False(t, st.Active, "the first state was paused")
83+ require.EqualValues(t, 1, st.Revision)
84+ _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // the resume is read here
85+ require.NoError(t, err)
86+}
87+
88+func TestBeginSessionRefused(t *testing.T) {
89+ s, _ := newSource(t, privateCfg(privateConfig{refuse: true}))
90+ ctx := context.Background()
91+ _, err := s.BeginSession(ctx, ports.Target{OutputID: firstOutput(t, s).ID}, true)
92+ require.ErrorIs(t, err, ErrSessionUnavailable)
93+ var stopped *SessionStoppedError
94+ require.ErrorAs(t, err, &stopped)
95+ require.Equal(t, StopBusy, stopped.Reason)
96+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable)
97+ _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // the connection survived
98+ require.NoError(t, err)
99+}
100+
101+func TestBeginSessionInvalidTargets(t *testing.T) {
102+ s, comp := newSource(t, privateCfg(privateConfig{}))
103+ ctx := context.Background()
104+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 99}, true)
105+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
106+ _, err = s.BeginSession(ctx, ports.Target{OutputID: 1, Region: ports.Region{Width: -1, Height: 1}}, true)
107+ require.ErrorIs(t, err, ports.ErrInvalidRegion)
108+ _, err = s.BeginSession(ctx, ports.Target{WorkspaceID: 42}, true)
109+ require.ErrorIs(t, err, ErrWorkspaceNotFound)
110+ require.Zero(t, comp.stat(func(c *compositor) int { return c.privSessions }))
111+}
112+
113+func TestWorkspacesFromAdvertisements(t *testing.T) {
114+ cfg := privateCfg(privateConfig{workspaces: []workspaceSpec{
115+ {id: 1<<32 | 7, output: "DP-1", name: "main", region: [4]int32{0, 0, 16, 8}, active: true},
116+ {id: 5, output: "DP-1", name: "other", region: [4]int32{0, 0, 16, 8}},
117+ {id: 6, output: "GONE", name: "orphan", region: [4]int32{0, 0, 16, 8}},
118+ {id: 8, output: "DP-1", name: "bad", region: [4]int32{0, 0, 0, 8}},
119+ }})
120+ s, _ := newSource(t, cfg)
121+ list, err := s.Workspaces(context.Background())
122+ require.NoError(t, err)
123+ require.Equal(t, []ports.Workspace{
124+ {ID: 5, OutputID: 1, Name: "other", Region: ports.Region{Width: 16, Height: 8}},
125+ {ID: 1<<32 | 7, OutputID: 1, Name: "main", Region: ports.Region{Width: 16, Height: 8}, Active: true},
126+ }, list)
127+}
128+
129+func TestWorkspaceSessionAndLiveRegion(t *testing.T) {
130+ const id = 1<<32 | 7
131+ cfg := privateCfg(privateConfig{workspaces: []workspaceSpec{
132+ {id: id, output: "DP-1", name: "main", region: [4]int32{0, 0, 16, 8}, active: true},
133+ {id: 9, output: "DP-1", name: "off", region: [4]int32{0, 0, 16, 8}},
134+ }})
135+ s, comp := newSource(t, cfg)
136+ ctx := context.Background()
137+
138+ st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: id}, true)
139+ require.NoError(t, err)
140+ require.Equal(t, ports.Target{OutputID: 1, WorkspaceID: id}, st.Target, "a workspace target has a zero region")
141+ require.Equal(t, ports.Region{Width: 16, Height: 8}, st.Region)
142+
143+ // The chain the application runs: Begin, then Capture(state.Target).
144+ f, err := s.Capture(ctx, st.Target)
145+ require.NoError(t, err)
146+ require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height})
147+ require.EqualValues(t, id, comp.snap().workspace)
148+
149+ f, err = s.Capture(ctx, ports.Target{WorkspaceID: id}) // OutputID may be omitted
150+ require.NoError(t, err)
151+ require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height})
152+ require.Equal(t, [4]int32{0, 0, 16, 8}, comp.snap().lastRegion)
153+ require.Equal(t, [4]int32{}, comp.snap().requested, "a workspace session requests no region")
154+
155+ // The compositor moves the workspace: the next frame follows the live region.
156+ comp.pushState([4]int32{4, 2, 8, 4}, id)
157+ f, err = s.Capture(ctx, st.Target) // the same target, still valid
158+ require.NoError(t, err)
159+ require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height})
160+ require.Equal(t, [4]int32{4, 2, 8, 4}, comp.snap().lastRegion)
161+
162+ // A workspace other than the session's needs a private render path.
163+ _, err = s.Capture(ctx, ports.Target{WorkspaceID: 9})
164+ require.ErrorIs(t, err, ErrSessionUnsupported)
165+ _, err = s.Capture(ctx, ports.Target{WorkspaceID: id, Region: ports.Region{X: 1, Y: 1, Width: 1, Height: 1}})
166+ require.ErrorIs(t, err, ports.ErrInvalidRegion)
167+}
168+
169+func TestWorkspaceCaptureNeedsSession(t *testing.T) {
170+ s, _ := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{
171+ {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true},
172+ }}))
173+ _, err := s.Capture(context.Background(), ports.Target{WorkspaceID: 3})
174+ require.ErrorIs(t, err, ErrSessionUnsupported)
175+}
176+
177+// The compositor serves a workspace that is not on screen: the session is
178+// requested by identity with a zero region, whatever the inventory says.
179+func TestBeginHiddenWorkspace(t *testing.T) {
180+ s, comp := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{
181+ {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}},
182+ }}))
183+ ctx := context.Background()
184+ st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true)
185+ require.NoError(t, err)
186+ require.Equal(t, ports.Target{OutputID: 1, WorkspaceID: 3}, st.Target)
187+ require.EqualValues(t, 3, comp.snap().workspace)
188+ require.Equal(t, [4]int32{}, comp.snap().requested)
189+ _, err = s.Capture(ctx, st.Target)
190+ require.NoError(t, err)
191+}
192+
193+// The session stays pinned to its output and workspace whatever the inventory
194+// announces or removes afterwards.
195+func TestSessionPinnedAgainstInventoryChanges(t *testing.T) {
196+ cfg := privateCfg(privateConfig{workspaces: []workspaceSpec{
197+ {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true},
198+ }})
199+ cfg.outputs = append(cfg.outputs, outputSpec{name: "DP-2", width: 32, height: 16, scale: 1, version: 4})
200+ s, comp := newSource(t, cfg)
201+ ctx := context.Background()
202+ st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true)
203+ require.NoError(t, err)
204+ require.EqualValues(t, 1, st.Target.OutputID)
205+
206+ comp.announceWorkspace(workspaceSpec{id: 3, output: "DP-2", region: [4]int32{0, 0, 32, 16}, active: true})
207+ comp.removeWorkspace(3)
208+ f, err := s.Capture(ctx, st.Target)
209+ require.NoError(t, err, "a removed workspace is not consulted")
210+ require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}, "still the pinned output")
211+ f, err = s.Capture(ctx, ports.Target{OutputID: 1, WorkspaceID: 3})
212+ require.NoError(t, err)
213+ _, err = s.Capture(ctx, ports.Target{OutputID: 2, WorkspaceID: 3})
214+ require.ErrorIs(t, err, ports.ErrOutputNotFound)
215+
216+ // The removal is followed by the stop: the last grant is never used after.
217+ comp.stopSession(uint32(StopWorkspaceGone))
218+ _, err = s.Capture(ctx, st.Target)
219+ require.ErrorIs(t, err, ErrSessionUnavailable)
220+ var stopped *SessionStoppedError
221+ require.ErrorAs(t, err, &stopped)
222+ require.Equal(t, StopWorkspaceGone, stopped.Reason)
223+}
224+
225+func TestPausedSessionIsInactive(t *testing.T) {
226+ s, comp := newSource(t, privateCfg(privateConfig{pausedStart: true}))
227+ ctx := context.Background()
228+ st, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
229+ require.NoError(t, err)
230+ require.False(t, st.Active)
231+ captures := comp.stat(func(c *compositor) int { return c.captures })
232+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
233+ require.ErrorIs(t, err, ErrSessionInactive)
234+ require.Equal(t, captures, comp.stat(func(c *compositor) int { return c.captures }), "no capture while paused")
235+
236+ comp.pushState([4]int32{0, 0, 16, 8}, 0) // resumes
237+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
238+ require.NoError(t, err)
239+}
240+
241+func TestCaptureRetriesCompositorFailureOnce(t *testing.T) {
242+ cfg := privateCfg(privateConfig{})
243+ cfg.failCopies = 1
244+ s, comp := newSource(t, cfg)
245+ ctx := context.Background()
246+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
247+ require.NoError(t, err)
248+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
249+ require.NoError(t, err)
250+ require.Equal(t, 2, comp.stat(func(c *compositor) int { return c.copies }))
251+
252+ comp.mu.Lock()
253+ comp.cfg.failCopies = 5
254+ comp.mu.Unlock()
255+ before := comp.stat(func(c *compositor) int { return c.copies })
256+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
257+ require.Error(t, err)
258+ require.NotErrorIs(t, err, ErrSessionUnavailable)
259+ require.Equal(t, before+2, comp.stat(func(c *compositor) int { return c.copies }), "one retry, no more")
260+}
261+
262+func TestOutputCaptureUnderSession(t *testing.T) {
263+ s, comp := newSource(t, privateCfg(privateConfig{}))
264+ ctx := context.Background()
265+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
266+ require.NoError(t, err)
267+ f, err := s.Capture(ctx, ports.Target{OutputID: 1})
268+ require.NoError(t, err)
269+ require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height})
270+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.captures }))
271+}
272+
273+func TestPingSession(t *testing.T) {
274+ s, comp := newSource(t, privateCfg(privateConfig{}))
275+ ctx := context.Background()
276+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable) // none active
277+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
278+ require.NoError(t, err)
279+ require.NoError(t, s.PingSession(ctx))
280+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.privPings }))
281+}
282+
283+func TestCapturePingsOnlyWhenDue(t *testing.T) {
284+ s, comp := newSource(t, privateCfg(privateConfig{}))
285+ ctx := context.Background()
286+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
287+ require.NoError(t, err)
288+
289+ for range 3 {
290+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
291+ require.NoError(t, err)
292+ }
293+ require.Zero(t, comp.stat(func(c *compositor) int { return c.privPings }), "no ping inside the interval")
294+
295+ s.pingEvery = 0
296+ for range 3 {
297+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
298+ require.NoError(t, err)
299+ }
300+ require.NoError(t, s.PingSession(ctx)) // round trip: every earlier ping was received
301+ require.Equal(t, 4, comp.stat(func(c *compositor) int { return c.privPings }))
302+}
303+
304+func TestStopDuringSessionFailsCaptureAndPing(t *testing.T) {
305+ s, comp := newSource(t, privateCfg(privateConfig{}))
306+ ctx := context.Background()
307+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
308+ require.NoError(t, err)
309+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
310+ require.NoError(t, err)
311+
312+ comp.stopSession(3)
313+ // Dispatching the stop happens inside the operation that reads it.
314+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable)
315+ captures := comp.stat(func(c *compositor) int { return c.captures })
316+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
317+ require.ErrorIs(t, err, ErrSessionUnavailable)
318+ require.Equal(t, captures, comp.stat(func(c *compositor) int { return c.captures }), "no frame is requested after the stop")
319+ require.NoError(t, s.EndSession(ctx))
320+ // A new session can follow.
321+ _, err = s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
322+ require.NoError(t, err)
323+}
324+
325+func TestStoppedSessionStaysFailClosedUntilEnded(t *testing.T) {
326+ s, comp := newSource(t, privateCfg(privateConfig{}))
327+ ctx := context.Background()
328+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
329+ require.NoError(t, err)
330+ comp.stopSession(uint32(StopWorkspaceGone))
331+ // The stop is read inside the first operation; a frame copied meanwhile is
332+ // discarded. Nothing is requested afterwards, and never a plain capture.
333+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
334+ require.ErrorIs(t, err, ErrSessionUnavailable)
335+ captures := comp.stat(func(c *compositor) int { return c.captures })
336+ for range 3 {
337+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
338+ require.ErrorIs(t, err, ErrSessionUnavailable)
339+ }
340+ var stopped *SessionStoppedError
341+ require.ErrorAs(t, err, &stopped)
342+ require.Equal(t, StopWorkspaceGone, stopped.Reason)
343+ require.Equal(t, captures, comp.stat(func(c *compositor) int { return c.captures }))
344+ _, err = s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
345+ require.ErrorIs(t, err, ErrSessionActive, "the owner must end the stopped session first")
346+ require.NoError(t, s.EndSession(ctx))
347+ require.Equal(t, 1, comp.stat(func(c *compositor) int { return c.privDestroyed }))
348+ _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // no session: plain capture again
349+ require.NoError(t, err)
350+}
351+
352+func TestStopArrivingWithFrameDiscardsFrame(t *testing.T) {
353+ s, _ := newSource(t, privateCfg(privateConfig{stopOnPing: true}))
354+ ctx := context.Background()
355+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
356+ require.NoError(t, err)
357+ s.pingEvery = 0
358+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
359+ require.ErrorIs(t, err, ErrSessionUnavailable)
360+ require.NoError(t, s.EndSession(ctx))
361+}
362+
363+func TestRevisionGoingBackIsProtocolViolation(t *testing.T) {
364+ s, _ := newSource(t, privateCfg(privateConfig{badRevision: true, inactive: true}))
365+ ctx := context.Background()
366+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
367+ require.NoError(t, err)
368+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable)
369+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
370+ require.ErrorIs(t, err, ErrSessionUnavailable)
371+}
372+
373+func TestZeroRevisionIsProtocolViolation(t *testing.T) {
374+ s, _ := newSource(t, privateCfg(privateConfig{zeroRevision: true}))
375+ ctx := context.Background()
376+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
377+ require.ErrorIs(t, err, ErrSessionUnavailable)
378+}
379+
380+func TestMalformedTokenRefusesSession(t *testing.T) {
381+ for _, tok := range []string{"short", testToken[:63] + "G", testToken + "0"} {
382+ s, _ := newSource(t, privateCfg(privateConfig{token: tok}))
383+ _, err := s.BeginSession(context.Background(), ports.Target{OutputID: 1}, true)
384+ require.ErrorIs(t, err, ErrSessionUnavailable, tok)
385+ }
386+}
387+
388+func TestTokenChangeIsProtocolViolation(t *testing.T) {
389+ s, _ := newSource(t, privateCfg(privateConfig{swapToken: true}))
390+ ctx := context.Background()
391+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
392+ require.NoError(t, err)
393+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable)
394+ _, err = s.Capture(ctx, ports.Target{OutputID: 1})
395+ require.ErrorIs(t, err, ErrSessionUnavailable)
396+}
397+
398+func TestSessionStateHidesToken(t *testing.T) {
399+ st := SessionState{Token: testToken, Active: true}
400+ for _, s := range []string{fmt.Sprint(st), fmt.Sprintf("%v", st), fmt.Sprintf("%+v", st), fmt.Sprintf("%#v", st)} {
401+ require.NotContains(t, s, testToken)
402+ }
403+}
404+
405+func TestSessionOperationsAfterClose(t *testing.T) {
406+ s, _ := newSource(t, privateCfg(privateConfig{}))
407+ require.NoError(t, s.Close())
408+ ctx := context.Background()
409+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
410+ require.ErrorIs(t, err, ports.ErrClosed)
411+ require.ErrorIs(t, s.PingSession(ctx), ports.ErrClosed)
412+ require.ErrorIs(t, s.EndSession(ctx), ports.ErrClosed)
413+ _, err = s.Workspaces(ctx)
414+ require.ErrorIs(t, err, ports.ErrClosed)
415+}
416+
417+func TestBeginSessionCancelled(t *testing.T) {
418+ s, _ := newSource(t, privateCfg(privateConfig{}))
419+ ctx, cancel := context.WithCancel(context.Background())
420+ cancel()
421+ _, err := s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
422+ require.ErrorIs(t, err, context.Canceled)
423+}
424+
425+func secondConnection(t *testing.T, comp *compositor) (*wlturbo.Display, *core.Surface) {
426+ t.Helper()
427+ conn, err := net.Dial("unix", comp.path)
428+ require.NoError(t, err)
429+ display, err := wlturbo.ConnectFromConn(conn)
430+ require.NoError(t, err)
431+ t.Cleanup(func() { _ = display.Close() })
432+ require.NoError(t, display.Roundtrip())
433+ wlComp := core.NewCompositor(display.Context())
434+ if _, err = display.Registry().BindNegotiated("wl_compositor", 4, wlComp); err != nil {
435+ return display, core.NewSurface(display.Context()) // peer without wl_compositor
436+ }
437+ surface, err := wlComp.CreateSurface()
438+ require.NoError(t, err)
439+ return display, surface
440+}
441+
442+func TestAuthorizeLayerWaitsForAttached(t *testing.T) {
443+ comp := startCompositor(t, privateCfg(privateConfig{}))
444+ display, surface := secondConnection(t, comp)
445+ require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testToken, nil))
446+ require.Equal(t, []string{testToken}, comp.privAttachedCopy())
447+}
448+
449+func TestAuthorizeLayerAttachFailed(t *testing.T) {
450+ for reason, want := range map[int]AttachFailure{
451+ 1: AttachUnknownToken, 2: AttachUnauthorized, 3: AttachTooManyLayers,
452+ 4: AttachAlreadyAttached, 5: AttachSessionEnded, 6: AttachLayerDestroyed,
453+ } {
454+ comp := startCompositor(t, privateCfg(privateConfig{attachFail: reason}))
455+ display, surface := secondConnection(t, comp)
456+ err := AuthorizeLayer(context.Background(), display, surface, testToken, nil)
457+ require.ErrorIs(t, err, ErrAttachFailed)
458+ var failed *AttachFailedError
459+ require.ErrorAs(t, err, &failed)
460+ require.Equal(t, want, failed.Reason)
461+ require.Equal(t, want == AttachUnknownToken || want == AttachSessionEnded, errors.Is(err, ErrSessionUnavailable))
462+ require.Equal(t, want == AttachUnauthorized, errors.Is(err, ErrUnauthorized))
463+ require.NotContains(t, err.Error(), testToken)
464+ }
465+}
466+
467+// Two attachments on one connection are told apart by their own objects: a
468+// detach of the first, arriving while the second authorizes, reaches only the
469+// first callback.
470+func TestAuthorizeLayerDetachedCallback(t *testing.T) {
471+ comp := startCompositor(t, privateCfg(privateConfig{}))
472+ display, first := secondConnection(t, comp)
473+ var got atomic.Pointer[LayerDetachedError]
474+ calls := make(chan struct{}, 4)
475+ require.NoError(t, AuthorizeLayer(context.Background(), display, first, testToken, func(err error) {
476+ var d *LayerDetachedError
477+ if errors.As(err, &d) {
478+ got.Store(d)
479+ }
480+ calls <- struct{}{}
481+ }))
482+ require.Nil(t, got.Load(), "no detach before it happens")
483+
484+ comp.detachLayer(0, uint32(DetachSessionEnded))
485+ second := core.NewSurface(display.Context())
486+ otherCalls := 0
487+ require.NoError(t, AuthorizeLayer(context.Background(), display, second, testToken, func(error) { otherCalls++ }))
488+ // The reader goroutine of the GUI delivers the callback; here the test
489+ // drives the connection until it arrives.
490+ for len(calls) == 0 {
491+ require.NoError(t, display.Roundtrip())
492+ }
493+ d := got.Load()
494+ require.NotNil(t, d)
495+ require.Equal(t, DetachSessionEnded, d.Reason)
496+ require.ErrorIs(t, d, ErrSessionUnavailable)
497+ require.Zero(t, otherCalls)
498+ require.Len(t, calls, 1)
499+ require.NotContains(t, d.Error(), testToken)
500+
501+ // The second layer's own detach reaches only its own callback.
502+ comp.detachLayer(1, uint32(DetachLayerDestroyed))
503+ require.NoError(t, display.Roundtrip())
504+ require.Equal(t, 1, otherCalls)
505+ require.Len(t, calls, 1)
506+}
507+
508+func TestAuthorizeLayerContextCancelled(t *testing.T) {
509+ comp := startCompositor(t, privateCfg(privateConfig{attachSilent: true}))
510+ display, surface := secondConnection(t, comp)
511+ ctx, cancel := context.WithCancel(context.Background())
512+ done := make(chan error, 1)
513+ go func() { done <- AuthorizeLayer(ctx, display, surface, testToken, nil) }()
514+ require.Eventually(t, func() bool { return len(comp.privAttachedCopy()) == 1 }, 5*time.Second, time.Millisecond)
515+ cancel()
516+ require.ErrorIs(t, <-done, context.Canceled)
517+ require.True(t, display.Closed())
518+
519+ // A context that is already cancelled sends nothing.
520+ comp2 := startCompositor(t, privateCfg(privateConfig{}))
521+ display2, surface2 := secondConnection(t, comp2)
522+ require.ErrorIs(t, AuthorizeLayer(ctx, display2, surface2, testToken, nil), context.Canceled)
523+ require.Empty(t, comp2.privAttachedCopy())
524+}
525+
526+func TestAuthorizeLayerCancelAfterSuccessKeepsConnection(t *testing.T) {
527+ comp := startCompositor(t, privateCfg(privateConfig{}))
528+ display, surface := secondConnection(t, comp)
529+ ctx, cancel := context.WithCancel(context.Background())
530+ require.NoError(t, AuthorizeLayer(ctx, display, surface, testToken, nil))
531+ cancel() // the watcher is gone: the GUI owns the connection now
532+ require.NoError(t, display.Roundtrip(), "the server still answers")
533+ require.False(t, display.Closed())
534+}
535+
536+// The deadline lands around the delayed answer. Whatever wins, the result and
537+// the connection agree: success leaves it open, a failure closed it.
538+func TestAuthorizeLayerDeadlineRacingTheAnswer(t *testing.T) {
539+ const delay = 2 * time.Millisecond
540+ var ok, failed int
541+ for range 100 {
542+ comp := startCompositor(t, privateCfg(privateConfig{attachDelay: delay}))
543+ display, surface := secondConnection(t, comp)
544+ err := authorizeLayer(context.Background(), display, surface, testToken, nil, delay)
545+ if err == nil {
546+ ok++
547+ require.False(t, display.Closed())
548+ continue
549+ }
550+ failed++
551+ require.ErrorIs(t, err, context.DeadlineExceeded)
552+ require.True(t, display.Closed())
553+ }
554+ t.Logf("success %d, deadline %d", ok, failed)
555+}
556+
557+func TestAuthorizeLayerTimesOutSilently(t *testing.T) {
558+ comp := startCompositor(t, privateCfg(privateConfig{attachSilent: true}))
559+ display, surface := secondConnection(t, comp)
560+ err := authorizeLayer(context.Background(), display, surface, testToken, nil, 100*time.Millisecond)
561+ require.ErrorIs(t, err, context.DeadlineExceeded)
562+ require.True(t, display.Closed())
563+}
564+
565+func TestAuthorizeLayerReportsProtocolError(t *testing.T) {
566+ comp := startCompositor(t, privateCfg(privateConfig{attachError: true}))
567+ display, surface := secondConnection(t, comp)
568+ err := AuthorizeLayer(context.Background(), display, surface, testToken, nil)
569+ require.ErrorIs(t, err, wlturbo.ErrDisplayError)
570+ var derr *wlturbo.DisplayError
571+ require.ErrorAs(t, err, &derr)
572+ require.EqualValues(t, 2, derr.Code)
573+}
574+
575+func TestAuthorizeLayerRejectsBadInput(t *testing.T) {
576+ require.Error(t, AuthorizeLayer(context.Background(), nil, nil, testToken, nil))
577+ comp := startCompositor(t, privateCfg(privateConfig{}))
578+ display, surface := secondConnection(t, comp)
579+ for _, tok := range []string{"", "has space", "new\nline", testToken[:63], testToken + "0", testToken[:63] + "G", testToken[:63] + "A"} {
580+ require.Error(t, AuthorizeLayer(context.Background(), display, surface, tok, nil), "%q", tok)
581+ }
582+ require.Empty(t, comp.privAttachedCopy())
583+}
584+
585+func TestAuthorizeLayerWithoutGlobal(t *testing.T) {
586+ comp := startCompositor(t, compositorConfig{})
587+ display, surface := secondConnection(t, comp)
588+ require.ErrorIs(t, AuthorizeLayer(context.Background(), display, surface, testToken, nil), ErrSessionUnsupported)
589+}
590+
591+// TestSessionLiveHeadless runs against a real compositor when
592+// NEFERCAP_TEST_WAYLAND_PRIVATE names its socket. It skips otherwise, and when
593+// that compositor lacks the private protocol.
594+func TestSessionLiveHeadless(t *testing.T) {
595+ socket := os.Getenv("NEFERCAP_TEST_WAYLAND_PRIVATE")
596+ if socket == "" {
597+ t.Skip("NEFERCAP_TEST_WAYLAND_PRIVATE is not set")
598+ }
599+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
600+ defer cancel()
601+ s, err := New(ctx, socket)
602+ require.NoError(t, err)
603+ defer s.Close()
604+ outs, err := s.Outputs(ctx)
605+ require.NoError(t, err)
606+ require.NotEmpty(t, outs)
607+ st, err := s.BeginSession(ctx, ports.Target{OutputID: outs[0].ID}, false)
608+ if err != nil {
609+ require.ErrorIs(t, err, ErrSessionUnsupported)
610+ t.Skip("compositor has no private capture protocol")
611+ }
612+ require.True(t, st.Active)
613+ require.NotEmpty(t, st.Token)
614+ require.NoError(t, s.PingSession(ctx))
615+ _, err = s.Capture(ctx, ports.Target{OutputID: outs[0].ID})
616+ require.NoError(t, err)
617+ require.NoError(t, s.EndSession(ctx))
618+}
619+
620+func TestGrantValidation(t *testing.T) {
621+ region := ports.Region{X: 2, Y: 1, Width: 4, Height: 3}
622+ for name, grant := range map[string][4]int32{
623+ "zero width": {2, 1, 0, 3},
624+ "negative width": {2, 1, -4, 3},
625+ "oversized": {0, 0, 1 << 20, 3},
626+ "sum overflow": {2147483647, 1, 2147483647, 3},
627+ "outside region": {0, 0, 4, 3},
628+ "larger": {2, 1, 5, 3},
629+ } {
630+ s, _ := newSource(t, privateCfg(privateConfig{stateRegion: &grant}))
631+ _, err := s.BeginSession(context.Background(), ports.Target{OutputID: 1, Region: region}, true)
632+ require.ErrorIs(t, err, ErrSessionUnavailable, name)
633+ }
634+ // A grant inside the requested region is fine, and so is any bounded grant
635+ // of a full-output request (its logical size is not known exactly).
636+ inside := [4]int32{3, 1, 2, 2}
637+ s, _ := newSource(t, privateCfg(privateConfig{stateRegion: &inside}))
638+ st, err := s.BeginSession(context.Background(), ports.Target{OutputID: 1, Region: region}, true)
639+ require.NoError(t, err)
640+ require.Equal(t, ports.Region{X: 3, Y: 1, Width: 2, Height: 2}, st.Region)
641+ zero := [4]int32{0, 0, 0, 0}
642+ s, _ = newSource(t, privateCfg(privateConfig{stateRegion: &zero}))
643+ _, err = s.BeginSession(context.Background(), ports.Target{OutputID: 1}, true)
644+ require.ErrorIs(t, err, ErrSessionUnavailable, "a zero grant is not the full output")
645+}
646+
647+func TestLaterStateForOtherWorkspaceFailsClosed(t *testing.T) {
648+ s, comp := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{
649+ {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true},
650+ }}))
651+ ctx := context.Background()
652+ _, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true)
653+ require.NoError(t, err)
654+ comp.pushState([4]int32{0, 0, 16, 8}, 4)
655+ require.ErrorIs(t, s.PingSession(ctx), ErrSessionUnavailable)
656+ _, err = s.Capture(ctx, ports.Target{WorkspaceID: 3})
657+ require.ErrorIs(t, err, ErrSessionUnavailable)
658+}
659+
660+func TestGeometryKeepsChanging(t *testing.T) {
661+ s, comp := newSource(t, privateCfg(privateConfig{moveEachCapture: true, workspaces: []workspaceSpec{
662+ {id: 3, output: "DP-1", region: [4]int32{0, 0, 16, 8}, active: true},
663+ }}))
664+ ctx := context.Background()
665+ st, err := s.BeginSession(ctx, ports.Target{WorkspaceID: 3}, true)
666+ require.NoError(t, err)
667+ f, err := s.Capture(ctx, st.Target)
668+ require.ErrorIs(t, err, ErrSessionGeometryChanging)
669+ require.Zero(t, f.Width, "no stale frame")
670+ require.Equal(t, maxRegionRetries+1, comp.stat(func(c *compositor) int { return c.captures }))
671+ require.NoError(t, s.EndSession(ctx))
672+}
673+
674+func TestHostileMetadataDisablesPrivateCapability(t *testing.T) {
675+ long := strings.Repeat("a", maxNameLen+1)
676+ var many []workspaceSpec
677+ for i := range maxWorkspaces + 1 {
678+ many = append(many, workspaceSpec{id: uint64(i + 1), output: "DP-1", region: [4]int32{0, 0, 16, 8}})
679+ }
680+ for name, ws := range map[string][]workspaceSpec{
681+ "control": {{id: 1, output: "DP-1", name: "a\nb", region: [4]int32{0, 0, 16, 8}}},
682+ "bidi": {{id: 1, output: "DP-1", name: "a\u202eb", region: [4]int32{0, 0, 16, 8}}},
683+ "utf8": {{id: 1, output: "DP-1", name: "a\xffb", region: [4]int32{0, 0, 16, 8}}},
684+ "long": {{id: 1, output: "DP-1", name: long, region: [4]int32{0, 0, 16, 8}}},
685+ "empty out": {{id: 1, output: "", name: "x", region: [4]int32{0, 0, 16, 8}}},
686+ "too many": many,
687+ } {
688+ s, _ := newSource(t, privateCfg(privateConfig{workspaces: ws}))
689+ ctx := context.Background()
690+ _, err := s.Workspaces(ctx)
691+ require.ErrorIs(t, err, ErrServerMetadata, name)
692+ require.ErrorIs(t, err, ErrSessionUnavailable, name)
693+ _, err = s.BeginSession(ctx, ports.Target{OutputID: 1}, true)
694+ require.ErrorIs(t, err, ErrServerMetadata, name)
695+ _, err = s.Capture(ctx, ports.Target{OutputID: 1}) // standard capture is untouched
696+ require.NoError(t, err, name)
697+ }
698+ // Legitimate unicode names are fine.
699+ s, _ := newSource(t, privateCfg(privateConfig{workspaces: []workspaceSpec{
700+ {id: 1, output: "DP-1", name: "espace de travail é ✓", region: [4]int32{0, 0, 16, 8}},
701+ }}))
702+ list, err := s.Workspaces(context.Background())
703+ require.NoError(t, err)
704+ require.Len(t, list, 1)
705+}
706+
707+func TestSessionStateJSONOmitsToken(t *testing.T) {
708+ st := SessionState{Token: testToken, Revision: 3, Active: true}
709+ for _, v := range []any{st, &st, struct{ S SessionState }{st}, map[string]any{"session": st}} {
710+ b, err := json.Marshal(v)
711+ require.NoError(t, err)
712+ require.NotContains(t, string(b), testToken)
713+ require.NotContains(t, strings.ToLower(string(b)), "token")
714+ }
715+}
716+
717+// layerScript runs AuthorizeLayer against a peer that answers with events.
718+func layerScript(t *testing.T, script [][2]uint32, onDetached func(error)) (*wlturbo.Display, error) {
719+ t.Helper()
720+ comp := startCompositor(t, privateCfg(privateConfig{attachScript: script}))
721+ display, surface := secondConnection(t, comp)
722+ return display, AuthorizeLayer(context.Background(), display, surface, testToken, onDetached)
723+}
724+
725+func TestLayerDetachedBeforeAckIsViolation(t *testing.T) {
726+ display, err := layerScript(t, [][2]uint32{{2, 0}}, nil)
727+ require.ErrorIs(t, err, ErrSessionUnavailable)
728+ require.True(t, display.Closed())
729+}
730+
731+// After a refusal the attachment object is destroyed: a late event for it is
732+// dropped, it is neither a violation nor a reason to close the connection.
733+func TestLateEventOnRetiredLayerIsDropped(t *testing.T) {
734+ display, err := layerScript(t, [][2]uint32{{1, 0}, {1, 0}}, nil)
735+ var failed *AttachFailedError
736+ require.ErrorAs(t, err, &failed)
737+ require.NoError(t, display.Roundtrip())
738+ require.False(t, display.Closed())
739+}
740+
741+func TestLayerStateMachineViolationsAfterAck(t *testing.T) {
742+ for name, script := range map[string][][2]uint32{
743+ "attached twice": {{0, 0}, {0, 0}},
744+ "failed after attach": {{0, 0}, {1, 0}},
745+ "detached then attach": {{0, 0}, {2, 0}, {0, 0}},
746+ } {
747+ got := make(chan error, 4)
748+ display, err := layerScript(t, script, func(e error) { got <- e })
749+ require.NoError(t, err, name)
750+ for range 3 {
751+ _ = display.Roundtrip() // the reader of the GUI connection
752+ }
753+ if name == "detached then attach" {
754+ var d *LayerDetachedError
755+ require.ErrorAs(t, recv(t, got), &d, name)
756+ require.True(t, display.Closed(), "the late event closes the connection")
757+ require.Empty(t, got, "the callback ran once")
758+ continue
759+ }
760+ require.ErrorIs(t, recv(t, got), ErrSessionUnavailable, name)
761+ require.True(t, display.Closed(), name)
762+ require.Empty(t, got, "reported once")
763+ }
764+}
765+
766+func TestAuthorizeLayerRejectsForeignSurface(t *testing.T) {
767+ comp := startCompositor(t, privateCfg(privateConfig{}))
768+ display, _ := secondConnection(t, comp)
769+ _, foreign := secondConnection(t, startCompositor(t, privateCfg(privateConfig{})))
770+ require.Error(t, AuthorizeLayer(context.Background(), display, foreign, testToken, nil))
771+ require.Empty(t, comp.privAttachedCopy())
772+}
773+
774+func recv(t *testing.T, ch <-chan error) error {
775+ t.Helper()
776+ select {
777+ case err := <-ch:
778+ return err
779+ case <-time.After(5 * time.Second):
780+ t.Fatal("no callback")
781+ return nil
782+ }
783+}
M internal/adapters/wayland/compositor_test.go +299 -2
@@ -8,6 +8,7 @@ import (
88 "sync"
99 "syscall"
1010 "testing"
11+ "time"
1112
1213 "golang.org/x/sys/unix"
1314 )
@@ -26,11 +27,18 @@ const (
2627 kindBuffer
2728 kindScreencopy
2829 kindFrame
30+ kindPrivManager
31+ kindPrivSession
32+ kindPrivLayer
33+ kindCompositor
34+ kindSurface
2935 )
3036
3137 const (
3238 globalShm = 1000
3339 globalScreencopy = 1001
40+ globalPrivate = 1002
41+ globalWlComp = 1003
3442 )
3543
3644 type outputSpec struct {
@@ -57,6 +65,46 @@ type compositorConfig struct {
5765 readyEarly bool // send ready right after the buffer events, before any copy
5866 noShmOffer bool // send only buffer_done: no wl_shm buffer is offered
5967 duplicateGlobal bool // announce the first output global twice
68+
69+ private privateConfig
70+}
71+
72+// workspaceSpec is one private-protocol workspace advertisement.
73+type workspaceSpec struct {
74+ id uint64
75+ output string
76+ name string
77+ region [4]int32
78+ active bool
79+}
80+
81+// privateConfig configures the private capture protocol of the peer. The wire
82+// layout is the client's provisional one, see capture_session.go.
83+type privateConfig struct {
84+ enabled bool
85+ token string // empty selects a default
86+ workspaces []workspaceSpec
87+ refuse bool // answer begin_session with stopped
88+ inactive bool // send an inactive state before the active one
89+ stopOnPing bool // answer ping with stopped
90+ swapToken bool // send a second state carrying a different token
91+ badRevision bool // send a state whose revision goes back
92+ zeroRevision bool // send a state with revision 0
93+ pausedStart bool // the first state is paused (active 0) and no active follows
94+ attachFail int // non-zero: answer attach_surface with attach_failed(value-1)
95+ attachSilent bool // never answer attach_surface
96+ attachDelay time.Duration // delay before attached
97+ attachScript [][2]uint32 // layer events (opcode, argument) sent instead of attached
98+ moveEachCapture bool // push a new region with every capture request
99+ attachError bool // answer attach_surface with wl_display.error(code 2)
100+ stateRegion *[4]int32
101+}
102+
103+func (p privateConfig) tok() string {
104+ if p.token == "" {
105+ return testToken
106+ }
107+ return p.token
60108 }
61109
62110 type poolMap struct{ data []byte }
@@ -89,8 +137,22 @@ type compositor struct {
89137 buffer *bufferInfo
90138 }
91139 hasPending bool
92- copyEvent chan struct{}
93- done chan struct{}
140+
141+ // private protocol observations
142+ privSessions int
143+ privPings int
144+ privDestroyed int
145+ privRecord uint32
146+ privRequested [4]int32
147+ privWorkspace uint64
148+ privAttached []string
149+ privSessionID uint32
150+ privRev uint64
151+ privLayers []uint32
152+ privManager uint32
153+ privLayerGone int
154+ copyEvent chan struct{}
155+ done chan struct{}
94156
95157 wmu sync.Mutex
96158 out [4096]byte
@@ -298,6 +360,22 @@ func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds *
298360 if op <= 1 {
299361 c.capture(o, op, body)
300362 }
363+ case kindPrivManager:
364+ c.handlePrivManager(o, id, op, body)
365+ case kindPrivSession:
366+ c.handlePrivSession(o, id, op)
367+ case kindPrivLayer:
368+ if op == 0 { // destroy
369+ c.mu.Lock()
370+ c.privLayerGone++
371+ c.mu.Unlock()
372+ c.send(1, 1, id)
373+ delete(o.kinds, id)
374+ }
375+ case kindCompositor:
376+ if op == 0 {
377+ o.kinds[word(body, 0)] = kindSurface
378+ }
301379 case kindFrame:
302380 switch op {
303381 case 0:
@@ -319,6 +397,10 @@ func (c *compositor) advertise() {
319397 c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version)
320398 }
321399 c.sendGlobal(globalShm, "wl_shm", 1)
400+ if c.cfg.private.enabled {
401+ c.sendGlobal(globalPrivate, captureManagerIface, 1)
402+ c.sendGlobal(globalWlComp, "wl_compositor", 4)
403+ }
322404 if !c.cfg.noScreencopy {
323405 c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion)
324406 }
@@ -359,6 +441,16 @@ func (c *compositor) bind(o *objects, name uint32, iface string, version, id uin
359441 c.send(id, 0, 1)
360442 case "zwlr_screencopy_manager_v1":
361443 o.kinds[id] = kindScreencopy
444+ case "wl_compositor":
445+ o.kinds[id] = kindCompositor
446+ case captureManagerIface:
447+ o.kinds[id] = kindPrivManager
448+ c.mu.Lock()
449+ c.privManager = id
450+ c.mu.Unlock()
451+ for _, w := range c.cfg.private.workspaces {
452+ c.sendWorkspace(id, w)
453+ }
362454 default:
363455 c.t.Errorf("bind of unexpected interface %q", iface)
364456 }
@@ -375,12 +467,17 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
375467 readyEarly, noShmOffer := c.cfg.readyEarly, c.cfg.noShmOffer
376468 list, version := c.cfg.announce, c.cfg.screencopyVersion
377469 c.captures++
470+ move, moveWS := c.cfg.private.moveEachCapture, c.privWorkspace
471+ moveN := int32(c.captures)
378472 if op == 1 {
379473 c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))}
380474 c.lastRegionSet = true
381475 w, h = word(body, 5), word(body, 6)
382476 }
383477 c.mu.Unlock()
478+ if move {
479+ c.pushState([4]int32{moveN, 0, 8, 4}, moveWS)
480+ }
384481 o.kinds[frame] = kindFrame
385482 o.frames[frame] = &frameState{output: out, w: w, h: h}
386483 if removeOnCapture {
@@ -536,3 +633,203 @@ func (c *compositor) flush(m []byte) {
536633 return
537634 }
538635 }
636+
637+// Private capture protocol peer (provisional layout, see capture_session.go).
638+
639+func (c *compositor) sendWorkspace(mgr uint32, w workspaceSpec) {
640+ c.wmu.Lock()
641+ m := c.begin(mgr, 0)
642+ m = c.words(m, uint32(w.id>>32), uint32(w.id))
643+ m = c.str(m, w.output)
644+ m = c.str(m, w.name)
645+ m = c.words(m, uint32(w.region[0]), uint32(w.region[1]), uint32(w.region[2]), uint32(w.region[3]))
646+ act := uint32(0)
647+ if w.active {
648+ act = 1
649+ }
650+ m = c.words(m, act)
651+ c.flush(m)
652+ c.wmu.Unlock()
653+}
654+
655+func (c *compositor) nextRevision() uint64 {
656+ c.mu.Lock()
657+ defer c.mu.Unlock()
658+ c.privRev++
659+ return c.privRev
660+}
661+
662+func (c *compositor) sendState(session uint32, token string, active bool, r [4]int32, ws uint64) {
663+ rev := c.nextRevision()
664+ if c.cfg.private.zeroRevision {
665+ rev = 0
666+ }
667+ if c.cfg.private.badRevision {
668+ rev = 10 - rev // 9, 8: the revision goes back
669+ }
670+ c.wmu.Lock()
671+ m := c.begin(session, 0)
672+ m = c.str(m, token)
673+ act := uint32(0)
674+ if active {
675+ act = 1
676+ }
677+ m = c.words(m, act, uint32(r[0]), uint32(r[1]), uint32(r[2]), uint32(r[3]), uint32(ws>>32), uint32(ws), uint32(rev>>32), uint32(rev))
678+ c.flush(m)
679+ c.wmu.Unlock()
680+}
681+
682+// pushState sends a state event on the current session, as a live geometry
683+// change would.
684+func (c *compositor) pushState(r [4]int32, ws uint64) {
685+ c.mu.Lock()
686+ id := c.privSessionID
687+ c.mu.Unlock()
688+ c.sendState(id, c.cfg.private.tok(), true, r, ws)
689+}
690+
691+func (c *compositor) stopSession(reason uint32) {
692+ c.mu.Lock()
693+ id := c.privSessionID
694+ c.mu.Unlock()
695+ c.send(id, 1, reason)
696+}
697+
698+func (c *compositor) handlePrivManager(o *objects, mgrID uint32, op uint16, body []byte) {
699+ switch op {
700+ case 1: // begin_session
701+ session := word(body, 0)
702+ out := c.cfg.outputs[o.outputs[word(body, 1)]]
703+ r := [4]int32{int32(word(body, 2)), int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5))}
704+ ws := uint64(word(body, 6))<<32 | uint64(word(body, 7))
705+ o.kinds[session] = kindPrivSession
706+ c.mu.Lock()
707+ c.privSessions++
708+ c.privSessionID = session
709+ c.privRecord = word(body, 8)
710+ c.privRequested = r
711+ c.privWorkspace = ws
712+ p := c.cfg.private
713+ c.mu.Unlock()
714+ if p.refuse {
715+ c.send(session, 1, 6) // busy
716+ return
717+ }
718+ if r == [4]int32{} {
719+ r = [4]int32{0, 0, int32(out.width), int32(out.height)}
720+ }
721+ for _, w := range c.cfg.private.workspaces {
722+ if w.id == ws && ws != 0 {
723+ r = w.region // a workspace target is its whole frame
724+ }
725+ }
726+ if p.stateRegion != nil {
727+ r = *p.stateRegion
728+ }
729+ if p.inactive || p.pausedStart {
730+ c.sendState(session, p.tok(), false, r, ws)
731+ }
732+ if !p.pausedStart {
733+ c.sendState(session, p.tok(), true, r, ws)
734+ }
735+ if p.swapToken {
736+ c.sendState(session, p.tok()+"x", true, r, ws)
737+ }
738+ case 2: // attach_surface(new_id layer, token, surface)
739+ layer := word(body, 0)
740+ n := int(word(body, 1))
741+ tok := string(body[8 : 8+n-1])
742+ o.kinds[layer] = kindPrivLayer
743+ c.mu.Lock()
744+ c.privAttached = append(c.privAttached, tok)
745+ c.privLayers = append(c.privLayers, layer)
746+ p := c.cfg.private
747+ c.mu.Unlock()
748+ switch {
749+ case p.attachSilent:
750+ case p.attachError:
751+ c.wmu.Lock()
752+ m := c.begin(1, 0)
753+ m = c.words(m, mgrID, 2)
754+ m = c.str(m, "surface_role")
755+ c.flush(m)
756+ c.wmu.Unlock()
757+ case p.attachScript != nil:
758+ for _, ev := range p.attachScript {
759+ if ev[0] == 0 {
760+ c.send(layer, 0)
761+ } else {
762+ c.send(layer, uint16(ev[0]), ev[1])
763+ }
764+ }
765+ case p.attachFail != 0:
766+ c.send(layer, 1, uint32(p.attachFail-1))
767+ default:
768+ time.Sleep(p.attachDelay)
769+ c.send(layer, 0)
770+ }
771+ case 0: // destroy
772+ c.send(1, 1, mgrID)
773+ delete(o.kinds, mgrID)
774+ }
775+}
776+
777+func (c *compositor) handlePrivSession(o *objects, id uint32, op uint16) {
778+ switch op {
779+ case 0:
780+ c.mu.Lock()
781+ c.privDestroyed++
782+ c.mu.Unlock()
783+ c.send(1, 1, id)
784+ delete(o.kinds, id)
785+ case 1:
786+ c.mu.Lock()
787+ c.privPings++
788+ stop := c.cfg.private.stopOnPing
789+ c.mu.Unlock()
790+ if stop {
791+ c.send(id, 1, 2)
792+ }
793+ }
794+}
795+
796+func (c *compositor) privAttachedCopy() []string {
797+ c.mu.Lock()
798+ defer c.mu.Unlock()
799+ return append([]string(nil), c.privAttached...)
800+}
801+
802+type privSnap struct {
803+ requested [4]int32
804+ record uint32
805+ workspace uint64
806+ lastRegion [4]int32
807+}
808+
809+func (c *compositor) snap() privSnap {
810+ c.mu.Lock()
811+ defer c.mu.Unlock()
812+ return privSnap{c.privRequested, c.privRecord, c.privWorkspace, c.lastRegion}
813+}
814+
815+// detachLayer sends neferwl_capture_layer_v1.detached on the n-th attachment.
816+func (c *compositor) detachLayer(n int, reason uint32) {
817+ c.mu.Lock()
818+ id := c.privLayers[n]
819+ c.mu.Unlock()
820+ c.send(id, 2, reason)
821+}
822+
823+func (c *compositor) announceWorkspace(w workspaceSpec) {
824+ c.mu.Lock()
825+ id := c.privManager
826+ c.mu.Unlock()
827+ c.sendWorkspace(id, w)
828+}
829+
830+func (c *compositor) removeWorkspace(id uint64) {
831+ c.mu.Lock()
832+ m := c.privManager
833+ c.mu.Unlock()
834+ c.send(m, 1, uint32(id>>32), uint32(id))
835+}
M internal/adapters/wayland/screencopy.go +1 -1
@@ -135,7 +135,7 @@ func (s *Source) capture(t ports.Target) (ports.Frame, error) {
135135 if f.out.removed {
136136 return ports.Frame{}, ports.ErrOutputNotFound
137137 }
138- return ports.Frame{}, errors.New("wayland: compositor failed the capture")
138+ return ports.Frame{}, errCompositorFailedCapture
139139 case f.ready && !f.copied:
140140 s.terminate()
141141 return ports.Frame{}, errors.New("wayland: compositor completed a frame that was never copied")
M internal/adapters/wayland/source.go +18 -10
@@ -13,6 +13,7 @@ import (
1313 "slices"
1414 "sync"
1515 "sync/atomic"
16+ "time"
1617
1718 "github.com/bnema/wlturbo"
1819 "github.com/bnema/wlturbo/protocol/core"
@@ -73,6 +74,13 @@ type Source struct {
7374 outputs map[uint32]*output
7475 bound int // wl_output globals bound so far
7576 buf shmBuffer
77+
78+ // Private capture session state, see capture_session.go.
79+ private *captureManager // nil when the compositor lacks the protocol
80+ workspaces map[uint64]*workspace
81+ metaErr error // set once the private inventory was rejected
82+ session *captureSession
83+ pingEvery time.Duration
7684 }
7785
7886 var _ ports.Source = (*Source)(nil)
@@ -97,10 +105,11 @@ func New(ctx context.Context, socket string) (*Source, error) {
97105 return nil, fmt.Errorf("wayland: %w", err)
98106 }
99107 s := &Source{
100- log: logging.For(ctx, "wayland"),
101- display: display,
102- wl: display.Context(),
103- outputs: make(map[uint32]*output),
108+ log: logging.For(ctx, "wayland"),
109+ display: display,
110+ wl: display.Context(),
111+ outputs: make(map[uint32]*output),
112+ pingEvery: sessionPingInterval,
104113 }
105114 stop := context.AfterFunc(ctx, s.terminate)
106115 err = s.discover()
@@ -150,6 +159,9 @@ func (s *Source) discover() error {
150159 if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil {
151160 return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err)
152161 }
162+ if err := s.bindCaptureManager(); err != nil {
163+ return err
164+ }
153165 return s.roundtrip()
154166 }
155167
@@ -312,11 +324,7 @@ func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) {
312324 if err == nil {
313325 list = make([]ports.Output, 0, len(s.outputs))
314326 for _, o := range s.outputs {
315- name := o.name
316- if name == "" { // wl_output older than version 4 has no name
317- name = fmt.Sprintf("output-%d", o.id)
318- }
319- list = append(list, ports.Output{ID: o.id, Name: name, Width: int(o.width), Height: int(o.height), Scale: int(o.scale)})
327+ list = append(list, ports.Output{ID: o.id, Name: outputName(o), Width: int(o.width), Height: int(o.height), Scale: int(o.scale)})
320328 }
321329 slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) })
322330 }
@@ -337,7 +345,7 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro
337345 if err := s.begin(ctx); err != nil {
338346 return ports.Frame{}, err
339347 }
340- frame, err := s.capture(t)
348+ frame, err := s.captureTarget(t)
341349 if err = s.finish(ctx, err); err != nil {
342350 return ports.Frame{}, err
343351 }
M internal/app/app.go +40 -7
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/bnema/nefercap/internal/adapters/cli"
1212 "github.com/bnema/nefercap/internal/adapters/ffmpeg"
13- "github.com/bnema/nefercap/internal/adapters/gui"
1413 "github.com/bnema/nefercap/internal/adapters/png"
1514 "github.com/bnema/nefercap/internal/adapters/wayland"
1615 "github.com/bnema/nefercap/internal/core"
@@ -22,6 +21,9 @@ const discoveryTimeout = 5 * time.Second
2221
2322 // Run owns the source connection and all capture work for one invocation.
2423 func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) {
24+ if handled, controlErr := controlCommand(ctx, options.Command, output); handled {
25+ return controlErr
26+ }
2527 discovery, cancel := context.WithTimeout(ctx, discoveryTimeout)
2628 source, err := wayland.New(discovery, "")
2729 if err != nil {
@@ -47,9 +49,12 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
4749 return nil
4850 }
4951 var selection ports.Selection
50- if options.Command == cli.GUI {
52+ if options.Command == cli.Shot || options.Command == cli.Rec {
53+ if err := rejectCompetingSelector(ctx); err != nil {
54+ return err
55+ }
5156 var accepted bool
52- selection, accepted, err = gui.New().Select(ctx, outputs)
57+ selection, accepted, err = chooseInteractive(ctx, source, outputs, options)
5358 if err != nil || !accepted {
5459 return err
5560 }
@@ -70,12 +75,40 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
7075 }
7176 log := logging.For(ctx, "app")
7277 log.Info().Str("mode", string(selection.Mode)).Msg("capture started")
73- err = core.New(source, png.New(), ffmpeg.New()).Run(ctx, selection)
74- if err != nil {
78+ var saved bool
79+ if options.Command == cli.Rec || options.Command == cli.Record {
80+ var outcome recordingOutcome
81+ outcome, err = recordInteractive(ctx, source, outputs, selection)
82+ saved = outcome.Saved
83+ } else {
84+ if selection.Target.WorkspaceID != 0 {
85+ state, sessionErr := source.BeginSession(ctx, selection.Target, false)
86+ if sessionErr != nil {
87+ return sessionError(ctx, false, sessionErr)
88+ }
89+ selection.Target = state.Target
90+ }
91+ err = core.New(source, png.New(), ffmpeg.New()).Run(ctx, selection)
92+ saved = err == nil
93+ }
94+ if saved && err == nil {
95+ log.Info().Msg("capture complete")
96+ }
97+ return finishCapture(output, selection.Path, saved, err)
98+}
99+
100+// finishCapture prints the path of a file that exists, whatever error the
101+// capture ended with, so a reported failure never hides where the file is.
102+// Nothing is printed when no file was saved. The original error and a failed
103+// write are both returned.
104+func finishCapture(output io.Writer, path string, saved bool, err error) error {
105+ if !saved {
75106 return err
76107 }
77- log.Info().Msg("capture complete")
78- return nil
108+ if _, printErr := fmt.Fprintln(output, path); printErr != nil {
109+ return errors.Join(err, printErr)
110+ }
111+ return err
79112 }
80113
81114 // discoveryError preserves a user interrupt, but reports local timeouts and
A internal/app/control.go +60 -0
@@ -0,0 +1,60 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+
9+ "github.com/bnema/nefercap/internal/adapters/cli"
10+ "github.com/bnema/nefercap/internal/adapters/control"
11+)
12+
13+// controlCommand resolves commands that must work without opening a GUI or
14+// connecting to Wayland. A rec invocation is a stop toggle when already active.
15+func controlCommand(ctx context.Context, command cli.Command, output io.Writer) (handled bool, err error) {
16+ switch command {
17+ case cli.Rec, cli.Stop:
18+ stopped, err := control.Stop(ctx)
19+ if err != nil {
20+ return true, err
21+ }
22+ if command == cli.Stop {
23+ if !stopped {
24+ return true, errors.New("no recording is active")
25+ }
26+ return true, nil
27+ }
28+ return stopped, nil
29+ case cli.Status:
30+ active, err := control.Query(ctx)
31+ if err != nil {
32+ return true, err
33+ }
34+ state := "idle"
35+ if active {
36+ state = "recording"
37+ }
38+ _, err = fmt.Fprintln(output, state)
39+ return true, err
40+ default:
41+ return false, nil
42+ }
43+}
44+
45+// stopOnRequest has one bounded lifetime per recording; the completion channel
46+// is joined before closing the control server. No request starts a new worker.
47+func stopOnRequest(ctx context.Context, requests <-chan control.Request, cancel context.CancelFunc) <-chan struct{} {
48+ done := make(chan struct{})
49+ go func() {
50+ defer close(done)
51+ select {
52+ case <-ctx.Done():
53+ case request := <-requests:
54+ if request == control.RequestStop {
55+ cancel()
56+ }
57+ }
58+ }()
59+ return done
60+}
A internal/app/control_test.go +65 -0
@@ -0,0 +1,65 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/adapters/cli"
12+ "github.com/bnema/nefercap/internal/adapters/control"
13+)
14+
15+func TestControlCommands(t *testing.T) {
16+ runtime := filepath.Join(t.TempDir(), "runtime")
17+ if err := os.Mkdir(runtime, 0o700); err != nil {
18+ t.Fatal(err)
19+ }
20+ t.Setenv("XDG_RUNTIME_DIR", runtime)
21+ var out bytes.Buffer
22+ ctx := context.Background()
23+ if handled, err := controlCommand(ctx, cli.Status, &out); !handled || err != nil || out.String() != "idle\n" {
24+ t.Fatalf("idle: %t %v %s", handled, err, out.String())
25+ }
26+ if handled, err := controlCommand(ctx, cli.Rec, &out); handled || err != nil {
27+ t.Fatalf("idle rec: %t %v", handled, err)
28+ }
29+ server, err := control.Listen()
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer server.Close()
34+ out.Reset()
35+ if handled, err := controlCommand(ctx, cli.Status, &out); !handled || err != nil || out.String() != "recording\n" {
36+ t.Fatalf("active: %t %v %s", handled, err, out.String())
37+ }
38+ if handled, err := controlCommand(ctx, cli.Rec, &out); !handled || err != nil {
39+ t.Fatalf("toggle: %t %v", handled, err)
40+ }
41+ select {
42+ case got := <-server.Requests():
43+ if got != control.RequestStop {
44+ t.Fatalf("request: %v", got)
45+ }
46+ case <-time.After(time.Second):
47+ t.Fatal("stop not delivered")
48+ }
49+}
50+
51+func TestStopOnRequest(t *testing.T) {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ defer cancel()
54+ requests := make(chan control.Request, 1)
55+ done := stopOnRequest(ctx, requests, cancel)
56+ requests <- control.RequestStop
57+ select {
58+ case <-done:
59+ case <-time.After(time.Second):
60+ t.Fatal("control waiter did not stop")
61+ }
62+ if ctx.Err() != context.Canceled {
63+ t.Fatal("stop did not cancel recording")
64+ }
65+}
A internal/app/interactive.go +69 -0
@@ -0,0 +1,69 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/nefercap/internal/adapters/cli"
9+ "github.com/bnema/nefercap/internal/adapters/control"
10+ "github.com/bnema/nefercap/internal/adapters/destination"
11+ "github.com/bnema/nefercap/internal/adapters/selection"
12+ "github.com/bnema/nefercap/internal/adapters/uierrors"
13+ "github.com/bnema/nefercap/internal/adapters/wayland"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// chooseInteractive uses native workspace metadata only when the compositor
18+// exposes it. Monitor and region screenshots still work with standard capture.
19+func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, options cli.Options) (ports.Selection, bool, error) {
20+ mode := ports.Screenshot
21+ if options.Command == cli.Rec {
22+ mode = ports.Record
23+ }
24+ if options.Output != "" {
25+ chosen, err := resolveOutput(outputs, options.Output)
26+ if err != nil {
27+ return ports.Selection{}, false, err
28+ }
29+ outputs = []ports.Output{chosen}
30+ }
31+ workspaces, err := source.Workspaces(ctx)
32+ if err != nil && !errors.Is(err, wayland.ErrSessionUnsupported) {
33+ if ctx.Err() != nil && uierrors.IsCancellation(err) {
34+ return ports.Selection{}, false, ctx.Err()
35+ }
36+ return ports.Selection{}, false, fmt.Errorf("list workspace capture targets: %w", err)
37+ }
38+ sel, accepted, err := selection.New(mode, options.Video, workspaces).Select(ctx, outputs)
39+ if err != nil || !accepted {
40+ return ports.Selection{}, false, err
41+ }
42+ sel.Duration = options.Duration
43+ sel.Path = options.Path
44+ if sel.Path == "" {
45+ if mode == ports.Record {
46+ sel.Path, err = destination.Video()
47+ } else {
48+ sel.Path, err = destination.Screenshot()
49+ }
50+ if err != nil {
51+ return ports.Selection{}, false, err
52+ }
53+ }
54+ return sel, true, nil
55+}
56+
57+func rejectCompetingSelector(ctx context.Context) error {
58+ active, err := control.Query(ctx)
59+ if err != nil {
60+ return err
61+ }
62+ if active {
63+ // A selector on another capture connection is not automatically part
64+ // of the recording's excluded set. Refuse rather than leak it into
65+ // an existing recording until cross-session selection is authorized.
66+ return errors.New("recording is active; stop it before opening a capture selector")
67+ }
68+ return nil
69+}
A internal/app/record_interactive.go +140 -0
@@ -0,0 +1,140 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/nefercap/internal/adapters/control"
9+ "github.com/bnema/nefercap/internal/adapters/ffmpeg"
10+ "github.com/bnema/nefercap/internal/adapters/indicator"
11+ "github.com/bnema/nefercap/internal/adapters/uierrors"
12+ "github.com/bnema/nefercap/internal/adapters/wayland"
13+ "github.com/bnema/nefercap/internal/core"
14+ "github.com/bnema/nefercap/internal/ports"
15+ "github.com/bnema/nefergui"
16+)
17+
18+// recordInteractive records with the HUD and the control socket. A stop
19+// requested by the user is success; the outcome says whether a file exists.
20+func recordInteractive(ctx context.Context, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) {
21+ session, err := source.BeginSession(ctx, sel.Target, true)
22+ if err != nil {
23+ return outcome, sessionError(ctx, true, err)
24+ }
25+ sel.Target = session.Target
26+ var output ports.Output
27+ for _, candidate := range outputs {
28+ if candidate.ID == sel.Target.OutputID {
29+ output = candidate
30+ break
31+ }
32+ }
33+ if output.ID == 0 {
34+ return outcome, ports.ErrOutputNotFound
35+ }
36+ server, err := control.Listen()
37+ if err != nil {
38+ return outcome, err
39+ }
40+ defer func() {
41+ if closeErr := server.Close(); closeErr != nil {
42+ err = errors.Join(err, closeErr)
43+ }
44+ }()
45+
46+ recordCtx, cancelRecord := context.WithCancelCause(ctx)
47+ defer cancelRecord(nil)
48+ hudCtx, cancelHUD := context.WithCancel(ctx)
49+ defer cancelHUD()
50+ life := newLifecycle(ctx, cancelRecord, cancelHUD)
51+ hudStop := make(chan struct{}, 1)
52+ watchCtx, stopWatch := context.WithCancel(context.Background())
53+ controlDone := stopOnRequest(watchCtx, server.Requests(), life.userStop)
54+ buttonDone := stopOnSignal(watchCtx, hudStop, life.userStop)
55+ defer func() { stopWatch(); <-controlDone; <-buttonDone }()
56+
57+ hud := startHUD(hudCtx, output, session.Token, hudStop, life)
58+ end := ending{}
59+ started := false
60+ // Do not start the encoder until the compositor acknowledges exclusion.
61+ // A protocol roundtrip alone does not prove the core applied the marking.
62+ select {
63+ case <-hud.ready:
64+ started = true
65+ captureDone := make(chan error, 1)
66+ go func() { captureDone <- core.New(source, nil, ffmpeg.New()).Run(recordCtx, sel) }()
67+ select {
68+ case end.runErr = <-captureDone:
69+ life.beginShutdown()
70+ cancelHUD()
71+ end.hudErr = <-hud.done
72+ case hudErr := <-hud.done:
73+ end.hudErr = life.hudEnded(hudErr, false)
74+ end.runErr = <-captureDone
75+ }
76+ case hudErr := <-hud.done:
77+ end.hudErr = life.hudEnded(hudErr, !authorized(hud.ready))
78+ case <-recordCtx.Done():
79+ life.beginShutdown()
80+ cancelHUD()
81+ end.hudErr = <-hud.done
82+ }
83+ end.failure = life.failed()
84+ end.parentErr = ctx.Err()
85+ end.userStop = context.Cause(recordCtx) == errUserStop
86+ err = classify(end)
87+ if started && end.runErr == nil {
88+ // Frames were written: the file exists whatever the HUD reported after.
89+ outcome.Saved = savedFile(sel.Path)
90+ }
91+ return outcome, err
92+}
93+
94+// sessionError classifies a BeginSession failure. The caller's own
95+// cancellation stays the bare context error, a missing native capability is
96+// named for a recording, and every other failure gets the phase and keeps its
97+// identity.
98+func sessionError(ctx context.Context, record bool, err error) error {
99+ if ctx.Err() != nil && uierrors.IsCancellation(err) {
100+ return ctx.Err()
101+ }
102+ if record && errors.Is(err, wayland.ErrSessionUnsupported) {
103+ return fmt.Errorf("recording indicator requires native capture-session support: %w", err)
104+ }
105+ return fmt.Errorf("begin capture session: %w", err)
106+}
107+
108+// authorized reports, without blocking, whether the HUD signalled ready. Ready
109+// and done can both be pending; a HUD that was authorized did not close early.
110+func authorized(ready <-chan struct{}) bool {
111+ select {
112+ case <-ready:
113+ return true
114+ default:
115+ return false
116+ }
117+}
118+
119+// hudHandle is the owner's view of a running HUD: ready fires once when the
120+// compositor acknowledged the exclusion, done delivers Run's result once.
121+type hudHandle struct {
122+ ready <-chan struct{}
123+ done <-chan error
124+}
125+
126+// startHUD runs the indicator on its own goroutine. Its detach callback and
127+// button only signal the lifecycle; they never touch the recording.
128+func startHUD(ctx context.Context, output ports.Output, token string, stop chan<- struct{}, life *lifecycle) hudHandle {
129+ ready := make(chan struct{}, 1)
130+ done := make(chan error, 1)
131+ authorize := func(hookCtx context.Context, surface nefergui.WaylandSurface) error {
132+ if err := wayland.AuthorizeLayer(hookCtx, surface.Display, surface.Surface, token, life.detached); err != nil {
133+ return err
134+ }
135+ ready <- struct{}{}
136+ return nil
137+ }
138+ go func() { done <- indicator.New(authorize, stop).Run(ctx, output) }()
139+ return hudHandle{ready: ready, done: done}
140+}
A internal/app/record_lifecycle.go +175 -0
@@ -0,0 +1,175 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "sync/atomic"
8+
9+ "github.com/bnema/nefercap/internal/adapters/uierrors"
10+)
11+
12+var (
13+ // errUserStop is the cancellation cause of a stop requested by the user
14+ // (control command or HUD button). It is not a failure.
15+ errUserStop = errors.New("recording stopped by user")
16+ // errHUDClosed and errHUDClosedEarly report a HUD that ended although
17+ // nobody asked it to.
18+ errHUDClosed = errors.New("recording controls closed unexpectedly")
19+ errHUDClosedEarly = errors.New("recording controls closed before authorization")
20+)
21+
22+// recordingOutcome says what a recording left behind, independently of the
23+// error: only a Saved recording has a path worth printing.
24+type recordingOutcome struct{ Saved bool }
25+
26+// lifecycle is the state shared by the recording owner, the stop watchers and
27+// the compositor's detach callback. Everything here is safe for concurrent use
28+// and non-blocking. It never touches the Source: the owner goroutine alone
29+// runs capture, so borrowed pixels and protocol state stay single-owner.
30+//
31+// The recording context carries the first cause that ended it: errUserStop for
32+// a requested stop, or the first real failure. A failure is also kept in a
33+// one-slot channel so it survives a parent cancellation that won the context.
34+type lifecycle struct {
35+ parent context.Context
36+ cancelRecord context.CancelCauseFunc
37+ cancelHUD context.CancelFunc
38+ shuttingDown atomic.Bool
39+ failure chan error
40+}
41+
42+func newLifecycle(parent context.Context, cancelRecord context.CancelCauseFunc, cancelHUD context.CancelFunc) *lifecycle {
43+ return &lifecycle{parent: parent, cancelRecord: cancelRecord, cancelHUD: cancelHUD, failure: make(chan error, 1)}
44+}
45+
46+// userStop ends the recording and its HUD without a failure. The flag is set
47+// first so the detach the HUD teardown provokes is recognised as an echo.
48+func (l *lifecycle) userStop() {
49+ l.shuttingDown.Store(true)
50+ l.cancelRecord(errUserStop)
51+ l.cancelHUD()
52+}
53+
54+// beginShutdown marks the owner's own teardown; call it before stopping the HUD.
55+func (l *lifecycle) beginShutdown() { l.shuttingDown.Store(true) }
56+
57+// fail records the first real failure, and ends recording and HUD with it as
58+// the cause. It reports whether err was the first.
59+func (l *lifecycle) fail(err error) bool {
60+ select {
61+ case l.failure <- err:
62+ default:
63+ return false
64+ }
65+ l.cancelRecord(err)
66+ l.cancelHUD()
67+ return true
68+}
69+
70+// detached is the compositor's layer-detach callback. A detach that arrives
71+// before shutdown begins is real: the exclusion is gone and recording must
72+// stop. One that follows our own shutdown or the parent's cancellation is the
73+// echo of closing the HUD and is ignored.
74+func (l *lifecycle) detached(err error) {
75+ if l.shuttingDown.Load() || l.parent.Err() != nil {
76+ return
77+ }
78+ if err == nil {
79+ err = errHUDClosed
80+ }
81+ l.fail(err)
82+}
83+
84+// hudEnded handles a HUD that returned. If nobody asked it to stop, that is a
85+// failure: its own real error, or a closed error when it ended quietly. It
86+// returns the part of err that classify must still consider.
87+func (l *lifecycle) hudEnded(err error, early bool) error {
88+ if l.shuttingDown.Load() || l.parent.Err() != nil {
89+ return err
90+ }
91+ own := err != nil && !uierrors.IsCancellation(err)
92+ reason := err
93+ if !own {
94+ reason = errHUDClosed
95+ if early {
96+ reason = errHUDClosedEarly
97+ }
98+ }
99+ if !l.fail(reason) || !own {
100+ return err
101+ }
102+ return nil
103+}
104+
105+// failed returns the first recorded failure, if any. Call it once the
106+// recording context is done, so the slot has been written.
107+func (l *lifecycle) failed() error {
108+ select {
109+ case err := <-l.failure:
110+ return err
111+ default:
112+ return nil
113+ }
114+}
115+
116+// stopOnSignal turns one HUD button press into a user stop. Its lifetime is
117+// bounded by ctx and the completion channel is joined by the caller.
118+func stopOnSignal(ctx context.Context, signal <-chan struct{}, stop context.CancelFunc) <-chan struct{} {
119+ done := make(chan struct{})
120+ go func() {
121+ defer close(done)
122+ select {
123+ case <-ctx.Done():
124+ case <-signal:
125+ stop()
126+ }
127+ }()
128+ return done
129+}
130+
131+// ending gathers everything that explains how one recording ended.
132+type ending struct {
133+ runErr error // core.Run, nil when it did not run
134+ hudErr error // HUD result after it was stopped
135+ failure error // first real failure recorded by the lifecycle
136+ parentErr error // parent context error at the end
137+ userStop bool
138+}
139+
140+// classify maps an ending to the error of the invocation. Real failures win
141+// over any cancellation, including ones joined with a cancellation; a bare
142+// user stop is success; a bare parent cancellation returns the parent's error.
143+func classify(e ending) error {
144+ var real []error
145+ if e.failure != nil {
146+ real = append(real, e.failure)
147+ }
148+ for _, err := range []error{e.runErr, e.hudErr} {
149+ if err != nil && !uierrors.IsCancellation(err) {
150+ real = append(real, err)
151+ }
152+ }
153+ switch len(real) {
154+ case 0:
155+ case 1:
156+ return real[0]
157+ default:
158+ return errors.Join(real...)
159+ }
160+ switch {
161+ case e.userStop:
162+ return nil
163+ case e.parentErr != nil:
164+ return e.parentErr
165+ }
166+ return e.runErr
167+}
168+
169+// savedFile reports whether path is a non-empty regular file. Call it only
170+// after the core reported success: the writer reserves the path exclusively
171+// and removes it when aborting, so a file there is this recording.
172+func savedFile(path string) bool {
173+ info, err := os.Stat(path)
174+ return err == nil && info.Mode().IsRegular() && info.Size() > 0
175+}
A internal/app/record_lifecycle_test.go +322 -0
@@ -0,0 +1,322 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/mock"
15+
16+ "github.com/bnema/nefercap/internal/adapters/wayland"
17+ "github.com/bnema/nefercap/internal/core"
18+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
19+ "github.com/bnema/nefercap/internal/ports"
20+)
21+
22+// newTestLifecycle builds the coordinator over real contexts, as production does.
23+func newTestLifecycle(t *testing.T) (*lifecycle, context.Context, context.Context, context.CancelFunc) {
24+ t.Helper()
25+ parent, cancelParent := context.WithCancel(context.Background())
26+ t.Cleanup(cancelParent)
27+ recordCtx, cancelRecord := context.WithCancelCause(parent)
28+ t.Cleanup(func() { cancelRecord(nil) })
29+ hudCtx, cancelHUD := context.WithCancel(parent)
30+ t.Cleanup(cancelHUD)
31+ return newLifecycle(parent, cancelRecord, cancelHUD), recordCtx, hudCtx, cancelParent
32+}
33+
34+func TestUserStopIsNotAFailure(t *testing.T) {
35+ life, recordCtx, hudCtx, _ := newTestLifecycle(t)
36+ life.userStop()
37+ if context.Cause(recordCtx) != errUserStop || hudCtx.Err() == nil {
38+ t.Fatalf("stop did not end recording and HUD: %v", context.Cause(recordCtx))
39+ }
40+ // The HUD teardown provokes a detach; it is an echo, not a failure.
41+ life.detached(errors.New("layer detached"))
42+ if err := life.failed(); err != nil {
43+ t.Fatalf("echo recorded as failure: %v", err)
44+ }
45+ // Stopped before the HUD was ready: the HUD only reports its cancellation.
46+ err := classify(ending{hudErr: context.Canceled, failure: life.failed(), parentErr: nil, userStop: context.Cause(recordCtx) == errUserStop})
47+ if err != nil {
48+ t.Fatalf("stop before ready: %v", err)
49+ }
50+}
51+
52+func TestDetachAfterReadyKeepsRealError(t *testing.T) {
53+ life, recordCtx, hudCtx, _ := newTestLifecycle(t)
54+ detach := fmt.Errorf("compositor: %w", wayland.ErrSessionUnavailable)
55+ life.detached(detach)
56+ if recordCtx.Err() == nil || hudCtx.Err() == nil {
57+ t.Fatal("detach did not stop recording and HUD")
58+ }
59+ // The core observes only the cancellation the detach caused.
60+ err := classify(ending{runErr: fmt.Errorf("capture first frame: %w", context.Canceled), failure: life.failed(), userStop: context.Cause(recordCtx) == errUserStop})
61+ if !errors.Is(err, wayland.ErrSessionUnavailable) {
62+ t.Fatalf("detach error lost: %v", err)
63+ }
64+ // A second detach does not replace the first cause.
65+ life.detached(errors.New("later"))
66+ if cause := context.Cause(recordCtx); !errors.Is(cause, wayland.ErrSessionUnavailable) {
67+ t.Fatalf("first cause replaced: %v", cause)
68+ }
69+}
70+
71+func TestDetachDuringOwnShutdownIsIgnored(t *testing.T) {
72+ life, recordCtx, _, _ := newTestLifecycle(t)
73+ life.beginShutdown()
74+ life.detached(errors.New("late detach"))
75+ if err := life.failed(); err != nil || recordCtx.Err() != nil {
76+ t.Fatalf("late detach acted on: %v %v", err, recordCtx.Err())
77+ }
78+}
79+
80+func TestDetachAfterParentCancelIsIgnored(t *testing.T) {
81+ life, _, _, cancelParent := newTestLifecycle(t)
82+ cancelParent()
83+ life.detached(errors.New("echo"))
84+ if err := life.failed(); err != nil {
85+ t.Fatalf("echo of parent cancel recorded: %v", err)
86+ }
87+}
88+
89+func TestHUDEndedBeforeReady(t *testing.T) {
90+ // Closed with nothing real: a failure naming the missing authorization.
91+ life, _, _, _ := newTestLifecycle(t)
92+ life.hudEnded(nil, true)
93+ if err := classify(ending{failure: life.failed()}); !errors.Is(err, errHUDClosedEarly) {
94+ t.Fatalf("silent early close: %v", err)
95+ }
96+ // A real startup error is returned as itself, not masked by a generic one.
97+ life, _, _, _ = newTestLifecycle(t)
98+ real := errors.New("authorize refused")
99+ rest := life.hudEnded(real, true)
100+ err := classify(ending{hudErr: rest, failure: life.failed()})
101+ if !errors.Is(err, real) || errors.Is(err, errHUDClosedEarly) {
102+ t.Fatalf("startup error masked: %v", err)
103+ }
104+}
105+
106+func TestHUDEndedIgnoredOnceStopping(t *testing.T) {
107+ life, _, _, _ := newTestLifecycle(t)
108+ life.userStop()
109+ life.hudEnded(context.Canceled, false)
110+ if err := life.failed(); err != nil {
111+ t.Fatalf("stopped HUD counted as failure: %v", err)
112+ }
113+}
114+
115+func TestParentCancelBeforeReadyWithNilHUD(t *testing.T) {
116+ life, _, _, cancelParent := newTestLifecycle(t)
117+ cancelParent()
118+ // The HUD ended cleanly (nil) because the parent cancelled it.
119+ life.hudEnded(nil, true)
120+ err := classify(ending{failure: life.failed(), parentErr: context.Canceled})
121+ if err != context.Canceled {
122+ t.Fatalf("want bare context.Canceled, got %v", err)
123+ }
124+}
125+
126+func TestStopOnSignalStopsOnce(t *testing.T) {
127+ life, recordCtx, _, _ := newTestLifecycle(t)
128+ signal := make(chan struct{}, 1)
129+ watchCtx, stopWatch := context.WithCancel(context.Background())
130+ defer stopWatch()
131+ done := stopOnSignal(watchCtx, signal, life.userStop)
132+ signal <- struct{}{}
133+ select {
134+ case <-done:
135+ case <-time.After(time.Second):
136+ t.Fatal("signal watcher did not finish")
137+ }
138+ if context.Cause(recordCtx) != errUserStop {
139+ t.Fatalf("cause: %v", context.Cause(recordCtx))
140+ }
141+}
142+
143+func TestClassify(t *testing.T) {
144+ boom := errors.New("boom")
145+ cancelled := fmt.Errorf("capture first frame: %w", context.Canceled)
146+ tests := []struct {
147+ name string
148+ in ending
149+ want error
150+ is error
151+ }{
152+ {name: "user stop before first frame", in: ending{runErr: cancelled, userStop: true}},
153+ {name: "user stop after frames", in: ending{userStop: true}},
154+ {name: "parent cancel returns bare error", in: ending{runErr: cancelled, parentErr: context.Canceled}, want: context.Canceled},
155+ {name: "cancel plus encoder failure", in: ending{runErr: errors.Join(cancelled, fmt.Errorf("finalize video: %w", boom)), userStop: true}, is: boom},
156+ {name: "HUD failure with user stop", in: ending{runErr: cancelled, hudErr: boom, userStop: true}, is: boom},
157+ {name: "recorded failure beats parent cancel", in: ending{runErr: cancelled, failure: boom, parentErr: context.Canceled}, is: boom},
158+ {name: "both failures kept", in: ending{runErr: boom, failure: errHUDClosed}, is: errHUDClosed},
159+ }
160+ for _, tt := range tests {
161+ t.Run(tt.name, func(t *testing.T) {
162+ err := classify(tt.in)
163+ switch {
164+ case tt.is != nil:
165+ if !errors.Is(err, tt.is) {
166+ t.Fatalf("got %v, want it to wrap %v", err, tt.is)
167+ }
168+ case err != tt.want:
169+ t.Fatalf("got %v, want %v", err, tt.want)
170+ }
171+ })
172+ }
173+}
174+
175+func TestSessionError(t *testing.T) {
176+ live := context.Background()
177+ done, cancel := context.WithCancel(context.Background())
178+ cancel()
179+ for _, record := range []bool{true, false} {
180+ if err := sessionError(done, record, fmt.Errorf("begin: %w", context.Canceled)); err != context.Canceled {
181+ t.Fatalf("record=%t: cancellation not bare: %v", record, err)
182+ }
183+ real := errors.New("protocol failure")
184+ if err := sessionError(done, record, errors.Join(context.Canceled, real)); !errors.Is(err, real) || err == context.Canceled {
185+ t.Fatalf("record=%t: real failure masked by cancel: %v", record, err)
186+ }
187+ // A cancel error while the caller is live is not the caller's cancel.
188+ if err := sessionError(live, record, context.Canceled); err == context.Canceled {
189+ t.Fatalf("record=%t: foreign cancel treated as caller's", record)
190+ }
191+ generic := errors.New("protocol failure")
192+ if err := sessionError(live, record, generic); !errors.Is(err, generic) || err == generic {
193+ t.Fatalf("record=%t: phase missing: %v", record, err)
194+ }
195+ if err := sessionError(live, record, wayland.ErrWorkspaceNotFound); !errors.Is(err, wayland.ErrWorkspaceNotFound) || errors.Is(err, wayland.ErrSessionUnsupported) {
196+ t.Fatalf("record=%t: workspace error mislabeled: %v", record, err)
197+ }
198+ }
199+ recorded := sessionError(live, true, wayland.ErrSessionUnsupported)
200+ if !errors.Is(recorded, wayland.ErrSessionUnsupported) || !strings.Contains(recorded.Error(), "recording indicator") {
201+ t.Fatalf("recording unsupported not explained: %v", recorded)
202+ }
203+ if shot := sessionError(live, false, wayland.ErrSessionUnsupported); strings.Contains(shot.Error(), "recording indicator") {
204+ t.Fatalf("screenshot got recording text: %v", shot)
205+ }
206+}
207+
208+func TestAuthorized(t *testing.T) {
209+ ready := make(chan struct{}, 1)
210+ if authorized(ready) {
211+ t.Fatal("authorized before ready")
212+ }
213+ ready <- struct{}{}
214+ if !authorized(ready) {
215+ t.Fatal("ready not seen")
216+ }
217+}
218+
219+// A HUD that was authorized and then ended quietly is a plain close, not an
220+// early close.
221+func TestHUDEndedAfterAuthorization(t *testing.T) {
222+ life, _, _, _ := newTestLifecycle(t)
223+ ready := make(chan struct{}, 1)
224+ ready <- struct{}{}
225+ life.hudEnded(nil, !authorized(ready))
226+ if err := classify(ending{failure: life.failed()}); !errors.Is(err, errHUDClosed) || errors.Is(err, errHUDClosedEarly) {
227+ t.Fatalf("got %v", err)
228+ }
229+}
230+
231+func TestFinishCapture(t *testing.T) {
232+ detach := errors.New("layer detached")
233+ var out bytes.Buffer
234+ if err := finishCapture(&out, "/v/a.mp4", true, detach); err != detach || out.String() != "/v/a.mp4\n" {
235+ t.Fatalf("saved with failure: %v %q", err, out.String())
236+ }
237+ out.Reset()
238+ if err := finishCapture(&out, "/v/a.mp4", true, nil); err != nil || out.String() != "/v/a.mp4\n" {
239+ t.Fatalf("saved: %v %q", err, out.String())
240+ }
241+ out.Reset()
242+ if err := finishCapture(&out, "/v/a.mp4", false, context.Canceled); err != context.Canceled || out.Len() != 0 {
243+ t.Fatalf("unsaved: %v %q", err, out.String())
244+ }
245+ if err := finishCapture(&out, "/v/a.mp4", false, nil); err != nil || out.Len() != 0 {
246+ t.Fatalf("stopped early: %v %q", err, out.String())
247+ }
248+ // A read-only file is a real writer that always fails.
249+ readOnly, openErr := os.Open(os.DevNull)
250+ if openErr != nil {
251+ t.Fatal(openErr)
252+ }
253+ defer readOnly.Close()
254+ err := finishCapture(readOnly, "/v/a.mp4", true, detach)
255+ if !errors.Is(err, detach) || len(err.(interface{ Unwrap() []error }).Unwrap()) != 2 {
256+ t.Fatalf("joined error lost a part: %v", err)
257+ }
258+}
259+
260+func TestSavedFile(t *testing.T) {
261+ dir := t.TempDir()
262+ missing := filepath.Join(dir, "missing.mp4")
263+ empty := filepath.Join(dir, "empty.mp4")
264+ full := filepath.Join(dir, "full.mp4")
265+ if err := os.WriteFile(empty, nil, 0o600); err != nil {
266+ t.Fatal(err)
267+ }
268+ if err := os.WriteFile(full, []byte("x"), 0o600); err != nil {
269+ t.Fatal(err)
270+ }
271+ if savedFile(missing) || savedFile(empty) || savedFile(dir) || !savedFile(full) {
272+ t.Fatal("saved file detection wrong")
273+ }
274+}
275+
276+// The production core with generated ports: cancelling during the first
277+// capture aborts nothing and writes nothing, and the lifecycle calls it success.
278+func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {
279+ ctx, cancel := context.WithCancelCause(context.Background())
280+ defer cancel(nil)
281+ target := ports.Target{OutputID: 1}
282+ source := portsmocks.NewMockSource(t)
283+ source.EXPECT().Capture(ctx, target).RunAndReturn(func(c context.Context, _ ports.Target) (ports.Frame, error) {
284+ cancel(errUserStop)
285+ return ports.Frame{}, c.Err()
286+ })
287+ video := portsmocks.NewMockVideoWriter(t)
288+ path := filepath.Join(t.TempDir(), "never.mp4")
289+ runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}})
290+ if runErr == nil {
291+ t.Fatal("core reported success without a frame")
292+ }
293+ if err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop}); err != nil {
294+ t.Fatalf("first frame cancel is a failure: %v", err)
295+ }
296+ if savedFile(path) {
297+ t.Fatal("file exists")
298+ }
299+}
300+
301+// A finalize failure that surfaces beside the cancellation must reach the user.
302+func TestEncoderFailureAfterCancelIsKept(t *testing.T) {
303+ ctx, cancel := context.WithCancelCause(context.Background())
304+ defer cancel(nil)
305+ frame := ports.Frame{Pixels: make([]byte, 4*2*2), Width: 2, Height: 2, Stride: 8, Format: ports.XRGB8888}
306+ target := ports.Target{OutputID: 1}
307+ source := portsmocks.NewMockSource(t)
308+ source.EXPECT().Capture(ctx, target).Return(frame, nil)
309+ closeErr := errors.New("encoder exited 1")
310+ video := portsmocks.NewMockVideoWriter(t)
311+ video.EXPECT().Start(ctx, frame, mock.Anything, mock.Anything).Return(nil)
312+ video.EXPECT().Write(ctx, frame).RunAndReturn(func(context.Context, ports.Frame) error {
313+ cancel(errUserStop)
314+ return nil
315+ })
316+ video.EXPECT().Close().Return(closeErr)
317+ runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: filepath.Join(t.TempDir(), "x.mp4"), Video: ports.VideoSettings{FPS: 30}})
318+ err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop})
319+ if !errors.Is(err, closeErr) {
320+ t.Fatalf("encoder failure masked: %v", err)
321+ }
322+}
A internal/core/selection.go +343 -0
@@ -0,0 +1,343 @@
1+package core
2+
3+import (
4+ "errors"
5+ "math"
6+
7+ "github.com/bnema/nefercap/internal/ports"
8+)
9+
10+// Picker selection thresholds, in output-local logical pixels.
11+const (
12+ // DragThreshold is the pointer travel from the press point at which a
13+ // press becomes a drag instead of a click.
14+ DragThreshold = 4
15+ // MinRegionSize is the smallest accepted dragged region edge.
16+ MinRegionSize = 4
17+)
18+
19+// ErrInvalidBounds reports a Picker started with an unusable output or size.
20+var ErrInvalidBounds = errors.New("invalid picker bounds")
21+
22+// PickKind is what a selection covers.
23+type PickKind uint8
24+
25+const (
26+ // PickRegion is a dragged rectangle. It is also the default picker kind,
27+ // where a click without a drag picks the whole monitor.
28+ PickRegion PickKind = iota
29+ // PickMonitor is one whole output.
30+ PickMonitor
31+ // PickWorkspace is the geometry of a workspace whose identity the caller
32+ // supplied with SetWorkspace.
33+ PickWorkspace
34+)
35+
36+// PickStatus is the picker life cycle.
37+type PickStatus uint8
38+
39+const (
40+ // PickIdle is the state before Begin.
41+ PickIdle PickStatus = iota
42+ PickActive
43+ PickAccepted
44+ PickCanceled
45+)
46+
47+// PickResult is an accepted selection. Region is explicit output-local
48+// logical geometry for drawing: the dragged rectangle, the full output for a
49+// monitor, or the workspace rectangle the caller supplied. WorkspaceID is
50+// nonzero only for PickWorkspace. Use Target for the capture request.
51+type PickResult struct {
52+ OutputID uint32
53+ Region ports.Region
54+ Kind PickKind
55+ WorkspaceID uint64
56+}
57+
58+// Target converts the result to a capture target; selectors must use it
59+// rather than map results themselves. A monitor is the zero region of its
60+// output. A region carries its geometry. A workspace carries only its exact
61+// native WorkspaceID: the compositor supplies live geometry at capture, so
62+// the Region snapshot in PickResult is for drawing only and is not cached in
63+// the target. A workspace is never converted to a monitor. Sizes are logical:
64+// the caller decides physical resolution and rounding.
65+func (r PickResult) Target() ports.Target {
66+ switch r.Kind {
67+ case PickMonitor:
68+ return ports.Target{OutputID: r.OutputID}
69+ case PickWorkspace:
70+ return ports.Target{OutputID: r.OutputID, WorkspaceID: r.WorkspaceID}
71+ }
72+ return ports.Target{OutputID: r.OutputID, Region: r.Region}
73+}
74+
75+// Picker is the pure selection state for one output. It has no framework
76+// types, performs no allocation per event and is not safe for concurrent use.
77+// Coordinates are output-local logical pixels. Mouse methods model the left
78+// button only; callers filter other buttons. Every mutating method returns
79+// whether visible state changed, so callers redraw only then.
80+//
81+// Interaction: dragging past DragThreshold and releasing accepts a region;
82+// a click accepts the whole monitor (or, after SelectWorkspace, the
83+// workspace); Confirm accepts the kind chosen by SelectMonitor or
84+// SelectWorkspace; Cancel abandons the pick.
85+type Picker struct {
86+ status PickStatus
87+ kind PickKind
88+ outputID uint32
89+ width int
90+ height int
91+
92+ px, py float64 // clamped pointer
93+ ax, ay float64 // clamped press point
94+ rect ports.Region
95+ pressed,
96+ dragging bool
97+ grid bool
98+
99+ wsID uint64
100+ wsRect ports.Region
101+
102+ result PickResult
103+}
104+
105+// Begin (re)starts the picker on an output of the given logical size,
106+// clearing all state including any workspace.
107+func (p *Picker) Begin(outputID uint32, width, height int) error {
108+ if outputID == 0 || width < 1 || height < 1 || width > ports.MaxDimension || height > ports.MaxDimension {
109+ *p = Picker{}
110+ return ErrInvalidBounds
111+ }
112+ *p = Picker{status: PickActive, outputID: outputID, width: width, height: height}
113+ return nil
114+}
115+
116+// Status returns the life-cycle state.
117+func (p *Picker) Status() PickStatus { return p.status }
118+
119+// Kind returns the chosen kind (PickRegion by default).
120+func (p *Picker) Kind() PickKind { return p.kind }
121+
122+// Dragging reports whether a press has passed DragThreshold.
123+func (p *Picker) Dragging() bool { return p.dragging }
124+
125+// Rect returns the normalized live drag rectangle; ok is false when not
126+// dragging.
127+func (p *Picker) Rect() (r ports.Region, ok bool) { return p.rect, p.dragging }
128+
129+// Bounds returns the output logical size.
130+func (p *Picker) Bounds() (width, height int) { return p.width, p.height }
131+
132+// Grid reports whether the thirds guides are on.
133+func (p *Picker) Grid() bool { return p.grid }
134+
135+// Workspace returns the supplied workspace identity and its rectangle
136+// clipped to the output; ok is false when none is available.
137+func (p *Picker) Workspace() (id uint64, r ports.Region, ok bool) {
138+ return p.wsID, p.wsRect, p.wsID != 0
139+}
140+
141+// Result returns the accepted selection; ok is false unless Status is
142+// PickAccepted.
143+func (p *Picker) Result() (PickResult, bool) {
144+ return p.result, p.status == PickAccepted
145+}
146+
147+// SetWorkspace supplies the current workspace identity and rectangle in
148+// output-local logical coordinates. Only a nonzero id with a rectangle that
149+// overlaps the output is eligible; the rectangle is clipped to the output.
150+// Anything else clears the workspace (and leaves PickWorkspace for
151+// PickRegion). It never invents an identity.
152+func (p *Picker) SetWorkspace(id uint64, r ports.Region) bool {
153+ if p.status != PickActive {
154+ return false
155+ }
156+ clipped, ok := p.clip(r)
157+ if id == 0 || !ok {
158+ id, clipped = 0, ports.Region{}
159+ }
160+ if id == p.wsID && clipped == p.wsRect {
161+ return false
162+ }
163+ p.wsID, p.wsRect = id, clipped
164+ if id == 0 && p.kind == PickWorkspace {
165+ p.kind = PickRegion
166+ }
167+ return true
168+}
169+
170+// SelectWorkspace makes Confirm and click pick the supplied workspace. It
171+// does nothing when no eligible workspace was supplied.
172+func (p *Picker) SelectWorkspace() bool { return p.setKind(PickWorkspace) }
173+
174+// SelectMonitor makes Confirm pick the whole monitor.
175+func (p *Picker) SelectMonitor() bool { return p.setKind(PickMonitor) }
176+
177+// SelectRegion returns to the default kind.
178+func (p *Picker) SelectRegion() bool { return p.setKind(PickRegion) }
179+
180+func (p *Picker) setKind(k PickKind) bool {
181+ if p.status != PickActive || p.pressed || p.kind == k || (k == PickWorkspace && p.wsID == 0) {
182+ return false
183+ }
184+ p.kind = k
185+ return true
186+}
187+
188+// ToggleGrid flips the thirds guides.
189+func (p *Picker) ToggleGrid() bool {
190+ if p.status != PickActive {
191+ return false
192+ }
193+ p.grid = !p.grid
194+ return true
195+}
196+
197+// MouseDown starts a press at (x, y).
198+func (p *Picker) MouseDown(x, y float64) bool {
199+ if p.status != PickActive || p.pressed {
200+ return false
201+ }
202+ p.setPointer(x, y)
203+ p.pressed, p.dragging = true, false
204+ p.ax, p.ay = p.px, p.py
205+ p.rect = ports.Region{}
206+ return true
207+}
208+
209+// MouseMove moves the pointer, growing the drag once past DragThreshold.
210+func (p *Picker) MouseMove(x, y float64) bool {
211+ if p.status != PickActive {
212+ return false
213+ }
214+ changed := p.setPointer(x, y)
215+ if !p.pressed {
216+ return changed
217+ }
218+ if !p.dragging {
219+ dx, dy := p.px-p.ax, p.py-p.ay
220+ if dx*dx+dy*dy < DragThreshold*DragThreshold {
221+ return changed
222+ }
223+ p.dragging = true
224+ changed = true
225+ }
226+ if r := p.dragRect(); r != p.rect {
227+ p.rect = r
228+ changed = true
229+ }
230+ return changed
231+}
232+
233+// MouseUp ends the press. A drag accepts its region when both edges reach
234+// MinRegionSize, otherwise the drag is discarded and picking continues. A
235+// click accepts the workspace when PickWorkspace is chosen, else the monitor.
236+func (p *Picker) MouseUp(x, y float64) bool {
237+ if p.status != PickActive || !p.pressed {
238+ return false
239+ }
240+ p.setPointer(x, y)
241+ p.pressed = false
242+ if !p.dragging {
243+ if p.kind == PickWorkspace && p.wsID != 0 {
244+ p.accept(PickWorkspace)
245+ } else {
246+ p.accept(PickMonitor)
247+ }
248+ return true
249+ }
250+ p.dragging = false
251+ r := p.dragRect()
252+ p.rect = ports.Region{}
253+ if r.Width < MinRegionSize || r.Height < MinRegionSize {
254+ return true
255+ }
256+ p.status = PickAccepted
257+ p.result = PickResult{OutputID: p.outputID, Region: r, Kind: PickRegion}
258+ return true
259+}
260+
261+// AbandonPress drops an unfinished press and any drag preview, for example
262+// when the pointer leaves the surface or input focus is lost, so a button
263+// release that is never delivered cannot leave the picker stuck. The chosen
264+// kind, grid and workspace are kept and the status stays PickActive. It does
265+// nothing after acceptance or cancellation. It reports whether state changed.
266+func (p *Picker) AbandonPress() bool {
267+ if p.status != PickActive || !(p.pressed || p.dragging || p.rect != (ports.Region{})) {
268+ return false
269+ }
270+ p.pressed, p.dragging = false, false
271+ p.rect = ports.Region{}
272+ return true
273+}
274+
275+// Confirm accepts the chosen monitor or workspace. It does nothing for the
276+// default region kind, whose selection is made by the mouse.
277+func (p *Picker) Confirm() bool {
278+ if p.status != PickActive || p.pressed {
279+ return false
280+ }
281+ switch {
282+ case p.kind == PickMonitor:
283+ p.accept(PickMonitor)
284+ case p.kind == PickWorkspace && p.wsID != 0:
285+ p.accept(PickWorkspace)
286+ default:
287+ return false
288+ }
289+ return true
290+}
291+
292+// Cancel abandons the pick (Escape).
293+func (p *Picker) Cancel() bool {
294+ if p.status != PickActive {
295+ return false
296+ }
297+ p.status = PickCanceled
298+ p.pressed, p.dragging = false, false
299+ p.rect = ports.Region{}
300+ return true
301+}
302+
303+func (p *Picker) accept(k PickKind) {
304+ p.status = PickAccepted
305+ switch k {
306+ case PickWorkspace:
307+ p.result = PickResult{OutputID: p.outputID, Region: p.wsRect, Kind: PickWorkspace, WorkspaceID: p.wsID}
308+ default:
309+ p.result = PickResult{OutputID: p.outputID, Region: ports.Region{Width: p.width, Height: p.height}, Kind: PickMonitor}
310+ }
311+}
312+
313+// setPointer clamps into the output and reports movement. NaN is ignored;
314+// +Inf and -Inf clamp to the output edges like any out-of-range value.
315+func (p *Picker) setPointer(x, y float64) bool {
316+ if math.IsNaN(x) || math.IsNaN(y) {
317+ return false
318+ }
319+ x = math.Min(math.Max(x, 0), float64(p.width))
320+ y = math.Min(math.Max(y, 0), float64(p.height))
321+ moved := x != p.px || y != p.py
322+ p.px, p.py = x, y
323+ return moved
324+}
325+
326+// dragRect is the normalized rectangle between press and pointer, rounded to
327+// whole logical pixels.
328+func (p *Picker) dragRect() ports.Region {
329+ x0, x1 := math.Round(math.Min(p.ax, p.px)), math.Round(math.Max(p.ax, p.px))
330+ y0, y1 := math.Round(math.Min(p.ay, p.py)), math.Round(math.Max(p.ay, p.py))
331+ return ports.Region{X: int(x0), Y: int(y0), Width: int(x1 - x0), Height: int(y1 - y0)}
332+}
333+
334+// clip intersects r with the output; ok is false when empty or invalid.
335+func (p *Picker) clip(r ports.Region) (ports.Region, bool) {
336+ x0, y0 := max(int64(r.X), 0), max(int64(r.Y), 0)
337+ x1 := min(int64(r.X)+int64(r.Width), int64(p.width))
338+ y1 := min(int64(r.Y)+int64(r.Height), int64(p.height))
339+ if r.Width < 1 || r.Height < 1 || x1 <= x0 || y1 <= y0 {
340+ return ports.Region{}, false
341+ }
342+ return ports.Region{X: int(x0), Y: int(y0), Width: int(x1 - x0), Height: int(y1 - y0)}, true
343+}
A internal/core/selection_test.go +420 -0
@@ -0,0 +1,420 @@
1+package core_test
2+
3+import (
4+ "math"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/nefercap/internal/core"
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+func newPicker(t testing.TB, id uint32, w, h int) *core.Picker {
15+ t.Helper()
16+ p := &core.Picker{}
17+ require.NoError(t, p.Begin(id, w, h))
18+ return p
19+}
20+
21+func TestPickerBeginValidation(t *testing.T) {
22+ for name, c := range map[string]struct {
23+ id uint32
24+ w, h int
25+ }{
26+ "zero id": {0, 100, 100},
27+ "zero width": {1, 0, 100},
28+ "neg height": {1, 100, -1},
29+ "too large": {1, ports.MaxDimension + 1, 100},
30+ "too large h": {1, 100, ports.MaxDimension + 1},
31+ } {
32+ t.Run(name, func(t *testing.T) {
33+ p := &core.Picker{}
34+ assert.ErrorIs(t, p.Begin(c.id, c.w, c.h), core.ErrInvalidBounds)
35+ assert.Equal(t, core.PickIdle, p.Status())
36+ assert.False(t, p.MouseDown(1, 1))
37+ })
38+ }
39+ p := newPicker(t, 3, 1920, 1080)
40+ assert.Equal(t, core.PickActive, p.Status())
41+ assert.Equal(t, core.PickRegion, p.Kind())
42+}
43+
44+func TestPickerDragAcceptsOnRelease(t *testing.T) {
45+ p := newPicker(t, 5, 1000, 800)
46+ assert.True(t, p.MouseDown(100, 200))
47+ assert.True(t, p.MouseMove(250, 300))
48+ r, ok := p.Rect()
49+ assert.True(t, ok)
50+ assert.Equal(t, ports.Region{X: 100, Y: 200, Width: 150, Height: 100}, r)
51+ assert.Equal(t, core.PickActive, p.Status(), "not accepted before release")
52+ _, ok = p.Result()
53+ assert.False(t, ok)
54+
55+ assert.True(t, p.MouseUp(250, 300))
56+ res, ok := p.Result()
57+ require.True(t, ok)
58+ assert.Equal(t, core.PickResult{OutputID: 5, Region: ports.Region{X: 100, Y: 200, Width: 150, Height: 100}, Kind: core.PickRegion}, res)
59+ assert.Equal(t, ports.Target{OutputID: 5, Region: res.Region}, res.Target())
60+}
61+
62+func TestPickerReverseDragNormalizes(t *testing.T) {
63+ for name, pts := range map[string][4]float64{
64+ "up-left": {300, 300, 100, 120},
65+ "up-right": {100, 300, 300, 120},
66+ "down-left": {300, 120, 100, 300},
67+ "down-right": {100, 120, 300, 300},
68+ } {
69+ t.Run(name, func(t *testing.T) {
70+ p := newPicker(t, 1, 1000, 1000)
71+ p.MouseDown(pts[0], pts[1])
72+ p.MouseMove(pts[2], pts[3])
73+ p.MouseUp(pts[2], pts[3])
74+ res, ok := p.Result()
75+ require.True(t, ok)
76+ assert.Equal(t, ports.Region{X: 100, Y: 120, Width: 200, Height: 180}, res.Region)
77+ })
78+ }
79+}
80+
81+func TestPickerClampsToOutput(t *testing.T) {
82+ p := newPicker(t, 1, 500, 400)
83+ p.MouseDown(-50, -20)
84+ p.MouseMove(9000, 9000)
85+ r, dragging := p.Rect()
86+ require.True(t, dragging)
87+ assert.Equal(t, ports.Region{Width: 500, Height: 400}, r, "live drag is clamped too")
88+ p.MouseUp(9000, 9000)
89+ res, ok := p.Result()
90+ require.True(t, ok)
91+ assert.Equal(t, ports.Region{Width: 500, Height: 400}, res.Region)
92+ assert.Equal(t, core.PickRegion, res.Kind, "a dragged full-output region stays a region")
93+
94+ // Never leaves the output, wherever it starts.
95+ q := newPicker(t, 1, 500, 400)
96+ q.MouseDown(480, 390)
97+ q.MouseMove(700, -100)
98+ q.MouseUp(700, -100)
99+ res, ok = q.Result()
100+ require.True(t, ok)
101+ assert.Equal(t, ports.Region{X: 480, Y: 0, Width: 20, Height: 390}, res.Region)
102+
103+ // NaN is ignored; infinities clamp to the edges.
104+ n := newPicker(t, 1, 500, 400)
105+ assert.False(t, n.MouseMove(math.NaN(), 5))
106+ assert.False(t, n.MouseMove(5, math.NaN()))
107+ i := newPicker(t, 1, 500, 400)
108+ i.MouseDown(math.Inf(-1), math.Inf(-1))
109+ i.MouseMove(math.Inf(1), math.Inf(1))
110+ i.MouseUp(math.Inf(1), math.Inf(1))
111+ res, ok = i.Result()
112+ require.True(t, ok)
113+ assert.Equal(t, ports.Region{Width: 500, Height: 400}, res.Region)
114+}
115+
116+func TestPickerThresholdAndClick(t *testing.T) {
117+ p := newPicker(t, 9, 1920, 1080)
118+ p.MouseDown(500, 500)
119+ assert.True(t, p.MouseMove(502, 502), "pointer moved")
120+ assert.False(t, p.Dragging(), "under threshold")
121+ assert.False(t, p.MouseMove(502, 502), "no change no redraw")
122+ assert.True(t, p.MouseMove(504, 500), "exactly the threshold starts a drag")
123+ assert.True(t, p.Dragging())
124+
125+ // A click, even with jitter below the threshold, picks the monitor.
126+ c := newPicker(t, 9, 1920, 1080)
127+ c.MouseDown(500, 500)
128+ c.MouseMove(503, 500)
129+ assert.True(t, c.MouseUp(503, 500))
130+ res, ok := c.Result()
131+ require.True(t, ok)
132+ assert.Equal(t, core.PickResult{OutputID: 9, Region: ports.Region{Width: 1920, Height: 1080}, Kind: core.PickMonitor}, res)
133+ assert.Equal(t, ports.Target{OutputID: 9}, res.Target(), "monitor is the full-output zero region")
134+}
135+
136+func TestPickerMinimumSize(t *testing.T) {
137+ p := newPicker(t, 1, 1000, 1000)
138+ p.MouseDown(100, 100)
139+ p.MouseMove(300, 102) // past threshold horizontally, height 2
140+ assert.True(t, p.MouseUp(300, 102))
141+ assert.Equal(t, core.PickActive, p.Status(), "tiny drag is discarded")
142+ _, dragging := p.Rect()
143+ assert.False(t, dragging)
144+
145+ // picking continues
146+ p.MouseDown(10, 10)
147+ p.MouseMove(60, 60)
148+ p.MouseUp(60, 60)
149+ res, ok := p.Result()
150+ require.True(t, ok)
151+ assert.Equal(t, ports.Region{X: 10, Y: 10, Width: 50, Height: 50}, res.Region)
152+}
153+
154+func TestPickerEscape(t *testing.T) {
155+ p := newPicker(t, 1, 100, 100)
156+ p.MouseDown(10, 10)
157+ p.MouseMove(50, 50)
158+ assert.True(t, p.Cancel())
159+ assert.Equal(t, core.PickCanceled, p.Status())
160+ _, ok := p.Result()
161+ assert.False(t, ok)
162+ assert.False(t, p.MouseUp(50, 50))
163+ assert.False(t, p.Confirm())
164+ assert.False(t, p.Cancel())
165+ assert.False(t, p.MouseDown(1, 1))
166+}
167+
168+func TestPickerMonitorKeyAndConfirm(t *testing.T) {
169+ p := newPicker(t, 2, 2560, 1440)
170+ assert.False(t, p.Confirm(), "region kind needs a drag")
171+ assert.True(t, p.SelectMonitor())
172+ assert.False(t, p.SelectMonitor())
173+ assert.Equal(t, core.PickMonitor, p.Kind())
174+ assert.True(t, p.Confirm())
175+ res, ok := p.Result()
176+ require.True(t, ok)
177+ assert.Equal(t, core.PickMonitor, res.Kind)
178+ assert.Equal(t, ports.Region{Width: 2560, Height: 1440}, res.Region)
179+ assert.Zero(t, res.WorkspaceID)
180+
181+ // A click also picks the monitor when m was chosen, even under the pointer.
182+ q := newPicker(t, 2, 2560, 1440)
183+ q.MouseMove(1000, 1000)
184+ q.SelectMonitor()
185+ q.MouseDown(1000, 1000)
186+ q.MouseUp(1000, 1000)
187+ res, ok = q.Result()
188+ require.True(t, ok)
189+ assert.Equal(t, core.PickMonitor, res.Kind)
190+}
191+
192+func TestPickerWorkspaceNeedsRealIdentity(t *testing.T) {
193+ rect := ports.Region{X: 0, Y: 0, Width: 1900, Height: 1000}
194+
195+ p := newPicker(t, 4, 1920, 1080)
196+ assert.False(t, p.SelectWorkspace(), "no workspace supplied")
197+ assert.False(t, p.SetWorkspace(0, rect), "zero id is not an identity")
198+ assert.False(t, p.SelectWorkspace())
199+ assert.False(t, p.SetWorkspace(7, ports.Region{}), "empty rect is not geometry")
200+ assert.False(t, p.SetWorkspace(7, ports.Region{X: 5000, Y: 0, Width: 10, Height: 10}), "outside output")
201+ assert.False(t, p.SetWorkspace(7, ports.Region{Width: -5, Height: 10}))
202+ _, _, ok := p.Workspace()
203+ assert.False(t, ok)
204+ assert.False(t, p.SelectWorkspace())
205+
206+ assert.True(t, p.SetWorkspace(7, rect))
207+ assert.False(t, p.SetWorkspace(7, rect), "unchanged")
208+ assert.True(t, p.SelectWorkspace())
209+ assert.True(t, p.Confirm())
210+ res, ok := p.Result()
211+ require.True(t, ok)
212+ assert.Equal(t, core.PickResult{OutputID: 4, Region: rect, Kind: core.PickWorkspace, WorkspaceID: 7}, res)
213+ assert.Equal(t, rect, res.Region, "geometry stays available for drawing")
214+ assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: 7}, res.Target())
215+}
216+
217+func TestPickResultTargetWorkspaceKeepsIdentity(t *testing.T) {
218+ // A workspace that fills the whole output is still a workspace, not the
219+ // monitor: the exact ID survives and no geometry snapshot is cached.
220+ full := ports.Region{Width: 1920, Height: 1080}
221+ p := newPicker(t, 4, 1920, 1080)
222+ require.True(t, p.SetWorkspace(1<<40+5, full))
223+ require.True(t, p.SelectWorkspace())
224+ require.True(t, p.Confirm())
225+ res, ok := p.Result()
226+ require.True(t, ok)
227+ assert.Equal(t, full, res.Region)
228+ assert.Equal(t, uint64(1<<40+5), res.WorkspaceID)
229+ got := res.Target()
230+ assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: 1<<40 + 5}, got)
231+ assert.Zero(t, got.Region)
232+ assert.NotEqual(t, ports.Target{OutputID: 4}, got)
233+
234+ // Monitor and region results never carry a workspace ID.
235+ assert.Equal(t, ports.Target{OutputID: 4}, core.PickResult{OutputID: 4, Kind: core.PickMonitor, Region: full, WorkspaceID: 9}.Target())
236+ assert.Equal(t, ports.Target{OutputID: 4, Region: full}, core.PickResult{OutputID: 4, Kind: core.PickRegion, Region: full, WorkspaceID: 9}.Target())
237+}
238+
239+func TestPickerWorkspaceClickAndClip(t *testing.T) {
240+ p := newPicker(t, 4, 1000, 500)
241+ p.SetWorkspace(11, ports.Region{X: -20, Y: 30, Width: 2000, Height: 2000})
242+ id, r, ok := p.Workspace()
243+ require.True(t, ok)
244+ assert.Equal(t, uint64(11), id)
245+ assert.Equal(t, ports.Region{X: 0, Y: 30, Width: 1000, Height: 470}, r, "clipped to the output")
246+ p.SelectWorkspace()
247+ p.MouseDown(5, 5)
248+ p.MouseUp(5, 5)
249+ res, ok := p.Result()
250+ require.True(t, ok)
251+ assert.Equal(t, core.PickWorkspace, res.Kind)
252+ assert.Equal(t, uint64(11), res.WorkspaceID)
253+
254+ // Drag still wins as a region even with workspace chosen.
255+ q := newPicker(t, 4, 1000, 500)
256+ q.SetWorkspace(11, ports.Region{Width: 1000, Height: 500})
257+ q.SelectWorkspace()
258+ q.MouseDown(10, 10)
259+ q.MouseMove(90, 90)
260+ q.MouseUp(90, 90)
261+ res, ok = q.Result()
262+ require.True(t, ok)
263+ assert.Equal(t, core.PickRegion, res.Kind)
264+ assert.Zero(t, res.WorkspaceID)
265+}
266+
267+func TestPickerWorkspaceLostFallsBack(t *testing.T) {
268+ p := newPicker(t, 4, 1000, 500)
269+ p.SetWorkspace(3, ports.Region{Width: 100, Height: 100})
270+ p.SelectWorkspace()
271+ assert.True(t, p.SetWorkspace(0, ports.Region{}))
272+ assert.Equal(t, core.PickRegion, p.Kind())
273+ assert.False(t, p.Confirm())
274+}
275+
276+func TestPickerFullscreenMaxMonitor(t *testing.T) {
277+ const max = ports.MaxDimension
278+ p := newPicker(t, 1, max, max)
279+ p.MouseDown(0, 0)
280+ p.MouseMove(1e12, 1e12)
281+ p.MouseUp(1e12, 1e12)
282+ res, ok := p.Result()
283+ require.True(t, ok)
284+ assert.Equal(t, ports.Region{Width: max, Height: max}, res.Region)
285+
286+ q := newPicker(t, 1, max, max)
287+ q.MouseDown(0, 0)
288+ q.MouseUp(0, 0)
289+ res, ok = q.Result()
290+ require.True(t, ok)
291+ assert.Equal(t, core.PickMonitor, res.Kind)
292+ assert.Equal(t, ports.Region{Width: max, Height: max}, res.Region)
293+}
294+
295+func TestPickerChangedFlags(t *testing.T) {
296+ p := newPicker(t, 1, 100, 100)
297+ assert.True(t, p.MouseMove(10, 10))
298+ assert.False(t, p.MouseMove(10, 10), "same position")
299+ assert.False(t, p.MouseUp(10, 10), "no press")
300+ assert.True(t, p.ToggleGrid())
301+ assert.True(t, p.Grid())
302+ assert.True(t, p.ToggleGrid())
303+ assert.False(t, p.Grid())
304+ assert.True(t, p.MouseDown(10, 10))
305+ assert.False(t, p.MouseDown(20, 20), "already pressed")
306+ assert.False(t, p.SelectMonitor(), "mode cannot change mid-press")
307+ assert.False(t, p.Confirm(), "no confirm mid-press")
308+}
309+
310+func TestPickerAbandonPressWithoutMotion(t *testing.T) {
311+ p := newPicker(t, 4, 1920, 1080)
312+ const id = 1<<40 + 9
313+ p.SetWorkspace(id, ports.Region{Width: 1900, Height: 1000})
314+ assert.False(t, p.AbandonPress(), "nothing to abandon")
315+ p.MouseDown(100, 100)
316+ assert.False(t, p.SelectWorkspace(), "blocked while pressed")
317+ assert.True(t, p.AbandonPress())
318+ assert.False(t, p.AbandonPress(), "already clear")
319+ assert.False(t, p.MouseUp(100, 100), "release after abandon is ignored")
320+ assert.Equal(t, core.PickActive, p.Status())
321+
322+ assert.True(t, p.SelectWorkspace())
323+ assert.True(t, p.Confirm())
324+ res, ok := p.Result()
325+ require.True(t, ok)
326+ assert.Equal(t, ports.Target{OutputID: 4, WorkspaceID: id}, res.Target())
327+}
328+
329+func TestPickerAbandonDragKeepsModes(t *testing.T) {
330+ p := newPicker(t, 4, 1000, 800)
331+ ws := ports.Region{X: 10, Y: 10, Width: 500, Height: 400}
332+ p.SetWorkspace(21, ws)
333+ p.SelectWorkspace()
334+ p.ToggleGrid()
335+ p.MouseDown(100, 100)
336+ p.MouseMove(300, 300)
337+ _, dragging := p.Rect()
338+ require.True(t, dragging)
339+
340+ assert.True(t, p.AbandonPress())
341+ _, dragging = p.Rect()
342+ assert.False(t, dragging)
343+ assert.False(t, p.Dragging())
344+ assert.True(t, p.Grid())
345+ assert.Equal(t, core.PickWorkspace, p.Kind())
346+ id, r, ok := p.Workspace()
347+ assert.True(t, ok)
348+ assert.Equal(t, uint64(21), id)
349+ assert.Equal(t, ws, r)
350+ assert.Equal(t, core.PickActive, p.Status())
351+
352+ // A fresh press starts from a clean anchor and drags normally.
353+ p.MouseDown(600, 600)
354+ p.MouseMove(700, 650)
355+ p.MouseUp(700, 650)
356+ res, ok := p.Result()
357+ require.True(t, ok)
358+ assert.Equal(t, ports.Region{X: 600, Y: 600, Width: 100, Height: 50}, res.Region)
359+ assert.Equal(t, core.PickRegion, res.Kind)
360+}
361+
362+func TestPickerAbandonPressAfterEnd(t *testing.T) {
363+ a := newPicker(t, 1, 100, 100)
364+ a.MouseDown(10, 10)
365+ a.MouseMove(60, 60)
366+ a.MouseUp(60, 60)
367+ before, _ := a.Result()
368+ assert.False(t, a.AbandonPress())
369+ after, ok := a.Result()
370+ assert.True(t, ok)
371+ assert.Equal(t, before, after)
372+
373+ c := newPicker(t, 1, 100, 100)
374+ c.MouseDown(10, 10)
375+ c.Cancel()
376+ assert.False(t, c.AbandonPress())
377+ assert.Equal(t, core.PickCanceled, c.Status())
378+
379+ var idle core.Picker
380+ assert.False(t, idle.AbandonPress())
381+ assert.Equal(t, core.PickIdle, idle.Status())
382+}
383+
384+func TestPickerAllocations(t *testing.T) {
385+ p := newPicker(t, 1, 1920, 1080)
386+ p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})
387+ n := testing.AllocsPerRun(10, func() {
388+ p.MouseDown(10, 10)
389+ for i := 0; i < 1000; i++ {
390+ p.MouseMove(float64(10+i%900), float64(10+(i*7)%900))
391+ _, _ = p.Rect()
392+ _, _ = p.Result()
393+ }
394+ p.AbandonPress()
395+ p.MouseDown(5, 5)
396+ p.AbandonPress()
397+ p.ToggleGrid()
398+ p.SelectMonitor()
399+ p.SelectRegion()
400+ p.Cancel()
401+ _ = p.Begin(1, 1920, 1080)
402+ p.SetWorkspace(1, ports.Region{Width: 100, Height: 100})
403+ })
404+ assert.Zero(t, n)
405+}
406+
407+func BenchmarkPickerDrag(b *testing.B) {
408+ p := &core.Picker{}
409+ _ = p.Begin(1, 3840, 2160)
410+ b.ReportAllocs()
411+ b.ResetTimer()
412+ for i := 0; i < b.N; i++ {
413+ p.MouseDown(100, 100)
414+ for j := 0; j < 100; j++ {
415+ p.MouseMove(float64(100+j*30), float64(100+j*15))
416+ }
417+ p.MouseUp(3000, 1500)
418+ _ = p.Begin(1, 3840, 2160)
419+ }
420+}
M internal/ports/capture.go +14 -0
@@ -20,6 +20,20 @@ type Region struct{ X, Y, Width, Height int }
2020 type Target struct {
2121 OutputID uint32
2222 Region Region
23+ // WorkspaceID is a stable compositor identity, never an index or monitor
24+ // alias. Nonzero requires a native capture session and a zero Region:
25+ // its geometry is supplied live by the compositor, not a cached crop.
26+ WorkspaceID uint64
27+}
28+
29+// Workspace is native compositor target metadata. Region is output-local,
30+// logical geometry. Active means that this workspace is currently displayed.
31+type Workspace struct {
32+ ID uint64
33+ OutputID uint32
34+ Name string
35+ Region Region
36+ Active bool
2337 }
2438
2539 // PixelFormat names the native little-endian wl_shm 32-bit pixel layout.

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 2

Tags 9

↑↓ moveenter openesc close