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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +428 -41
M Makefile +4 -4
| @@ -43,10 +43,10 @@ fakes-check: | |||
| 43 | 43 | arch: | |
| 44 | 44 | $(HEXCHECK) -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 45 | 45 | # 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 | |
| 50 | 50 | perf_empty := | |
| 51 | 51 | perf_space := $(perf_empty) $(perf_empty) | |
| 52 | 52 | 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_PKGS | 45 | # 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 | 46 | + | # 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 := TestFrameAllocations | 48 | + | PERF_TESTS := TestFrameAllocations TestSelectionAllocations TestFormAllocations TestVideoRowsAllocations TestWriteSteadyStateAllocations TestResolveOutputAllocations TestCaptureAllocations |
| 49 | - | PERF_PKGS := ./internal/ports | 49 | + | 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 ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 18 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 15 | 19 | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 16 | 20 | github.com/rs/zerolog v1.35.1 // indirect | |
| 17 | 21 | github.com/stretchr/objx v0.5.3 // indirect | |
| 18 | 22 | 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 | 25 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect | |
| 20 | 26 | ) | |
| @@ -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 // indirect | 18 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 15 | github.com/mattn/go-isatty v0.0.22 // indirect | 19 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
| 16 | github.com/rs/zerolog v1.35.1 // indirect | 20 | github.com/rs/zerolog v1.35.1 // indirect | ||
| 17 | github.com/stretchr/objx v0.5.3 // indirect | 21 | github.com/stretchr/objx v0.5.3 // indirect | ||
| 18 | go.yaml.in/yaml/v3 v3.0.5 // indirect | 22 | 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 // indirect | 25 | 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= | |||
| 10 | 10 | github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |
| 11 | 11 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
| 12 | 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 | 13 | github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= | |
| 20 | 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 | 17 | github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | |
| 22 | 18 | github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | |
| 23 | 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 | 24 | github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= | |
| 29 | 25 | github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= | |
| 30 | 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 | 27 | go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= | |
| 42 | 28 | go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= | |
| 43 | 29 | 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 { | |||
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 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 | 70 | type Writer struct { | |
| 70 | 71 | binary string | |
| 71 | 72 | closeTimeout time.Duration | |
| @@ -374,9 +375,9 @@ func writeFull(dst io.Writer, p []byte) error { | |||
| 374 | 375 | // kills it) and reports the outcome. The video is kept when the encoder exits | |
| 375 | 376 | // cleanly after at least one frame, including after a cancelled context; it is | |
| 376 | 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 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. | |
| 380 | 381 | func (w *Writer) Close() error { return w.terminate(false) } | |
| 381 | 382 | ||
| 382 | 383 | // 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 string | 71 | binary string | ||
| 71 | closeTimeout time.Duration | 72 | 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 exits | 375 | // 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 is | 376 | // 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 Abort | 378 | + | // 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 Abort | 379 | + | // 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 it | 383 | // 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) { | |||
| 370 | 370 | spec := c.cfg.outputs[out] | |
| 371 | 371 | w, h := spec.width, spec.height | |
| 372 | 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 | 377 | c.captures++ | |
| 374 | 378 | if op == 1 { | |
| 375 | 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 | 383 | c.mu.Unlock() | |
| 380 | 384 | o.kinds[frame] = kindFrame | |
| 381 | 385 | o.frames[frame] = &frameState{output: out, w: w, h: h} | |
| 382 | - | if c.cfg.removeOnCapture { | |
| 386 | + | if removeOnCapture { | |
| 383 | 387 | c.removeOutput(uint32(out + 1)) | |
| 384 | 388 | c.send(frame, 3) | |
| 385 | 389 | return | |
| 386 | 390 | } | |
| 387 | - | if c.cfg.failCapture { | |
| 391 | + | if failCapture { | |
| 388 | 392 | c.send(frame, 3) | |
| 389 | 393 | return | |
| 390 | 394 | } | |
| 391 | - | if c.cfg.readyEarly { | |
| 395 | + | if readyEarly { | |
| 392 | 396 | c.send(frame, 2, 0, 1, 500) | |
| 393 | 397 | return | |
| 394 | 398 | } | |
| 395 | - | list := c.cfg.announce | |
| 396 | - | if c.cfg.noShmOffer { | |
| 399 | + | if noShmOffer { | |
| 397 | 400 | c.send(frame, 6) | |
| 398 | 401 | return | |
| 399 | 402 | } | |
| @@ -403,7 +406,7 @@ func (c *compositor) capture(o *objects, op uint16, body []byte) { | |||
| 403 | 406 | for _, a := range list { | |
| 404 | 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 | 410 | c.send(frame, 6) | |
| 408 | 411 | } | |
| 409 | 412 | } | |
| @@ -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.height | 371 | 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] = kindFrame | 384 | 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 | return | 389 | return | ||
| 386 | } | 390 | } | ||
| 387 | - | if c.cfg.failCapture { | 391 | + | if failCapture { |
| 388 | c.send(frame, 3) | 392 | c.send(frame, 3) | ||
| 389 | return | 393 | 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 | return | 397 | return | ||
| 394 | } | 398 | } | ||
| 395 | - | list := c.cfg.announce | 399 | + | if noShmOffer { |
| 396 | - | if c.cfg.noShmOffer { | |||
| 397 | c.send(frame, 6) | 400 | c.send(frame, 6) | ||
| 398 | return | 401 | 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) { | |||
| 109 | 109 | s.terminate() | |
| 110 | 110 | if cancelled || ctx.Err() != nil { | |
| 111 | 111 | err = ctx.Err() | |
| 112 | - | } else { | |
| 113 | - | err = fmt.Errorf("wayland: discover: %w", err) | |
| 114 | 112 | } | |
| 115 | 113 | s.log.Error().Err(err).Msg("wayland source failed to start") | |
| 116 | 114 | return nil, err | |
| @@ -276,6 +274,12 @@ func (s *Source) detachWatcher() { | |||
| 276 | 274 | // finish ends the call started by begin and classifies err. A cancellation | |
| 277 | 275 | // observed at any point makes the Source terminal. When it is terminal, the | |
| 278 | 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 | 283 | func (s *Source) finish(ctx context.Context, err error) error { | |
| 280 | 284 | s.watchActive.Store(false) | |
| 281 | 285 | 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, err | 114 | return nil, err | ||
| @@ -276,6 +274,12 @@ func (s *Source) detachWatcher() { | |||||
| 276 | // finish ends the call started by begin and classifies err. A cancellation | 274 | // 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, the | 275 | // 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) { | |||
| 599 | 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 | 602 | func TestMalformedOutputModeAndScale(t *testing.T) { | |
| 611 | 603 | s, _ := newSource(t, compositorConfig{outputs: []outputSpec{ | |
| 612 | 604 | {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 { | |||
| 68 | 68 | // silently crop when zero output dimensions request source resolution. | |
| 69 | 69 | // Write consumes borrowed pixels before returning; it must not queue frames. | |
| 70 | 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 | 76 | // Cancellation errors wrap ctx.Err(); unrelated failures must be preserved. | |
| 73 | 77 | // Write rejects width, height or format changes with ErrGeometryChanged. | |
| 74 | 78 | // 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; | ||