detached e07cd4d

feat(app): wire local screenshot and recording commands

commit
e07cd4dff34322714b267386f0f2c353b2abf93e
author
brice · · 2026-09-30 08:41 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
b8f27df
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +428 -41
M Makefile +4 -4
@@ -43,10 +43,10 @@ fakes-check:
4343 arch:
4444 $(HEXCHECK) -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
4545 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
46-# the packages holding them; extend both as encoder/wayland tests appear. Every
47-# named test must exist, so the guard cannot pass vacuously.
48-PERF_TESTS := TestFrameAllocations
49-PERF_PKGS := ./internal/ports
46+# the packages holding them. Every named test must exist, so the guard cannot
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
5050 perf_empty :=
5151 perf_space := $(perf_empty) $(perf_empty)
5252 PERF_RE := ^($(subst $(perf_space),|,$(strip $(PERF_TESTS))))$$
@@ -43,10 +43,10 @@ fakes-check:
43 arch:43 arch:
44 $(HEXCHECK) -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...44 $(HEXCHECK) -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
45 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS45 # Steady-state allocation guards. PERF_TESTS lists exact test names and PERF_PKGS
46-# the packages holding them; extend both as encoder/wayland tests appear. Every46+# the packages holding them. Every named test must exist, so the guard cannot
47-# named test must exist, so the guard cannot pass vacuously.47+# pass vacuously when a frame-path test is removed or renamed.
48-PERF_TESTS := TestFrameAllocations48+PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestFormAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations
49-PERF_PKGS := ./internal/ports49+PERF_PKGS := ./internal/ports ./internal/core ./internal/adapters/gui ./internal/adapters/ffmpeg ./internal/app ./internal/adapters/wayland
50 perf_empty :=50 perf_empty :=
51 perf_space := $(perf_empty) $(perf_empty)51 perf_space := $(perf_empty) $(perf_empty)
52 PERF_RE := ^($(subst $(perf_space),|,$(strip $(PERF_TESTS))))$$52 PERF_RE := ^($(subst $(perf_space),|,$(strip $(PERF_TESTS))))$$
A README.md +64 -0
@@ -0,0 +1,64 @@
1+# nefercap
2+
3+A small Wayland screenshot and silent video recorder, with a NeferGUI control panel.
4+
5+## Requirements
6+
7+- 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.
11+
12+## Build and run
13+
14+```sh
15+make bin
16+./bin/nefercap outputs
17+./bin/nefercap screenshot -output DP-1 -file capture.png
18+./bin/nefercap screenshot -output DP-1 -region 0,0,1920x1080 -file region.png
19+./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
22+```
23+
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.
27+
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.
29+
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.
31+
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.
39+
40+## Architecture and footprint
41+
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.
43+
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.
45+
46+```sh
47+make check
48+make race
49+make mocks-check
50+staticcheck ./...
51+go test ./... -run '^$' -bench . -benchmem
52+```
53+
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.
55+
56+## Local dependency setup
57+
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.
59+
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.
61+
62+## License
63+
64+GPL-3.0. See [LICENSE](LICENSE).
@@ -0,0 +1,64 @@
1+# nefercap
2+
3+A small Wayland screenshot and silent video recorder, with a NeferGUI control panel.
4+
5+## Requirements
6+
7+- 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.
11+
12+## Build and run
13+
14+```sh
15+make bin
16+./bin/nefercap outputs
17+./bin/nefercap screenshot -output DP-1 -file capture.png
18+./bin/nefercap screenshot -output DP-1 -region 0,0,1920x1080 -file region.png
19+./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
22+```
23+
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.
27+
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.
29+
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.
31+
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.
39+
40+## Architecture and footprint
41+
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.
43+
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.
45+
46+```sh
47+make check
48+make race
49+make mocks-check
50+staticcheck ./...
51+go test ./... -run '^$' -bench . -benchmem
52+```
53+
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.
55+
56+## Local dependency setup
57+
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.
59+
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.
61+
62+## License
63+
64+GPL-3.0. See [LICENSE](LICENSE).
A cmd/nefercap/main.go +46 -0
@@ -0,0 +1,46 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "os"
9+ "os/signal"
10+ "syscall"
11+
12+ "github.com/bnema/nefercap/internal/adapters/cli"
13+ "github.com/bnema/nefercap/internal/app"
14+ "github.com/bnema/nefercap/internal/logging"
15+)
16+
17+func main() { os.Exit(run()) }
18+
19+// Only the invocation's bare signal cancellation is a successful interruption.
20+// Wrapped or joined errors retain their failure so cleanup errors are visible.
21+func interrupted(ctx context.Context, err error) bool {
22+ return err == context.Canceled && ctx.Err() != nil
23+}
24+
25+func run() int {
26+ options, err := cli.Parse(os.Args[1:], os.Stderr)
27+ if errors.Is(err, flag.ErrHelp) {
28+ return 0
29+ }
30+ if err != nil {
31+ fmt.Fprintln(os.Stderr, err)
32+ return 2
33+ }
34+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
35+ defer stop()
36+ ctx = logging.With(ctx, os.Stderr, options.Debug)
37+ if err := app.Run(ctx, options, os.Stdout); err != nil {
38+ if interrupted(ctx, err) {
39+ return 0
40+ }
41+ log := logging.For(ctx, "app")
42+ log.Error().Err(err).Msg("capture failed")
43+ return 1
44+ }
45+ return 0
46+}
@@ -0,0 +1,46 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "os"
9+ "os/signal"
10+ "syscall"
11+
12+ "github.com/bnema/nefercap/internal/adapters/cli"
13+ "github.com/bnema/nefercap/internal/app"
14+ "github.com/bnema/nefercap/internal/logging"
15+)
16+
17+func main() { os.Exit(run()) }
18+
19+// Only the invocation's bare signal cancellation is a successful interruption.
20+// Wrapped or joined errors retain their failure so cleanup errors are visible.
21+func interrupted(ctx context.Context, err error) bool {
22+ return err == context.Canceled && ctx.Err() != nil
23+}
24+
25+func run() int {
26+ options, err := cli.Parse(os.Args[1:], os.Stderr)
27+ if errors.Is(err, flag.ErrHelp) {
28+ return 0
29+ }
30+ if err != nil {
31+ fmt.Fprintln(os.Stderr, err)
32+ return 2
33+ }
34+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
35+ defer stop()
36+ ctx = logging.With(ctx, os.Stderr, options.Debug)
37+ if err := app.Run(ctx, options, os.Stdout); err != nil {
38+ if interrupted(ctx, err) {
39+ return 0
40+ }
41+ log := logging.For(ctx, "app")
42+ log.Error().Err(err).Msg("capture failed")
43+ return 1
44+ }
45+ return 0
46+}
A cmd/nefercap/main_test.go +43 -0
@@ -0,0 +1,43 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "testing"
9+)
10+
11+func TestInterrupted(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ if interrupted(ctx, context.Canceled) {
14+ t.Fatal("live invocation hid internal cancellation")
15+ }
16+ cancel()
17+ if !interrupted(ctx, context.Canceled) {
18+ t.Fatal("signal cancellation rejected")
19+ }
20+ for _, err := range []error{nil, context.DeadlineExceeded, fmt.Errorf("phase: %w", context.Canceled), errors.Join(context.Canceled, errors.New("cleanup failed"))} {
21+ if interrupted(ctx, err) {
22+ t.Fatalf("hid failure: %v", err)
23+ }
24+ }
25+}
26+
27+func TestUsageExitCodes(t *testing.T) {
28+ original := os.Args
29+ defer func() { os.Args = original }()
30+ for _, tc := range []struct {
31+ args []string
32+ want int
33+ }{
34+ {[]string{"nefercap", "--help"}, 0},
35+ {[]string{"nefercap", "unknown-command"}, 2},
36+ {[]string{"nefercap", "screenshot"}, 2},
37+ } {
38+ os.Args = tc.args
39+ if code := run(); code != tc.want {
40+ t.Fatalf("%v: exit %d, want %d", tc.args, code, tc.want)
41+ }
42+ }
43+}
@@ -0,0 +1,43 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "testing"
9+)
10+
11+func TestInterrupted(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ if interrupted(ctx, context.Canceled) {
14+ t.Fatal("live invocation hid internal cancellation")
15+ }
16+ cancel()
17+ if !interrupted(ctx, context.Canceled) {
18+ t.Fatal("signal cancellation rejected")
19+ }
20+ for _, err := range []error{nil, context.DeadlineExceeded, fmt.Errorf("phase: %w", context.Canceled), errors.Join(context.Canceled, errors.New("cleanup failed"))} {
21+ if interrupted(ctx, err) {
22+ t.Fatalf("hid failure: %v", err)
23+ }
24+ }
25+}
26+
27+func TestUsageExitCodes(t *testing.T) {
28+ original := os.Args
29+ defer func() { os.Args = original }()
30+ for _, tc := range []struct {
31+ args []string
32+ want int
33+ }{
34+ {[]string{"nefercap", "--help"}, 0},
35+ {[]string{"nefercap", "unknown-command"}, 2},
36+ {[]string{"nefercap", "screenshot"}, 2},
37+ } {
38+ os.Args = tc.args
39+ if code := run(); code != tc.want {
40+ t.Fatalf("%v: exit %d, want %d", tc.args, code, tc.want)
41+ }
42+ }
43+}
M go.mod +6 -0
@@ -11,10 +11,16 @@ require (
1111 )
1212
1313 require (
14+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
15+ github.com/bnema/purego-vulkan v0.6.0 // indirect
16+ github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 // indirect
17+ github.com/go-text/typesetting v0.3.5 // indirect
1418 github.com/mattn/go-colorable v0.1.15 // indirect
1519 github.com/mattn/go-isatty v0.0.22 // indirect
1620 github.com/rs/zerolog v1.35.1 // indirect
1721 github.com/stretchr/objx v0.5.3 // indirect
1822 go.yaml.in/yaml/v3 v3.0.5 // indirect
23+ golang.org/x/image v0.46.0 // indirect
24+ golang.org/x/text v0.42.0 // indirect
1925 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
2026 )
@@ -11,10 +11,16 @@ require (
11 )11 )
12 12
13 require (13 require (
14+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
15+ github.com/bnema/purego-vulkan v0.6.0 // indirect
16+ github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 // indirect
17+ github.com/go-text/typesetting v0.3.5 // indirect
14 github.com/mattn/go-colorable v0.1.15 // indirect18 github.com/mattn/go-colorable v0.1.15 // indirect
15 github.com/mattn/go-isatty v0.0.22 // indirect19 github.com/mattn/go-isatty v0.0.22 // indirect
16 github.com/rs/zerolog v1.35.1 // indirect20 github.com/rs/zerolog v1.35.1 // indirect
17 github.com/stretchr/objx v0.5.3 // indirect21 github.com/stretchr/objx v0.5.3 // indirect
18 go.yaml.in/yaml/v3 v3.0.5 // indirect22 go.yaml.in/yaml/v3 v3.0.5 // indirect
23+ golang.org/x/image v0.46.0 // indirect
24+ golang.org/x/text v0.42.0 // indirect
19 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect25 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
20 )26 )
M go.sum +2 -16
@@ -10,14 +10,10 @@ github.com/bnema/wlturbo v0.3.0 h1:Xt5HoiTL/pTVnUjD29mzBsUAVJp+1bxB9DfzxZK6RVA=
1010 github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=
1111 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
1212 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
13-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
14-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
15-github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
16-github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
17-github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
18-github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
1913 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
2014 github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
15+github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
16+github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
2117 github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
2218 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
2319 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -28,16 +24,6 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
2824 github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
2925 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
3026 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
31-go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
32-go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
33-go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
34-go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
35-go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA=
36-go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g=
37-go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
38-go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
39-go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
40-go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
4127 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
4228 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
4329 golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
@@ -10,14 +10,10 @@ github.com/bnema/wlturbo v0.3.0 h1:Xt5HoiTL/pTVnUjD29mzBsUAVJp+1bxB9DfzxZK6RVA=
10 github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=10 github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA=
11 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=11 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
12 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=12 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
13-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
14-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
15-github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
16-github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
17-github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
18-github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
19 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=13 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
20 github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=14 github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
15+github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
16+github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
21 github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=17 github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
22 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=18 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
23 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=19 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
@@ -28,16 +24,6 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
28 github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=24 github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
29 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=25 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
30 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=26 github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
31-go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
32-go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
33-go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
34-go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
35-go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA=
36-go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g=
37-go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
38-go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
39-go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
40-go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
41 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=27 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
42 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=28 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
43 golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=29 golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
M internal/adapters/ffmpeg/ffmpeg.go +5 -4
@@ -65,7 +65,8 @@ type failure struct {
6565 }
6666
6767 // Writer implements ports.VideoWriter. Start, Write and Close have one owner;
68-// Abort may be called concurrently with Write. A Writer is single-use.
68+// Abort may be called concurrently with Write or a draining Close.
69+// A Writer is single-use.
6970 type Writer struct {
7071 binary string
7172 closeTimeout time.Duration
@@ -374,9 +375,9 @@ func writeFull(dst io.Writer, p []byte) error {
374375 // kills it) and reports the outcome. The video is kept when the encoder exits
375376 // cleanly after at least one frame, including after a cancelled context; it is
376377 // removed otherwise. Close and Abort are terminal and the first one wins:
377-// later Close calls return the first Close result, and Close after an Abort
378-// returns nil because nothing was kept. Close is only concurrent with Abort
379-// (besides Write) as described on Abort.
378+// later Close calls return the first Close result. Close after an Abort returns
379+// its cleanup result (nil unless reaping or removal failed); nothing was kept.
380+// Close may run concurrently with Abort as described on Abort, but not Write.
380381 func (w *Writer) Close() error { return w.terminate(false) }
381382
382383 // Abort discards the recording. When it is the first terminal operation it
@@ -65,7 +65,8 @@ type failure struct {
65 }65 }
66 66
67 // Writer implements ports.VideoWriter. Start, Write and Close have one owner;67 // Writer implements ports.VideoWriter. Start, Write and Close have one owner;
68-// Abort may be called concurrently with Write. A Writer is single-use.68+// Abort may be called concurrently with Write or a draining Close.
69+// A Writer is single-use.
69 type Writer struct {70 type Writer struct {
70 binary string71 binary string
71 closeTimeout time.Duration72 closeTimeout time.Duration
@@ -374,9 +375,9 @@ func writeFull(dst io.Writer, p []byte) error {
374 // kills it) and reports the outcome. The video is kept when the encoder exits375 // kills it) and reports the outcome. The video is kept when the encoder exits
375 // cleanly after at least one frame, including after a cancelled context; it is376 // cleanly after at least one frame, including after a cancelled context; it is
376 // removed otherwise. Close and Abort are terminal and the first one wins:377 // removed otherwise. Close and Abort are terminal and the first one wins:
377-// later Close calls return the first Close result, and Close after an Abort378+// later Close calls return the first Close result. Close after an Abort returns
378-// returns nil because nothing was kept. Close is only concurrent with Abort379+// its cleanup result (nil unless reaping or removal failed); nothing was kept.
379-// (besides Write) as described on Abort.380+// Close may run concurrently with Abort as described on Abort, but not Write.
380 func (w *Writer) Close() error { return w.terminate(false) }381 func (w *Writer) Close() error { return w.terminate(false) }
381 382
382 // Abort discards the recording. When it is the first terminal operation it383 // Abort discards the recording. When it is the first terminal operation it
M internal/adapters/wayland/compositor_test.go +9 -6
@@ -370,6 +370,10 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
370370 spec := c.cfg.outputs[out]
371371 w, h := spec.width, spec.height
372372 c.mu.Lock()
373+ // cfg fields may be changed by tests between captures: read them under mu.
374+ removeOnCapture, failCapture := c.cfg.removeOnCapture, c.cfg.failCapture
375+ readyEarly, noShmOffer := c.cfg.readyEarly, c.cfg.noShmOffer
376+ list, version := c.cfg.announce, c.cfg.screencopyVersion
373377 c.captures++
374378 if op == 1 {
375379 c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))}
@@ -379,21 +383,20 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
379383 c.mu.Unlock()
380384 o.kinds[frame] = kindFrame
381385 o.frames[frame] = &frameState{output: out, w: w, h: h}
382- if c.cfg.removeOnCapture {
386+ if removeOnCapture {
383387 c.removeOutput(uint32(out + 1))
384388 c.send(frame, 3)
385389 return
386390 }
387- if c.cfg.failCapture {
391+ if failCapture {
388392 c.send(frame, 3)
389393 return
390394 }
391- if c.cfg.readyEarly {
395+ if readyEarly {
392396 c.send(frame, 2, 0, 1, 500)
393397 return
394398 }
395- list := c.cfg.announce
396- if c.cfg.noShmOffer {
399+ if noShmOffer {
397400 c.send(frame, 6)
398401 return
399402 }
@@ -403,7 +406,7 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
403406 for _, a := range list {
404407 c.send(frame, 0, a.format, a.width, a.height, a.stride)
405408 }
406- if c.cfg.screencopyVersion >= 3 {
409+ if version >= 3 {
407410 c.send(frame, 6)
408411 }
409412 }
@@ -370,6 +370,10 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
370 spec := c.cfg.outputs[out]370 spec := c.cfg.outputs[out]
371 w, h := spec.width, spec.height371 w, h := spec.width, spec.height
372 c.mu.Lock()372 c.mu.Lock()
373+ // cfg fields may be changed by tests between captures: read them under mu.
374+ removeOnCapture, failCapture := c.cfg.removeOnCapture, c.cfg.failCapture
375+ readyEarly, noShmOffer := c.cfg.readyEarly, c.cfg.noShmOffer
376+ list, version := c.cfg.announce, c.cfg.screencopyVersion
373 c.captures++377 c.captures++
374 if op == 1 {378 if op == 1 {
375 c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))}379 c.lastRegion = [4]int32{int32(word(body, 3)), int32(word(body, 4)), int32(word(body, 5)), int32(word(body, 6))}
@@ -379,21 +383,20 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
379 c.mu.Unlock()383 c.mu.Unlock()
380 o.kinds[frame] = kindFrame384 o.kinds[frame] = kindFrame
381 o.frames[frame] = &frameState{output: out, w: w, h: h}385 o.frames[frame] = &frameState{output: out, w: w, h: h}
382- if c.cfg.removeOnCapture {386+ if removeOnCapture {
383 c.removeOutput(uint32(out + 1))387 c.removeOutput(uint32(out + 1))
384 c.send(frame, 3)388 c.send(frame, 3)
385 return389 return
386 }390 }
387- if c.cfg.failCapture {391+ if failCapture {
388 c.send(frame, 3)392 c.send(frame, 3)
389 return393 return
390 }394 }
391- if c.cfg.readyEarly {395+ if readyEarly {
392 c.send(frame, 2, 0, 1, 500)396 c.send(frame, 2, 0, 1, 500)
393 return397 return
394 }398 }
395- list := c.cfg.announce399+ if noShmOffer {
396- if c.cfg.noShmOffer {
397 c.send(frame, 6)400 c.send(frame, 6)
398 return401 return
399 }402 }
@@ -403,7 +406,7 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) {
403 for _, a := range list {406 for _, a := range list {
404 c.send(frame, 0, a.format, a.width, a.height, a.stride)407 c.send(frame, 0, a.format, a.width, a.height, a.stride)
405 }408 }
406- if c.cfg.screencopyVersion >= 3 {409+ if version >= 3 {
407 c.send(frame, 6)410 c.send(frame, 6)
408 }411 }
409 }412 }
M internal/adapters/wayland/source.go +6 -2
@@ -109,8 +109,6 @@ func New(ctx context.Context, socket string) (*Source, error) {
109109 s.terminate()
110110 if cancelled || ctx.Err() != nil {
111111 err = ctx.Err()
112- } else {
113- err = fmt.Errorf("wayland: discover: %w", err)
114112 }
115113 s.log.Error().Err(err).Msg("wayland source failed to start")
116114 return nil, err
@@ -276,6 +274,12 @@ func (s *Source) detachWatcher() {
276274 // finish ends the call started by begin and classifies err. A cancellation
277275 // observed at any point makes the Source terminal. When it is terminal, the
278276 // mapped storage is released before returning.
277+//
278+// Ordering invariant: a context sets Err before it closes Done and runs
279+// AfterFunc callbacks. So a callback that ran while the call was active has
280+// already made Err visible, and a callback that ran while idle did nothing but
281+// is caught by the Err check. Clearing active before that check therefore
282+// cannot report success for a call whose context was cancelled in between.
279283 func (s *Source) finish(ctx context.Context, err error) error {
280284 s.watchActive.Store(false)
281285 if cerr := ctx.Err(); cerr != nil {
@@ -109,8 +109,6 @@ func New(ctx context.Context, socket string) (*Source, error) {
109 s.terminate()109 s.terminate()
110 if cancelled || ctx.Err() != nil {110 if cancelled || ctx.Err() != nil {
111 err = ctx.Err()111 err = ctx.Err()
112- } else {
113- err = fmt.Errorf("wayland: discover: %w", err)
114 }112 }
115 s.log.Error().Err(err).Msg("wayland source failed to start")113 s.log.Error().Err(err).Msg("wayland source failed to start")
116 return nil, err114 return nil, err
@@ -276,6 +274,12 @@ func (s *Source) detachWatcher() {
276 // finish ends the call started by begin and classifies err. A cancellation274 // finish ends the call started by begin and classifies err. A cancellation
277 // observed at any point makes the Source terminal. When it is terminal, the275 // observed at any point makes the Source terminal. When it is terminal, the
278 // mapped storage is released before returning.276 // mapped storage is released before returning.
277+//
278+// Ordering invariant: a context sets Err before it closes Done and runs
279+// AfterFunc callbacks. So a callback that ran while the call was active has
280+// already made Err visible, and a callback that ran while idle did nothing but
281+// is caught by the Err check. Clearing active before that check therefore
282+// cannot report success for a call whose context was cancelled in between.
279 func (s *Source) finish(ctx context.Context, err error) error {283 func (s *Source) finish(ctx context.Context, err error) error {
280 s.watchActive.Store(false)284 s.watchActive.Store(false)
281 if cerr := ctx.Err(); cerr != nil {285 if cerr := ctx.Err(); cerr != nil {
M internal/adapters/wayland/source_test.go +0 -8
@@ -599,14 +599,6 @@ func TestNoShmOffer(t *testing.T) {
599599 require.NoError(t, err)
600600 }
601601
602-func TestBufferDoneOnlyAfterShmOffer(t *testing.T) {
603- // The default peer sends buffer then buffer_done: capture waits for the latter.
604- s, _ := newSource(t, compositorConfig{})
605- out := firstOutput(t, s)
606- _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
607- require.NoError(t, err)
608-}
609-
610602 func TestMalformedOutputModeAndScale(t *testing.T) {
611603 s, _ := newSource(t, compositorConfig{outputs: []outputSpec{
612604 {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4},
@@ -599,14 +599,6 @@ func TestNoShmOffer(t *testing.T) {
599 require.NoError(t, err)599 require.NoError(t, err)
600 }600 }
601 601
602-func TestBufferDoneOnlyAfterShmOffer(t *testing.T) {
603- // The default peer sends buffer then buffer_done: capture waits for the latter.
604- s, _ := newSource(t, compositorConfig{})
605- out := firstOutput(t, s)
606- _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
607- require.NoError(t, err)
608-}
609-
610 func TestMalformedOutputModeAndScale(t *testing.T) {602 func TestMalformedOutputModeAndScale(t *testing.T) {
611 s, _ := newSource(t, compositorConfig{outputs: []outputSpec{603 s, _ := newSource(t, compositorConfig{outputs: []outputSpec{
612 {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4},604 {name: "BAD", width: 0xffffffff, height: 1 << 30, scale: -4, version: 4},
A internal/app/app.go +103 -0
@@ -0,0 +1,103 @@
1+// Package app wires the capture adapters to the core workflow.
2+package app
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/adapters/cli"
12+ "github.com/bnema/nefercap/internal/adapters/ffmpeg"
13+ "github.com/bnema/nefercap/internal/adapters/gui"
14+ "github.com/bnema/nefercap/internal/adapters/png"
15+ "github.com/bnema/nefercap/internal/adapters/wayland"
16+ "github.com/bnema/nefercap/internal/core"
17+ "github.com/bnema/nefercap/internal/logging"
18+ "github.com/bnema/nefercap/internal/ports"
19+)
20+
21+const discoveryTimeout = 5 * time.Second
22+
23+// Run owns the source connection and all capture work for one invocation.
24+func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) {
25+ discovery, cancel := context.WithTimeout(ctx, discoveryTimeout)
26+ source, err := wayland.New(discovery, "")
27+ if err != nil {
28+ cancel()
29+ return discoveryError(ctx, "connect Wayland", err)
30+ }
31+ defer func() {
32+ if closeErr := source.Close(); closeErr != nil {
33+ err = errors.Join(err, closeErr)
34+ }
35+ }()
36+ outputs, err := source.Outputs(discovery)
37+ cancel()
38+ if err != nil {
39+ return discoveryError(ctx, "list outputs", err)
40+ }
41+ if options.Command == cli.Outputs {
42+ for _, item := range outputs {
43+ if _, err := fmt.Fprintf(output, "%s\t%dx%d\tscale=%d\n", item.Name, item.Width, item.Height, item.Scale); err != nil {
44+ return err
45+ }
46+ }
47+ return nil
48+ }
49+ var selection ports.Selection
50+ if options.Command == cli.GUI {
51+ var accepted bool
52+ selection, accepted, err = gui.New().Select(ctx, outputs)
53+ if err != nil || !accepted {
54+ return err
55+ }
56+ } else {
57+ target, err := resolveOutput(outputs, options.Output)
58+ if err != nil {
59+ return err
60+ }
61+ selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Video: options.Video, Duration: options.Duration}
62+ switch options.Command {
63+ case cli.Screenshot:
64+ selection.Mode = ports.Screenshot
65+ case cli.Record:
66+ selection.Mode = ports.Record
67+ default:
68+ return fmt.Errorf("unsupported command %q", options.Command)
69+ }
70+ }
71+ log := logging.For(ctx, "app")
72+ 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 {
75+ return err
76+ }
77+ log.Info().Msg("capture complete")
78+ return nil
79+}
80+
81+// discoveryError preserves a user interrupt, but reports local timeouts and
82+// actual protocol failures with the failing phase.
83+func discoveryError(ctx context.Context, phase string, err error) error {
84+ if ctx.Err() != nil && err == ctx.Err() {
85+ return err
86+ }
87+ return fmt.Errorf("%s: %w", phase, err)
88+}
89+
90+func resolveOutput(outputs []ports.Output, name string) (ports.Output, error) {
91+ if name == "" {
92+ if len(outputs) == 1 {
93+ return outputs[0], nil
94+ }
95+ return ports.Output{}, fmt.Errorf("select an output with -output (use outputs to list names)")
96+ }
97+ for _, output := range outputs {
98+ if output.Name == name {
99+ return output, nil
100+ }
101+ }
102+ return ports.Output{}, fmt.Errorf("%w: %q", ports.ErrOutputNotFound, name)
103+}
@@ -0,0 +1,103 @@
1+// Package app wires the capture adapters to the core workflow.
2+package app
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/adapters/cli"
12+ "github.com/bnema/nefercap/internal/adapters/ffmpeg"
13+ "github.com/bnema/nefercap/internal/adapters/gui"
14+ "github.com/bnema/nefercap/internal/adapters/png"
15+ "github.com/bnema/nefercap/internal/adapters/wayland"
16+ "github.com/bnema/nefercap/internal/core"
17+ "github.com/bnema/nefercap/internal/logging"
18+ "github.com/bnema/nefercap/internal/ports"
19+)
20+
21+const discoveryTimeout = 5 * time.Second
22+
23+// Run owns the source connection and all capture work for one invocation.
24+func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) {
25+ discovery, cancel := context.WithTimeout(ctx, discoveryTimeout)
26+ source, err := wayland.New(discovery, "")
27+ if err != nil {
28+ cancel()
29+ return discoveryError(ctx, "connect Wayland", err)
30+ }
31+ defer func() {
32+ if closeErr := source.Close(); closeErr != nil {
33+ err = errors.Join(err, closeErr)
34+ }
35+ }()
36+ outputs, err := source.Outputs(discovery)
37+ cancel()
38+ if err != nil {
39+ return discoveryError(ctx, "list outputs", err)
40+ }
41+ if options.Command == cli.Outputs {
42+ for _, item := range outputs {
43+ if _, err := fmt.Fprintf(output, "%s\t%dx%d\tscale=%d\n", item.Name, item.Width, item.Height, item.Scale); err != nil {
44+ return err
45+ }
46+ }
47+ return nil
48+ }
49+ var selection ports.Selection
50+ if options.Command == cli.GUI {
51+ var accepted bool
52+ selection, accepted, err = gui.New().Select(ctx, outputs)
53+ if err != nil || !accepted {
54+ return err
55+ }
56+ } else {
57+ target, err := resolveOutput(outputs, options.Output)
58+ if err != nil {
59+ return err
60+ }
61+ selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Video: options.Video, Duration: options.Duration}
62+ switch options.Command {
63+ case cli.Screenshot:
64+ selection.Mode = ports.Screenshot
65+ case cli.Record:
66+ selection.Mode = ports.Record
67+ default:
68+ return fmt.Errorf("unsupported command %q", options.Command)
69+ }
70+ }
71+ log := logging.For(ctx, "app")
72+ 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 {
75+ return err
76+ }
77+ log.Info().Msg("capture complete")
78+ return nil
79+}
80+
81+// discoveryError preserves a user interrupt, but reports local timeouts and
82+// actual protocol failures with the failing phase.
83+func discoveryError(ctx context.Context, phase string, err error) error {
84+ if ctx.Err() != nil && err == ctx.Err() {
85+ return err
86+ }
87+ return fmt.Errorf("%s: %w", phase, err)
88+}
89+
90+func resolveOutput(outputs []ports.Output, name string) (ports.Output, error) {
91+ if name == "" {
92+ if len(outputs) == 1 {
93+ return outputs[0], nil
94+ }
95+ return ports.Output{}, fmt.Errorf("select an output with -output (use outputs to list names)")
96+ }
97+ for _, output := range outputs {
98+ if output.Name == name {
99+ return output, nil
100+ }
101+ }
102+ return ports.Output{}, fmt.Errorf("%w: %q", ports.ErrOutputNotFound, name)
103+}
A internal/app/app_test.go +58 -0
@@ -0,0 +1,58 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/nefercap/internal/ports"
9+)
10+
11+func TestDiscoveryError(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ defer cancel()
14+ deadline := discoveryError(ctx, "list outputs", context.DeadlineExceeded)
15+ if !errors.Is(deadline, context.DeadlineExceeded) || deadline == context.DeadlineExceeded {
16+ t.Fatalf("timeout lost phase: %v", deadline)
17+ }
18+ cancel()
19+ if err := discoveryError(ctx, "connect Wayland", context.Canceled); err != context.Canceled {
20+ t.Fatalf("signal wrapped: %v", err)
21+ }
22+ real := errors.New("protocol failure")
23+ if err := discoveryError(ctx, "list outputs", real); !errors.Is(err, real) || err == real {
24+ t.Fatalf("failure lost phase: %v", err)
25+ }
26+}
27+
28+func TestResolveOutput(t *testing.T) {
29+ outputs := []ports.Output{{ID: 1, Name: "DP-1"}, {ID: 2, Name: "HDMI-A-1"}}
30+ got, err := resolveOutput(outputs, "HDMI-A-1")
31+ if err != nil || got.ID != 2 {
32+ t.Fatalf("named output: %v, %v", got, err)
33+ }
34+ if _, err := resolveOutput(outputs, ""); err == nil {
35+ t.Fatal("ambiguous default accepted")
36+ }
37+ if _, err := resolveOutput(outputs, "missing"); !errors.Is(err, ports.ErrOutputNotFound) {
38+ t.Fatalf("missing output: %v", err)
39+ }
40+ if _, err := resolveOutput(nil, ""); err == nil {
41+ t.Fatal("empty default accepted")
42+ }
43+ got, err = resolveOutput(outputs[:1], "")
44+ if err != nil || got.ID != 1 {
45+ t.Fatalf("single output: %v, %v", got, err)
46+ }
47+}
48+
49+func TestResolveOutputAllocations(t *testing.T) {
50+ outputs := []ports.Output{{ID: 1, Name: "DP-1"}}
51+ if allocs := testing.AllocsPerRun(1000, func() {
52+ if _, err := resolveOutput(outputs, "DP-1"); err != nil {
53+ panic(err)
54+ }
55+ }); allocs != 0 {
56+ t.Fatalf("output lookup: %g allocations", allocs)
57+ }
58+}
@@ -0,0 +1,58 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/nefercap/internal/ports"
9+)
10+
11+func TestDiscoveryError(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ defer cancel()
14+ deadline := discoveryError(ctx, "list outputs", context.DeadlineExceeded)
15+ if !errors.Is(deadline, context.DeadlineExceeded) || deadline == context.DeadlineExceeded {
16+ t.Fatalf("timeout lost phase: %v", deadline)
17+ }
18+ cancel()
19+ if err := discoveryError(ctx, "connect Wayland", context.Canceled); err != context.Canceled {
20+ t.Fatalf("signal wrapped: %v", err)
21+ }
22+ real := errors.New("protocol failure")
23+ if err := discoveryError(ctx, "list outputs", real); !errors.Is(err, real) || err == real {
24+ t.Fatalf("failure lost phase: %v", err)
25+ }
26+}
27+
28+func TestResolveOutput(t *testing.T) {
29+ outputs := []ports.Output{{ID: 1, Name: "DP-1"}, {ID: 2, Name: "HDMI-A-1"}}
30+ got, err := resolveOutput(outputs, "HDMI-A-1")
31+ if err != nil || got.ID != 2 {
32+ t.Fatalf("named output: %v, %v", got, err)
33+ }
34+ if _, err := resolveOutput(outputs, ""); err == nil {
35+ t.Fatal("ambiguous default accepted")
36+ }
37+ if _, err := resolveOutput(outputs, "missing"); !errors.Is(err, ports.ErrOutputNotFound) {
38+ t.Fatalf("missing output: %v", err)
39+ }
40+ if _, err := resolveOutput(nil, ""); err == nil {
41+ t.Fatal("empty default accepted")
42+ }
43+ got, err = resolveOutput(outputs[:1], "")
44+ if err != nil || got.ID != 1 {
45+ t.Fatalf("single output: %v, %v", got, err)
46+ }
47+}
48+
49+func TestResolveOutputAllocations(t *testing.T) {
50+ outputs := []ports.Output{{ID: 1, Name: "DP-1"}}
51+ if allocs := testing.AllocsPerRun(1000, func() {
52+ if _, err := resolveOutput(outputs, "DP-1"); err != nil {
53+ panic(err)
54+ }
55+ }); allocs != 0 {
56+ t.Fatalf("output lookup: %g allocations", allocs)
57+ }
58+}
A internal/app/recording_test.go +77 -0
@@ -0,0 +1,77 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strconv"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefercap/internal/adapters/ffmpeg"
14+ "github.com/bnema/nefercap/internal/core"
15+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+// A generated source mock supplies pixels; the production core and FFmpeg
20+// writer run together, exercising cancellation at their owning boundary.
21+func TestRecordingFinalizesAfterCancellation(t *testing.T) {
22+ for _, binary := range []string{"ffmpeg", "ffprobe"} {
23+ if _, err := exec.LookPath(binary); err != nil {
24+ t.Skip(binary + " unavailable")
25+ }
26+ }
27+ ctx, cancel := context.WithCancel(context.Background())
28+ defer cancel()
29+ frame := ports.Frame{Pixels: make([]byte, 64*48*4), Width: 64, Height: 48, Stride: 64 * 4, Format: ports.XRGB8888}
30+ for i := 0; i < len(frame.Pixels); i += 4 {
31+ frame.Pixels[i+2] = 255
32+ }
33+ target := ports.Target{OutputID: 1}
34+ source := portsmocks.NewMockSource(t)
35+ n := 0
36+ source.EXPECT().Capture(ctx, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
37+ n++
38+ if n == 5 {
39+ cancel()
40+ return ports.Frame{}, context.Canceled
41+ }
42+ return frame, nil
43+ })
44+ path := filepath.Join(t.TempDir(), "recording.mp4")
45+ if err := core.New(source, nil, ffmpeg.New()).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}}); err != nil {
46+ t.Fatal(err)
47+ }
48+ probeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
49+ defer stop()
50+ data, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=codec_name,nb_read_frames", "-of", "json", path).Output()
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ var result struct {
55+ Streams []struct {
56+ Codec string `json:"codec_name"`
57+ Frames string `json:"nb_read_frames"`
58+ }
59+ }
60+ if err := json.Unmarshal(data, &result); err != nil {
61+ t.Fatal(err)
62+ }
63+ if len(result.Streams) != 1 || result.Streams[0].Codec != "h264" {
64+ t.Fatalf("unexpected probe: %s", data)
65+ }
66+ frames, err := strconv.Atoi(result.Streams[0].Frames)
67+ if err != nil || frames < 4 {
68+ t.Fatalf("complete frames missing: %s", data)
69+ }
70+ info, err := os.Stat(path)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if info.Mode().Perm() != 0o600 {
75+ t.Fatalf("permissions: %v", info.Mode())
76+ }
77+}
@@ -0,0 +1,77 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strconv"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefercap/internal/adapters/ffmpeg"
14+ "github.com/bnema/nefercap/internal/core"
15+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+// A generated source mock supplies pixels; the production core and FFmpeg
20+// writer run together, exercising cancellation at their owning boundary.
21+func TestRecordingFinalizesAfterCancellation(t *testing.T) {
22+ for _, binary := range []string{"ffmpeg", "ffprobe"} {
23+ if _, err := exec.LookPath(binary); err != nil {
24+ t.Skip(binary + " unavailable")
25+ }
26+ }
27+ ctx, cancel := context.WithCancel(context.Background())
28+ defer cancel()
29+ frame := ports.Frame{Pixels: make([]byte, 64*48*4), Width: 64, Height: 48, Stride: 64 * 4, Format: ports.XRGB8888}
30+ for i := 0; i < len(frame.Pixels); i += 4 {
31+ frame.Pixels[i+2] = 255
32+ }
33+ target := ports.Target{OutputID: 1}
34+ source := portsmocks.NewMockSource(t)
35+ n := 0
36+ source.EXPECT().Capture(ctx, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
37+ n++
38+ if n == 5 {
39+ cancel()
40+ return ports.Frame{}, context.Canceled
41+ }
42+ return frame, nil
43+ })
44+ path := filepath.Join(t.TempDir(), "recording.mp4")
45+ if err := core.New(source, nil, ffmpeg.New()).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}}); err != nil {
46+ t.Fatal(err)
47+ }
48+ probeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
49+ defer stop()
50+ data, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=codec_name,nb_read_frames", "-of", "json", path).Output()
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ var result struct {
55+ Streams []struct {
56+ Codec string `json:"codec_name"`
57+ Frames string `json:"nb_read_frames"`
58+ }
59+ }
60+ if err := json.Unmarshal(data, &result); err != nil {
61+ t.Fatal(err)
62+ }
63+ if len(result.Streams) != 1 || result.Streams[0].Codec != "h264" {
64+ t.Fatalf("unexpected probe: %s", data)
65+ }
66+ frames, err := strconv.Atoi(result.Streams[0].Frames)
67+ if err != nil || frames < 4 {
68+ t.Fatalf("complete frames missing: %s", data)
69+ }
70+ info, err := os.Stat(path)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if info.Mode().Perm() != 0o600 {
75+ t.Fatalf("permissions: %v", info.Mode())
76+ }
77+}
M internal/ports/capture.go +5 -1
@@ -68,7 +68,11 @@ type VideoSettings struct {
6868 // silently crop when zero output dimensions request source resolution.
6969 // Write consumes borrowed pixels before returning; it must not queue frames.
7070 // Close drains and finalizes the encoder with a bounded shutdown deadline.
71-// Abort interrupts pending writes and is safe concurrently with Write.
71+// Abort as the first terminal operation interrupts writes and discards output.
72+// Abort is safe concurrently with Write or a draining Close. During Close it
73+// escalates shutdown unless Close already observed encoder exit; Close's result
74+// is authoritative. Abort after completed Close is a no-op. Close after Abort
75+// returns Abort's cleanup result. Close preserves frames on a clean exit.
7276 // Cancellation errors wrap ctx.Err(); unrelated failures must be preserved.
7377 // Write rejects width, height or format changes with ErrGeometryChanged.
7478 // Stride changes are allowed. Close and Abort are idempotent terminal operations;
@@ -68,7 +68,11 @@ type VideoSettings struct {
68 // silently crop when zero output dimensions request source resolution.68 // silently crop when zero output dimensions request source resolution.
69 // Write consumes borrowed pixels before returning; it must not queue frames.69 // Write consumes borrowed pixels before returning; it must not queue frames.
70 // Close drains and finalizes the encoder with a bounded shutdown deadline.70 // Close drains and finalizes the encoder with a bounded shutdown deadline.
71-// Abort interrupts pending writes and is safe concurrently with Write.71+// Abort as the first terminal operation interrupts writes and discards output.
72+// Abort is safe concurrently with Write or a draining Close. During Close it
73+// escalates shutdown unless Close already observed encoder exit; Close's result
74+// is authoritative. Abort after completed Close is a no-op. Close after Abort
75+// returns Abort's cleanup result. Close preserves frames on a clean exit.
72 // Cancellation errors wrap ctx.Err(); unrelated failures must be preserved.76 // Cancellation errors wrap ctx.Err(); unrelated failures must be preserved.
73 // Write rejects width, height or format changes with ErrGeometryChanged.77 // Write rejects width, height or format changes with ErrGeometryChanged.
74 // Stride changes are allowed. Close and Abort are idempotent terminal operations;78 // Stride changes are allowed. Close and Abort are idempotent terminal operations;

Keyboard shortcuts

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

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

Branches 2

Tags 9

↑↓ moveenter openesc close