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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
57 files · +7423 -1359
M .hexcheck.yaml +4 -2
| @@ -17,5 +17,7 @@ mocking: | |||
| 17 | 17 | generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock'] | |
| 18 | 18 | ruleSettings: | |
| 19 | 19 | 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 @@ | |||
| 3 | 3 | - Local-only repository: do not create remotes, publish branches, or access GitHub without explicit permission. | |
| 4 | 4 | - Use Go 1.27; normal builds and tests use CGO_ENABLED=0. Enable CGO only for race tests. | |
| 5 | 5 | - 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. | |
| 7 | 7 | - Work in .worktrees/ branches, not directly on main. Merge locally only after checks and review pass. | |
| 8 | 8 | - Sign every commit and tag; commit format: type(scope): description. | |
| 9 | 9 | - 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: | |||
| 45 | 45 | # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS | |
| 46 | 46 | # the packages holding them. Every named test must exist, so the guard cannot | |
| 47 | 47 | # 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 | |
| 50 | 50 | perf_empty := | |
| 51 | 51 | perf_space := $(perf_empty) $(perf_empty) | |
| 52 | 52 | PERF_RE := ^($(subst $(perf_space),|,$(strip $(PERF_TESTS))))$$ | |
M README.md +48 -26
| @@ -1,47 +1,69 @@ | |||
| 1 | 1 | # nefercap | |
| 2 | 2 | ||
| 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. | |
| 4 | 31 | ||
| 5 | 32 | ## Requirements | |
| 6 | 33 | ||
| 7 | 34 | - 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. | |
| 11 | 41 | ||
| 12 | - | ## Build and run | |
| 42 | + | ## Scripted commands | |
| 13 | 43 | ||
| 14 | 44 | ```sh | |
| 15 | - | make bin | |
| 16 | 45 | ./bin/nefercap outputs | |
| 17 | 46 | ./bin/nefercap screenshot -output DP-1 -file capture.png | |
| 18 | 47 | ./bin/nefercap screenshot -output DP-1 -region 0,0,1920x1080 -file region.png | |
| 19 | 48 | ./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 | |
| 22 | 50 | ``` | |
| 23 | 51 | ||
| 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. | |
| 27 | 53 | ||
| 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. | |
| 29 | 55 | ||
| 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. | |
| 31 | 57 | ||
| 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. | |
| 39 | 59 | ||
| 40 | 60 | ## Architecture and footprint | |
| 41 | 61 | ||
| 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. | |
| 43 | 65 | ||
| 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. | |
| 45 | 67 | ||
| 46 | 68 | ```sh | |
| 47 | 69 | make check | |
| @@ -51,13 +73,13 @@ staticcheck ./... | |||
| 51 | 73 | go test ./... -run '^$' -bench . -benchmem | |
| 52 | 74 | ``` | |
| 53 | 75 | ||
| 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. | |
| 55 | 77 | ||
| 56 | - | ## Local dependency setup | |
| 78 | + | ## Local dependencies | |
| 57 | 79 | ||
| 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. | |
| 59 | 81 | ||
| 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. | |
| 61 | 83 | ||
| 62 | 84 | ## License | |
| 63 | 85 | ||
M go.mod +1 -1
| @@ -3,7 +3,7 @@ module github.com/bnema/nefercap | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/nefergui v0.1.0 | |
| 6 | + | github.com/bnema/nefergui v0.1.1-0.20260930094617-905a5c7e50c4 | |
| 7 | 7 | github.com/bnema/wlturbo v0.3.0 | |
| 8 | 8 | github.com/bnema/zerowrap v1.4.1 | |
| 9 | 9 | github.com/stretchr/testify v1.12.1 | |
M go.sum +2 -2 generated file: collapsed
M internal/adapters/cli/cli.go +56 -18
| @@ -18,10 +18,18 @@ import ( | |||
| 18 | 18 | type Command string | |
| 19 | 19 | ||
| 20 | 20 | 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. | |
| 21 | 30 | Outputs Command = "outputs" | |
| 22 | 31 | Screenshot Command = "screenshot" | |
| 23 | 32 | Record Command = "record" | |
| 24 | - | GUI Command = "gui" | |
| 25 | 33 | ) | |
| 26 | 34 | ||
| 27 | 35 | // DefaultFPS is the recording frame rate when -fps is not given. | |
| @@ -30,7 +38,9 @@ const DefaultFPS = 30 | |||
| 30 | 38 | // ErrUsage wraps every command-line usage error. | |
| 31 | 39 | var ErrUsage = errors.New("usage error") | |
| 32 | 40 | ||
| 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. | |
| 34 | 44 | type Options struct { | |
| 35 | 45 | Command Command | |
| 36 | 46 | Output string // output name; empty lets the caller choose | |
| @@ -45,20 +55,35 @@ type Options struct { | |||
| 45 | 55 | func Usage() string { | |
| 46 | 56 | return fmt.Sprintf(`Usage: nefercap [command] [flags] | |
| 47 | 57 | ||
| 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): | |
| 50 | 75 | outputs list outputs | |
| 51 | 76 | screenshot save a PNG screenshot | |
| 52 | 77 | record record silent video until interrupted or target video length is reached | |
| 53 | 78 | ||
| 54 | 79 | Flags: | |
| 55 | 80 | -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 | |
| 57 | 83 | -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) | |
| 62 | 87 | `, ports.MaxFPS, DefaultFPS, ports.MaxDimension) | |
| 63 | 88 | } | |
| 64 | 89 | ||
| @@ -68,10 +93,10 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 68 | 93 | if out == nil { | |
| 69 | 94 | out = io.Discard | |
| 70 | 95 | } | |
| 71 | - | opts := Options{Command: GUI} | |
| 96 | + | opts := Options{Command: Shot} | |
| 72 | 97 | if len(args) > 0 && !strings.HasPrefix(args[0], "-") { | |
| 73 | 98 | switch cmd := Command(args[0]); cmd { | |
| 74 | - | case Outputs, Screenshot, Record, GUI: | |
| 99 | + | case Shot, Rec, Stop, Status, Outputs, Screenshot, Record: | |
| 75 | 100 | opts.Command = cmd | |
| 76 | 101 | args = args[1:] | |
| 77 | 102 | default: | |
| @@ -79,18 +104,23 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 79 | 104 | } | |
| 80 | 105 | } | |
| 81 | 106 | ||
| 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 | + | ||
| 82 | 111 | fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError) | |
| 83 | 112 | fs.SetOutput(io.Discard) | |
| 84 | 113 | fs.BoolVar(&opts.Debug, "debug", false, "") | |
| 85 | 114 | var region, size string | |
| 86 | 115 | fps := DefaultFPS | |
| 87 | - | capture := opts.Command == Screenshot || opts.Command == Record | |
| 88 | - | if capture { | |
| 116 | + | if scripted || interactive { | |
| 89 | 117 | fs.StringVar(&opts.Output, "output", "", "") | |
| 90 | - | fs.StringVar(®ion, "region", "", "") | |
| 91 | 118 | fs.StringVar(&opts.Path, "file", "", "") | |
| 92 | 119 | } | |
| 93 | - | if opts.Command == Record { | |
| 120 | + | if scripted { | |
| 121 | + | fs.StringVar(®ion, "region", "", "") | |
| 122 | + | } | |
| 123 | + | if video { | |
| 94 | 124 | fs.IntVar(&fps, "fps", DefaultFPS, "") | |
| 95 | 125 | fs.StringVar(&size, "size", "", "") | |
| 96 | 126 | fs.DurationVar(&opts.Duration, "duration", 0, "") | |
| @@ -106,13 +136,21 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 106 | 136 | if fs.NArg() > 0 { | |
| 107 | 137 | return Options{}, usageErr("unexpected argument %q", fs.Arg(0)) | |
| 108 | 138 | } | |
| 109 | - | if !capture { | |
| 139 | + | if !scripted && !interactive { | |
| 110 | 140 | return opts, nil | |
| 111 | 141 | } | |
| 112 | 142 | ||
| 113 | - | if opts.Path == "" { | |
| 143 | + | if scripted && opts.Path == "" { | |
| 114 | 144 | return Options{}, usageErr("-file is required") | |
| 115 | 145 | } | |
| 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 | + | } | |
| 116 | 154 | if region != "" { | |
| 117 | 155 | r, err := parseRegion(region) | |
| 118 | 156 | if err != nil { | |
| @@ -120,7 +158,7 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 120 | 158 | } | |
| 121 | 159 | opts.Region = r | |
| 122 | 160 | } | |
| 123 | - | if opts.Command == Record { | |
| 161 | + | if video { | |
| 124 | 162 | if fps < 1 || fps > ports.MaxFPS { | |
| 125 | 163 | return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS) | |
| 126 | 164 | } | |
M internal/adapters/cli/cli_test.go +44 -7
| @@ -20,9 +20,21 @@ func TestParseValid(t *testing.T) { | |||
| 20 | 20 | args []string | |
| 21 | 21 | want cli.Options | |
| 22 | 22 | }{ | |
| 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}}, | |
| 26 | 38 | {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}}, | |
| 27 | 39 | {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}}, | |
| 28 | 40 | {"screenshot", []string{"screenshot", "-file", "a.png"}, | |
| @@ -58,14 +70,37 @@ func TestParseUsageErrors(t *testing.T) { | |||
| 58 | 70 | cases := map[string][]string{ | |
| 59 | 71 | "unknown command": {"snap"}, | |
| 60 | 72 | "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"}, | |
| 62 | 98 | "positional after flags": {"record", "-file", "a.mkv", "extra"}, | |
| 63 | 99 | "unknown flag": {"-nope"}, | |
| 64 | 100 | "unknown flag in command": {"outputs", "-nope"}, | |
| 65 | 101 | "screenshot missing file": {"screenshot"}, | |
| 66 | 102 | "record missing file": {"record", "-fps", "30"}, | |
| 67 | 103 | "empty file": {"screenshot", "-file", ""}, | |
| 68 | - | "capture flag on gui": {"-file", "a.png"}, | |
| 69 | 104 | "capture flag on outputs": {"outputs", "-output", "DP-1"}, | |
| 70 | 105 | "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"}, | |
| 71 | 106 | "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"}, | |
| @@ -121,7 +156,7 @@ func TestParseErrorNamesProblem(t *testing.T) { | |||
| 121 | 156 | } | |
| 122 | 157 | ||
| 123 | 158 | 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"}} { | |
| 125 | 160 | var out bytes.Buffer | |
| 126 | 161 | got, err := cli.Parse(args, &out) | |
| 127 | 162 | assert.True(t, errors.Is(err, flag.ErrHelp), args) | |
| @@ -132,7 +167,9 @@ func TestParseHelp(t *testing.T) { | |||
| 132 | 167 | ||
| 133 | 168 | func TestUsageMentionsEverything(t *testing.T) { | |
| 134 | 169 | 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"} { | |
| 136 | 172 | assert.Contains(t, u, s) | |
| 137 | 173 | } | |
| 174 | + | assert.NotContains(t, u, " gui ") | |
| 138 | 175 | } | |
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 ( | |||
| 8 | 8 | "sync" | |
| 9 | 9 | "syscall" | |
| 10 | 10 | "testing" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "golang.org/x/sys/unix" | |
| 13 | 14 | ) | |
| @@ -26,11 +27,18 @@ const ( | |||
| 26 | 27 | kindBuffer | |
| 27 | 28 | kindScreencopy | |
| 28 | 29 | kindFrame | |
| 30 | + | kindPrivManager | |
| 31 | + | kindPrivSession | |
| 32 | + | kindPrivLayer | |
| 33 | + | kindCompositor | |
| 34 | + | kindSurface | |
| 29 | 35 | ) | |
| 30 | 36 | ||
| 31 | 37 | const ( | |
| 32 | 38 | globalShm = 1000 | |
| 33 | 39 | globalScreencopy = 1001 | |
| 40 | + | globalPrivate = 1002 | |
| 41 | + | globalWlComp = 1003 | |
| 34 | 42 | ) | |
| 35 | 43 | ||
| 36 | 44 | type outputSpec struct { | |
| @@ -57,6 +65,46 @@ type compositorConfig struct { | |||
| 57 | 65 | readyEarly bool // send ready right after the buffer events, before any copy | |
| 58 | 66 | noShmOffer bool // send only buffer_done: no wl_shm buffer is offered | |
| 59 | 67 | 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 | |
| 60 | 108 | } | |
| 61 | 109 | ||
| 62 | 110 | type poolMap struct{ data []byte } | |
| @@ -89,8 +137,22 @@ type compositor struct { | |||
| 89 | 137 | buffer *bufferInfo | |
| 90 | 138 | } | |
| 91 | 139 | 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{} | |
| 94 | 156 | ||
| 95 | 157 | wmu sync.Mutex | |
| 96 | 158 | out [4096]byte | |
| @@ -298,6 +360,22 @@ func (c *compositor) handle(o *objects, id uint32, op uint16, body []byte, fds * | |||
| 298 | 360 | if op <= 1 { | |
| 299 | 361 | c.capture(o, op, body) | |
| 300 | 362 | } | |
| 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 | + | } | |
| 301 | 379 | case kindFrame: | |
| 302 | 380 | switch op { | |
| 303 | 381 | case 0: | |
| @@ -319,6 +397,10 @@ func (c *compositor) advertise() { | |||
| 319 | 397 | c.sendGlobal(1, "wl_output", c.cfg.outputs[0].version) | |
| 320 | 398 | } | |
| 321 | 399 | 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 | + | } | |
| 322 | 404 | if !c.cfg.noScreencopy { | |
| 323 | 405 | c.sendGlobal(globalScreencopy, "zwlr_screencopy_manager_v1", c.cfg.screencopyVersion) | |
| 324 | 406 | } | |
| @@ -359,6 +441,16 @@ func (c *compositor) bind(o *objects, name uint32, iface string, version, id uin | |||
| 359 | 441 | c.send(id, 0, 1) | |
| 360 | 442 | case "zwlr_screencopy_manager_v1": | |
| 361 | 443 | 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 | + | } | |
| 362 | 454 | default: | |
| 363 | 455 | c.t.Errorf("bind of unexpected interface %q", iface) | |
| 364 | 456 | } | |
| @@ -375,12 +467,17 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) { | |||
| 375 | 467 | readyEarly, noShmOffer := c.cfg.readyEarly, c.cfg.noShmOffer | |
| 376 | 468 | list, version := c.cfg.announce, c.cfg.screencopyVersion | |
| 377 | 469 | c.captures++ | |
| 470 | + | move, moveWS := c.cfg.private.moveEachCapture, c.privWorkspace | |
| 471 | + | moveN := int32(c.captures) | |
| 378 | 472 | if op == 1 { | |
| 379 | 473 | c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))} | |
| 380 | 474 | c.lastRegionSet = true | |
| 381 | 475 | w, h = word(body, 5), word(body, 6) | |
| 382 | 476 | } | |
| 383 | 477 | c.mu.Unlock() | |
| 478 | + | if move { | |
| 479 | + | c.pushState([4]int32{moveN, 0, 8, 4}, moveWS) | |
| 480 | + | } | |
| 384 | 481 | o.kinds[frame] = kindFrame | |
| 385 | 482 | o.frames[frame] = &frameState{output: out, w: w, h: h} | |
| 386 | 483 | if removeOnCapture { | |
| @@ -536,3 +633,203 @@ func (c *compositor) flush(m []byte) { | |||
| 536 | 633 | return | |
| 537 | 634 | } | |
| 538 | 635 | } | |
| 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) { | |||
| 135 | 135 | if f.out.removed { | |
| 136 | 136 | return ports.Frame{}, ports.ErrOutputNotFound | |
| 137 | 137 | } | |
| 138 | - | return ports.Frame{}, errors.New("wayland: compositor failed the capture") | |
| 138 | + | return ports.Frame{}, errCompositorFailedCapture | |
| 139 | 139 | case f.ready && !f.copied: | |
| 140 | 140 | s.terminate() | |
| 141 | 141 | 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 ( | |||
| 13 | 13 | "slices" | |
| 14 | 14 | "sync" | |
| 15 | 15 | "sync/atomic" | |
| 16 | + | "time" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/wlturbo" | |
| 18 | 19 | "github.com/bnema/wlturbo/protocol/core" | |
| @@ -73,6 +74,13 @@ type Source struct { | |||
| 73 | 74 | outputs map[uint32]*output | |
| 74 | 75 | bound int // wl_output globals bound so far | |
| 75 | 76 | 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 | |
| 76 | 84 | } | |
| 77 | 85 | ||
| 78 | 86 | var _ ports.Source = (*Source)(nil) | |
| @@ -97,10 +105,11 @@ func New(ctx context.Context, socket string) (*Source, error) { | |||
| 97 | 105 | return nil, fmt.Errorf("wayland: %w", err) | |
| 98 | 106 | } | |
| 99 | 107 | 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, | |
| 104 | 113 | } | |
| 105 | 114 | stop := context.AfterFunc(ctx, s.terminate) | |
| 106 | 115 | err = s.discover() | |
| @@ -150,6 +159,9 @@ func (s *Source) discover() error { | |||
| 150 | 159 | if _, err := reg.BindNegotiated(screencopyIface, screencopyVersion, s.screencopy); err != nil { | |
| 151 | 160 | return fmt.Errorf("wayland: bind %s: %w", screencopyIface, err) | |
| 152 | 161 | } | |
| 162 | + | if err := s.bindCaptureManager(); err != nil { | |
| 163 | + | return err | |
| 164 | + | } | |
| 153 | 165 | return s.roundtrip() | |
| 154 | 166 | } | |
| 155 | 167 | ||
| @@ -312,11 +324,7 @@ func (s *Source) Outputs(ctx context.Context) ([]ports.Output, error) { | |||
| 312 | 324 | if err == nil { | |
| 313 | 325 | list = make([]ports.Output, 0, len(s.outputs)) | |
| 314 | 326 | 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)}) | |
| 320 | 328 | } | |
| 321 | 329 | slices.SortFunc(list, func(a, b ports.Output) int { return cmp.Compare(a.ID, b.ID) }) | |
| 322 | 330 | } | |
| @@ -337,7 +345,7 @@ func (s *Source) Capture(ctx context.Context, t ports.Target) (ports.Frame, erro | |||
| 337 | 345 | if err := s.begin(ctx); err != nil { | |
| 338 | 346 | return ports.Frame{}, err | |
| 339 | 347 | } | |
| 340 | - | frame, err := s.capture(t) | |
| 348 | + | frame, err := s.captureTarget(t) | |
| 341 | 349 | if err = s.finish(ctx, err); err != nil { | |
| 342 | 350 | return ports.Frame{}, err | |
| 343 | 351 | } | |
M internal/app/app.go +40 -7
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | |
| 12 | 12 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | |
| 13 | - | "github.com/bnema/nefercap/internal/adapters/gui" | |
| 14 | 13 | "github.com/bnema/nefercap/internal/adapters/png" | |
| 15 | 14 | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| 16 | 15 | "github.com/bnema/nefercap/internal/core" | |
| @@ -22,6 +21,9 @@ const discoveryTimeout = 5 * time.Second | |||
| 22 | 21 | ||
| 23 | 22 | // Run owns the source connection and all capture work for one invocation. | |
| 24 | 23 | 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 | + | } | |
| 25 | 27 | discovery, cancel := context.WithTimeout(ctx, discoveryTimeout) | |
| 26 | 28 | source, err := wayland.New(discovery, "") | |
| 27 | 29 | if err != nil { | |
| @@ -47,9 +49,12 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 47 | 49 | return nil | |
| 48 | 50 | } | |
| 49 | 51 | 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 | + | } | |
| 51 | 56 | var accepted bool | |
| 52 | - | selection, accepted, err = gui.New().Select(ctx, outputs) | |
| 57 | + | selection, accepted, err = chooseInteractive(ctx, source, outputs, options) | |
| 53 | 58 | if err != nil || !accepted { | |
| 54 | 59 | return err | |
| 55 | 60 | } | |
| @@ -70,12 +75,40 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 70 | 75 | } | |
| 71 | 76 | log := logging.For(ctx, "app") | |
| 72 | 77 | 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 { | |
| 75 | 106 | return err | |
| 76 | 107 | } | |
| 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 | |
| 79 | 112 | } | |
| 80 | 113 | ||
| 81 | 114 | // 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 } | |||
| 20 | 20 | type Target struct { | |
| 21 | 21 | OutputID uint32 | |
| 22 | 22 | 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 | |
| 23 | 37 | } | |
| 24 | 38 | ||
| 25 | 39 | // PixelFormat names the native little-endian wl_shm 32-bit pixel layout. | |