feat(screenshot): copy PNG captures to the Wayland clipboard
- commit
- 461ae32a8ed7bd8ad05b24c1e40a151657564487
- author
- brice · · 2026-09-30 14:15 UTC
- committer
- brice · · 2026-09-30 14:48 UTC
- parent
- 111a4b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +622 -24
M .mockery.yml +1 -0
| @@ -17,6 +17,7 @@ packages: | |||
| 17 | 17 | interfaces: | |
| 18 | 18 | Source: | |
| 19 | 19 | ScreenshotWriter: | |
| 20 | + | Clipboard: | |
| 20 | 21 | VideoWriter: | |
| 21 | 22 | Selector: | |
| 22 | 23 | io: | |
| @@ -17,6 +17,7 @@ packages: | |||||
| 17 | interfaces: | 17 | interfaces: | ||
| 18 | Source: | 18 | Source: | ||
| 19 | ScreenshotWriter: | 19 | ScreenshotWriter: | ||
| 20 | + | Clipboard: | |||
| 20 | VideoWriter: | 21 | VideoWriter: | ||
| 21 | Selector: | 22 | Selector: | ||
| 22 | io: | 23 | io: | ||
M README.md +5 -1
| @@ -7,6 +7,8 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell | |||
| 7 | 7 | ```sh | |
| 8 | 8 | make bin | |
| 9 | 9 | ./bin/nefercap shot | |
| 10 | + | ./bin/nefercap shot -clipboard | |
| 11 | + | ./bin/nefercap shot -clipboard -file capture.png | |
| 10 | 12 | ./bin/nefercap rec | |
| 11 | 13 | ./bin/nefercap stop | |
| 12 | 14 | ./bin/nefercap status | |
| @@ -37,6 +39,8 @@ Monitor recording follows workspace changes. A workspace recording follows its s | |||
| 37 | 39 | ||
| 38 | 40 | Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. | |
| 39 | 41 | ||
| 42 | + | `shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it. | |
| 43 | + | ||
| 40 | 44 | Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. | |
| 41 | 45 | ||
| 42 | 46 | ## Requirements | |
| @@ -59,7 +63,7 @@ A standard compositor can support monitor/region screenshots. Recording refuses | |||
| 59 | 63 | ./bin/nefercap rec -size 1920x1080 -file presentation.mp4 | |
| 60 | 64 | ``` | |
| 61 | 65 | ||
| 62 | - | Use an output name from `outputs`. Scripted `screenshot` and `record` require `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor. | |
| 66 | + | Use an output name from `outputs`. Scripted `screenshot` requires `-file` unless `-clipboard` is used; `record` always requires `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor. | |
| 63 | 67 | ||
| 64 | 68 | `-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging. | |
| 65 | 69 | ||
| @@ -7,6 +7,8 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell | |||||
| 7 | ```sh | 7 | ```sh | ||
| 8 | make bin | 8 | make bin | ||
| 9 | ./bin/nefercap shot | 9 | ./bin/nefercap shot | ||
| 10 | + | ./bin/nefercap shot -clipboard | |||
| 11 | + | ./bin/nefercap shot -clipboard -file capture.png | |||
| 10 | ./bin/nefercap rec | 12 | ./bin/nefercap rec | ||
| 11 | ./bin/nefercap stop | 13 | ./bin/nefercap stop | ||
| 12 | ./bin/nefercap status | 14 | ./bin/nefercap status | ||
| @@ -37,6 +39,8 @@ Monitor recording follows workspace changes. A workspace recording follows its s | |||||
| 37 | 39 | ||||
| 38 | Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. | 40 | Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path. | ||
| 39 | 41 | ||||
| 42 | + | `shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it. | |||
| 43 | + | ||||
| 40 | Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. | 44 | Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border. | ||
| 41 | 45 | ||||
| 42 | ## Requirements | 46 | ## Requirements | ||
| @@ -59,7 +63,7 @@ A standard compositor can support monitor/region screenshots. Recording refuses | |||||
| 59 | ./bin/nefercap rec -size 1920x1080 -file presentation.mp4 | 63 | ./bin/nefercap rec -size 1920x1080 -file presentation.mp4 | ||
| 60 | ``` | 64 | ``` | ||
| 61 | 65 | ||||
| 62 | - | Use an output name from `outputs`. Scripted `screenshot` and `record` require `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor. | 66 | + | Use an output name from `outputs`. Scripted `screenshot` requires `-file` unless `-clipboard` is used; `record` always requires `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor. |
| 63 | 67 | ||||
| 64 | `-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging. | 68 | `-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging. | ||
| 65 | 69 | ||||
M internal/adapters/capturefile/capturefile.go +10 -1
| @@ -22,7 +22,16 @@ type Reservation struct { | |||
| 22 | 22 | // error matching both ports.ErrPathExists and fs.ErrExist. The returned | |
| 23 | 23 | // Reservation records the file's identity for a later Remove. | |
| 24 | 24 | func Create(path string) (*os.File, *Reservation, error) { | |
| 25 | - | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | |
| 25 | + | return create(path, os.O_WRONLY) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // CreateReadable reserves a file whose original descriptor can be reused for copying. | |
| 29 | + | func CreateReadable(path string) (*os.File, *Reservation, error) { | |
| 30 | + | return create(path, os.O_RDWR) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func create(path string, access int) (*os.File, *Reservation, error) { | |
| 34 | + | f, err := os.OpenFile(path, access|os.O_CREATE|os.O_EXCL, 0o600) | |
| 26 | 35 | if err != nil { | |
| 27 | 36 | if errors.Is(err, fs.ErrExist) { | |
| 28 | 37 | return nil, nil, fmt.Errorf("%w: %w", ports.ErrPathExists, err) | |
| @@ -22,7 +22,16 @@ type Reservation struct { | |||||
| 22 | // error matching both ports.ErrPathExists and fs.ErrExist. The returned | 22 | // error matching both ports.ErrPathExists and fs.ErrExist. The returned | ||
| 23 | // Reservation records the file's identity for a later Remove. | 23 | // Reservation records the file's identity for a later Remove. | ||
| 24 | func Create(path string) (*os.File, *Reservation, error) { | 24 | func Create(path string) (*os.File, *Reservation, error) { | ||
| 25 | - | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | 25 | + | return create(path, os.O_WRONLY) |
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // CreateReadable reserves a file whose original descriptor can be reused for copying. | |||
| 29 | + | func CreateReadable(path string) (*os.File, *Reservation, error) { | |||
| 30 | + | return create(path, os.O_RDWR) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func create(path string, access int) (*os.File, *Reservation, error) { | |||
| 34 | + | f, err := os.OpenFile(path, access|os.O_CREATE|os.O_EXCL, 0o600) | |||
| 26 | if err != nil { | 35 | if err != nil { | ||
| 27 | if errors.Is(err, fs.ErrExist) { | 36 | if errors.Is(err, fs.ErrExist) { | ||
| 28 | return nil, nil, fmt.Errorf("%w: %w", ports.ErrPathExists, err) | 37 | return nil, nil, fmt.Errorf("%w: %w", ports.ErrPathExists, err) | ||
M internal/adapters/cli/cli.go +15 -10
| @@ -42,13 +42,14 @@ var ErrUsage = errors.New("usage error") | |||
| 42 | 42 | // Path is optional for Shot and Rec, where an empty Path lets the caller | |
| 43 | 43 | // choose one. | |
| 44 | 44 | type Options struct { | |
| 45 | - | Command Command | |
| 46 | - | Output string // output name; empty lets the caller choose | |
| 47 | - | Region ports.Region | |
| 48 | - | Path string | |
| 49 | - | Video ports.VideoSettings | |
| 50 | - | Duration time.Duration | |
| 51 | - | Debug bool | |
| 45 | + | Command Command | |
| 46 | + | Output string // output name; empty lets the caller choose | |
| 47 | + | Region ports.Region | |
| 48 | + | Path string | |
| 49 | + | Video ports.VideoSettings | |
| 50 | + | Duration time.Duration | |
| 51 | + | Debug bool | |
| 52 | + | Clipboard bool | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | 55 | // Usage describes the command line. | |
| @@ -80,9 +81,10 @@ Script commands (never open the selector): | |||
| 80 | 81 | record record silent video until interrupted or target video length is reached | |
| 81 | 82 | ||
| 82 | 83 | Flags: | |
| 84 | + | -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot) | |
| 83 | 85 | -debug enable debug logging (all commands) | |
| 84 | 86 | -output NAME output name (all except stop, status, outputs) | |
| 85 | - | -file PATH new file to write; optional for shot, rec, required for screenshot, record | |
| 87 | + | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it | |
| 86 | 88 | -region X,Y,WxH output-local logical region; default is the full output (screenshot, record) | |
| 87 | 89 | -fps N frames per second, 1..%d (rec, record, default %d) | |
| 88 | 90 | -size WxH scaled video size, both even, at most %d each (rec, record, default source size) | |
| @@ -123,6 +125,9 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 123 | 125 | if scripted { | |
| 124 | 126 | fs.StringVar(®ion, "region", "", "") | |
| 125 | 127 | } | |
| 128 | + | if opts.Command == Shot || opts.Command == Screenshot { | |
| 129 | + | fs.BoolVar(&opts.Clipboard, "clipboard", false, "") | |
| 130 | + | } | |
| 126 | 131 | if video { | |
| 127 | 132 | fs.IntVar(&fps, "fps", DefaultFPS, "") | |
| 128 | 133 | fs.StringVar(&size, "size", "", "") | |
| @@ -143,10 +148,10 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||
| 143 | 148 | return opts, nil | |
| 144 | 149 | } | |
| 145 | 150 | ||
| 146 | - | if scripted && opts.Path == "" { | |
| 151 | + | if scripted && opts.Path == "" && !opts.Clipboard { | |
| 147 | 152 | return Options{}, usageErr("-file is required") | |
| 148 | 153 | } | |
| 149 | - | if interactive { | |
| 154 | + | if interactive || opts.Clipboard { | |
| 150 | 155 | // Set explicitly so "-file ''" is rejected rather than treated as auto. | |
| 151 | 156 | explicit := false | |
| 152 | 157 | fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" }) | |
| @@ -42,13 +42,14 @@ var ErrUsage = errors.New("usage error") | |||||
| 42 | // Path is optional for Shot and Rec, where an empty Path lets the caller | 42 | // Path is optional for Shot and Rec, where an empty Path lets the caller | ||
| 43 | // choose one. | 43 | // choose one. | ||
| 44 | type Options struct { | 44 | type Options struct { | ||
| 45 | - | Command Command | 45 | + | Command Command |
| 46 | - | Output string // output name; empty lets the caller choose | 46 | + | Output string // output name; empty lets the caller choose |
| 47 | - | Region ports.Region | 47 | + | Region ports.Region |
| 48 | - | Path string | 48 | + | Path string |
| 49 | - | Video ports.VideoSettings | 49 | + | Video ports.VideoSettings |
| 50 | - | Duration time.Duration | 50 | + | Duration time.Duration |
| 51 | - | Debug bool | 51 | + | Debug bool |
| 52 | + | Clipboard bool | |||
| 52 | } | 53 | } | ||
| 53 | 54 | ||||
| 54 | // Usage describes the command line. | 55 | // Usage describes the command line. | ||
| @@ -80,9 +81,10 @@ Script commands (never open the selector): | |||||
| 80 | record record silent video until interrupted or target video length is reached | 81 | record record silent video until interrupted or target video length is reached | ||
| 81 | 82 | ||||
| 82 | Flags: | 83 | Flags: | ||
| 84 | + | -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot) | |||
| 83 | -debug enable debug logging (all commands) | 85 | -debug enable debug logging (all commands) | ||
| 84 | -output NAME output name (all except stop, status, outputs) | 86 | -output NAME output name (all except stop, status, outputs) | ||
| 85 | - | -file PATH new file to write; optional for shot, rec, required for screenshot, record | 87 | + | -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it |
| 86 | -region X,Y,WxH output-local logical region; default is the full output (screenshot, record) | 88 | -region X,Y,WxH output-local logical region; default is the full output (screenshot, record) | ||
| 87 | -fps N frames per second, 1..%d (rec, record, default %d) | 89 | -fps N frames per second, 1..%d (rec, record, default %d) | ||
| 88 | -size WxH scaled video size, both even, at most %d each (rec, record, default source size) | 90 | -size WxH scaled video size, both even, at most %d each (rec, record, default source size) | ||
| @@ -123,6 +125,9 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||||
| 123 | if scripted { | 125 | if scripted { | ||
| 124 | fs.StringVar(®ion, "region", "", "") | 126 | fs.StringVar(®ion, "region", "", "") | ||
| 125 | } | 127 | } | ||
| 128 | + | if opts.Command == Shot || opts.Command == Screenshot { | |||
| 129 | + | fs.BoolVar(&opts.Clipboard, "clipboard", false, "") | |||
| 130 | + | } | |||
| 126 | if video { | 131 | if video { | ||
| 127 | fs.IntVar(&fps, "fps", DefaultFPS, "") | 132 | fs.IntVar(&fps, "fps", DefaultFPS, "") | ||
| 128 | fs.StringVar(&size, "size", "", "") | 133 | fs.StringVar(&size, "size", "", "") | ||
| @@ -143,10 +148,10 @@ func Parse(args []string, out io.Writer) (Options, error) { | |||||
| 143 | return opts, nil | 148 | return opts, nil | ||
| 144 | } | 149 | } | ||
| 145 | 150 | ||||
| 146 | - | if scripted && opts.Path == "" { | 151 | + | if scripted && opts.Path == "" && !opts.Clipboard { |
| 147 | return Options{}, usageErr("-file is required") | 152 | return Options{}, usageErr("-file is required") | ||
| 148 | } | 153 | } | ||
| 149 | - | if interactive { | 154 | + | if interactive || opts.Clipboard { |
| 150 | // Set explicitly so "-file ''" is rejected rather than treated as auto. | 155 | // Set explicitly so "-file ''" is rejected rather than treated as auto. | ||
| 151 | explicit := false | 156 | explicit := false | ||
| 152 | fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" }) | 157 | fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" }) | ||
A internal/adapters/cli/clipboard_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/stretchr/testify/require" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestClipboardFlag(t *testing.T) { | |
| 10 | + | for _, cmd := range []string{"shot", "screenshot"} { | |
| 11 | + | opts, err := Parse([]string{cmd, "-clipboard"}, io.Discard) | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | require.True(t, opts.Clipboard) | |
| 14 | + | require.Empty(t, opts.Path) | |
| 15 | + | opts, err = Parse([]string{cmd, "-clipboard", "-file", "shot.png"}, io.Discard) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | require.True(t, opts.Clipboard) | |
| 18 | + | require.Equal(t, "shot.png", opts.Path) | |
| 19 | + | _, err = Parse([]string{cmd, "-clipboard", "-file", ""}, io.Discard) | |
| 20 | + | require.ErrorIs(t, err, ErrUsage) | |
| 21 | + | } | |
| 22 | + | for _, cmd := range []string{"rec", "record", "stop", "status", "outputs"} { | |
| 23 | + | _, err := Parse([]string{cmd, "-clipboard"}, io.Discard) | |
| 24 | + | require.ErrorIs(t, err, ErrUsage) | |
| 25 | + | } | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/stretchr/testify/require" | |||
| 5 | + | "io" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestClipboardFlag(t *testing.T) { | |||
| 10 | + | for _, cmd := range []string{"shot", "screenshot"} { | |||
| 11 | + | opts, err := Parse([]string{cmd, "-clipboard"}, io.Discard) | |||
| 12 | + | require.NoError(t, err) | |||
| 13 | + | require.True(t, opts.Clipboard) | |||
| 14 | + | require.Empty(t, opts.Path) | |||
| 15 | + | opts, err = Parse([]string{cmd, "-clipboard", "-file", "shot.png"}, io.Discard) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | require.True(t, opts.Clipboard) | |||
| 18 | + | require.Equal(t, "shot.png", opts.Path) | |||
| 19 | + | _, err = Parse([]string{cmd, "-clipboard", "-file", ""}, io.Discard) | |||
| 20 | + | require.ErrorIs(t, err, ErrUsage) | |||
| 21 | + | } | |||
| 22 | + | for _, cmd := range []string{"rec", "record", "stop", "status", "outputs"} { | |||
| 23 | + | _, err := Parse([]string{cmd, "-clipboard"}, io.Discard) | |||
| 24 | + | require.ErrorIs(t, err, ErrUsage) | |||
| 25 | + | } | |||
| 26 | + | } | |||
A internal/adapters/clipboard/clipboard.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | // Package clipboard streams PNG screenshots to the native Wayland clipboard. | |
| 2 | + | package clipboard | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "os/exec" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/nefercap/internal/adapters/capturefile" | |
| 14 | + | "github.com/bnema/nefercap/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Native uses wl-copy's default fork: the clipboard owner intentionally survives | |
| 18 | + | // nefercap. Only the initial handoff is bounded; never use --foreground here. | |
| 19 | + | type Native struct{} | |
| 20 | + | ||
| 21 | + | func New() *Native { return &Native{} } | |
| 22 | + | ||
| 23 | + | func (*Native) Copy(ctx context.Context, r io.Reader) error { | |
| 24 | + | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 25 | + | defer cancel() | |
| 26 | + | if err := ctx.Err(); err != nil { | |
| 27 | + | return err | |
| 28 | + | } | |
| 29 | + | cmd := exec.CommandContext(ctx, "wl-copy", "--type", "image/png") | |
| 30 | + | cmd.Stdin = r | |
| 31 | + | // Give wl-copy a real descriptor: its forked owner inherits stderr, so | |
| 32 | + | // exec's automatic stderr copier would wait for that long-lived owner. | |
| 33 | + | rd, wr, err := os.Pipe() | |
| 34 | + | if err != nil { | |
| 35 | + | return fmt.Errorf("wl-copy stderr: %w", err) | |
| 36 | + | } | |
| 37 | + | stderr := &limitedError{} | |
| 38 | + | drained := make(chan struct{}) | |
| 39 | + | go func() { _, _ = io.Copy(stderr, rd); close(drained) }() | |
| 40 | + | cmd.Stderr = wr | |
| 41 | + | cmd.WaitDelay = 200 * time.Millisecond | |
| 42 | + | err = cmd.Run() | |
| 43 | + | _ = wr.Close() | |
| 44 | + | // Drain queued diagnostics, but never wait for the forked owner to close. | |
| 45 | + | if deadlineErr := rd.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); deadlineErr != nil { | |
| 46 | + | _ = rd.Close() | |
| 47 | + | } | |
| 48 | + | <-drained | |
| 49 | + | _ = rd.Close() | |
| 50 | + | if err != nil { | |
| 51 | + | if ctx.Err() != nil { | |
| 52 | + | return fmt.Errorf("wl-copy: %w", ctx.Err()) | |
| 53 | + | } | |
| 54 | + | if errors.Is(err, exec.ErrNotFound) { | |
| 55 | + | return fmt.Errorf("wl-copy not found: install wl-clipboard: %w", err) | |
| 56 | + | } | |
| 57 | + | return fmt.Errorf("wl-copy: %w: %s", err, stderr.data) | |
| 58 | + | } | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type limitedError struct{ data []byte } | |
| 63 | + | ||
| 64 | + | func (b *limitedError) Write(p []byte) (int, error) { | |
| 65 | + | n := len(p) | |
| 66 | + | remaining := 4096 - len(b.data) | |
| 67 | + | b.data = append(b.data, p[:min(remaining, len(p))]...) | |
| 68 | + | return n, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // pngStream keeps diagnostic formatters from inspecting a concurrently active pipe. | |
| 72 | + | type pngStream struct{ io.Reader } | |
| 73 | + | ||
| 74 | + | func (pngStream) String() string { return "PNG stream" } | |
| 75 | + | ||
| 76 | + | // Writer composes the existing PNG codec and clipboard port. Saved is set only | |
| 77 | + | // after encoding a valid file, and survives clipboard failures. | |
| 78 | + | // Each Writer belongs to one screenshot invocation. | |
| 79 | + | type Writer struct { | |
| 80 | + | codec ports.ScreenshotWriter | |
| 81 | + | clipboard ports.Clipboard | |
| 82 | + | Saved bool | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func NewWriter(codec ports.ScreenshotWriter, clipboard ports.Clipboard) *Writer { | |
| 86 | + | return &Writer{codec: codec, clipboard: clipboard} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (w *Writer) Encode(ctx context.Context, out io.Writer, f ports.Frame) error { | |
| 90 | + | return w.codec.Encode(ctx, out, f) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (w *Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) { | |
| 94 | + | w.Saved = false | |
| 95 | + | if err := ctx.Err(); err != nil { | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | if err := f.Validate(); err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | if path == "" { | |
| 102 | + | r, out := io.Pipe() | |
| 103 | + | encoded := make(chan error, 1) | |
| 104 | + | go func() { | |
| 105 | + | err := w.codec.Encode(ctx, out, f) | |
| 106 | + | _ = out.CloseWithError(err) | |
| 107 | + | encoded <- err | |
| 108 | + | }() | |
| 109 | + | copyErr := w.clipboard.Copy(ctx, pngStream{r}) | |
| 110 | + | _ = r.CloseWithError(copyErr) | |
| 111 | + | return errors.Join(<-encoded, copyErr) | |
| 112 | + | } | |
| 113 | + | file, reservation, err := capturefile.CreateReadable(path) | |
| 114 | + | if err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | defer func() { err = errors.Join(err, file.Close()) }() | |
| 118 | + | if err = w.codec.Encode(ctx, file, f); err != nil { | |
| 119 | + | return errors.Join(err, reservation.Remove()) | |
| 120 | + | } | |
| 121 | + | // Reuse the original descriptor, never reopen a potentially replaced path. | |
| 122 | + | w.Saved = true | |
| 123 | + | if _, err = file.Seek(0, io.SeekStart); err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | return w.clipboard.Copy(ctx, file) | |
| 127 | + | } | |
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | // Package clipboard streams PNG screenshots to the native Wayland clipboard. | |||
| 2 | + | package clipboard | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/nefercap/internal/adapters/capturefile" | |||
| 14 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Native uses wl-copy's default fork: the clipboard owner intentionally survives | |||
| 18 | + | // nefercap. Only the initial handoff is bounded; never use --foreground here. | |||
| 19 | + | type Native struct{} | |||
| 20 | + | ||||
| 21 | + | func New() *Native { return &Native{} } | |||
| 22 | + | ||||
| 23 | + | func (*Native) Copy(ctx context.Context, r io.Reader) error { | |||
| 24 | + | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |||
| 25 | + | defer cancel() | |||
| 26 | + | if err := ctx.Err(); err != nil { | |||
| 27 | + | return err | |||
| 28 | + | } | |||
| 29 | + | cmd := exec.CommandContext(ctx, "wl-copy", "--type", "image/png") | |||
| 30 | + | cmd.Stdin = r | |||
| 31 | + | // Give wl-copy a real descriptor: its forked owner inherits stderr, so | |||
| 32 | + | // exec's automatic stderr copier would wait for that long-lived owner. | |||
| 33 | + | rd, wr, err := os.Pipe() | |||
| 34 | + | if err != nil { | |||
| 35 | + | return fmt.Errorf("wl-copy stderr: %w", err) | |||
| 36 | + | } | |||
| 37 | + | stderr := &limitedError{} | |||
| 38 | + | drained := make(chan struct{}) | |||
| 39 | + | go func() { _, _ = io.Copy(stderr, rd); close(drained) }() | |||
| 40 | + | cmd.Stderr = wr | |||
| 41 | + | cmd.WaitDelay = 200 * time.Millisecond | |||
| 42 | + | err = cmd.Run() | |||
| 43 | + | _ = wr.Close() | |||
| 44 | + | // Drain queued diagnostics, but never wait for the forked owner to close. | |||
| 45 | + | if deadlineErr := rd.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); deadlineErr != nil { | |||
| 46 | + | _ = rd.Close() | |||
| 47 | + | } | |||
| 48 | + | <-drained | |||
| 49 | + | _ = rd.Close() | |||
| 50 | + | if err != nil { | |||
| 51 | + | if ctx.Err() != nil { | |||
| 52 | + | return fmt.Errorf("wl-copy: %w", ctx.Err()) | |||
| 53 | + | } | |||
| 54 | + | if errors.Is(err, exec.ErrNotFound) { | |||
| 55 | + | return fmt.Errorf("wl-copy not found: install wl-clipboard: %w", err) | |||
| 56 | + | } | |||
| 57 | + | return fmt.Errorf("wl-copy: %w: %s", err, stderr.data) | |||
| 58 | + | } | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | type limitedError struct{ data []byte } | |||
| 63 | + | ||||
| 64 | + | func (b *limitedError) Write(p []byte) (int, error) { | |||
| 65 | + | n := len(p) | |||
| 66 | + | remaining := 4096 - len(b.data) | |||
| 67 | + | b.data = append(b.data, p[:min(remaining, len(p))]...) | |||
| 68 | + | return n, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // pngStream keeps diagnostic formatters from inspecting a concurrently active pipe. | |||
| 72 | + | type pngStream struct{ io.Reader } | |||
| 73 | + | ||||
| 74 | + | func (pngStream) String() string { return "PNG stream" } | |||
| 75 | + | ||||
| 76 | + | // Writer composes the existing PNG codec and clipboard port. Saved is set only | |||
| 77 | + | // after encoding a valid file, and survives clipboard failures. | |||
| 78 | + | // Each Writer belongs to one screenshot invocation. | |||
| 79 | + | type Writer struct { | |||
| 80 | + | codec ports.ScreenshotWriter | |||
| 81 | + | clipboard ports.Clipboard | |||
| 82 | + | Saved bool | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func NewWriter(codec ports.ScreenshotWriter, clipboard ports.Clipboard) *Writer { | |||
| 86 | + | return &Writer{codec: codec, clipboard: clipboard} | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (w *Writer) Encode(ctx context.Context, out io.Writer, f ports.Frame) error { | |||
| 90 | + | return w.codec.Encode(ctx, out, f) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (w *Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) { | |||
| 94 | + | w.Saved = false | |||
| 95 | + | if err := ctx.Err(); err != nil { | |||
| 96 | + | return err | |||
| 97 | + | } | |||
| 98 | + | if err := f.Validate(); err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | if path == "" { | |||
| 102 | + | r, out := io.Pipe() | |||
| 103 | + | encoded := make(chan error, 1) | |||
| 104 | + | go func() { | |||
| 105 | + | err := w.codec.Encode(ctx, out, f) | |||
| 106 | + | _ = out.CloseWithError(err) | |||
| 107 | + | encoded <- err | |||
| 108 | + | }() | |||
| 109 | + | copyErr := w.clipboard.Copy(ctx, pngStream{r}) | |||
| 110 | + | _ = r.CloseWithError(copyErr) | |||
| 111 | + | return errors.Join(<-encoded, copyErr) | |||
| 112 | + | } | |||
| 113 | + | file, reservation, err := capturefile.CreateReadable(path) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | defer func() { err = errors.Join(err, file.Close()) }() | |||
| 118 | + | if err = w.codec.Encode(ctx, file, f); err != nil { | |||
| 119 | + | return errors.Join(err, reservation.Remove()) | |||
| 120 | + | } | |||
| 121 | + | // Reuse the original descriptor, never reopen a potentially replaced path. | |||
| 122 | + | w.Saved = true | |||
| 123 | + | if _, err = file.Seek(0, io.SeekStart); err != nil { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | return w.clipboard.Copy(ctx, file) | |||
| 127 | + | } | |||
A internal/adapters/clipboard/clipboard_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | package clipboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/nefercap/internal/adapters/png" | |
| 14 | + | mocks "github.com/bnema/nefercap/internal/mocks/ports" | |
| 15 | + | "github.com/bnema/nefercap/internal/ports" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var frame = ports.Frame{Pixels: []byte{1, 2, 3, 0}, Width: 1, Height: 1, Stride: 4, Format: ports.XRGB8888} | |
| 21 | + | ||
| 22 | + | func TestWriterDestinations(t *testing.T) { | |
| 23 | + | for _, both := range []bool{false, true} { | |
| 24 | + | t.Run(map[bool]string{false: "clipboard only", true: "file and clipboard"}[both], func(t *testing.T) { | |
| 25 | + | dir := t.TempDir() | |
| 26 | + | path := "" | |
| 27 | + | if both { | |
| 28 | + | path = filepath.Join(dir, "shot.png") | |
| 29 | + | } | |
| 30 | + | clip := mocks.NewMockClipboard(t) | |
| 31 | + | var data []byte | |
| 32 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { | |
| 33 | + | var err error | |
| 34 | + | data, err = io.ReadAll(r) | |
| 35 | + | return err | |
| 36 | + | }).Once() | |
| 37 | + | w := NewWriter(png.New(), clip) | |
| 38 | + | require.NoError(t, w.Save(context.Background(), frame, path)) | |
| 39 | + | require.Equal(t, both, w.Saved) | |
| 40 | + | require.True(t, strings.HasPrefix(string(data), "\x89PNG")) | |
| 41 | + | if both { | |
| 42 | + | disk, err := os.ReadFile(path) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, disk, data) | |
| 45 | + | } else { | |
| 46 | + | files, err := os.ReadDir(dir) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Empty(t, files) | |
| 49 | + | } | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestSavedFileSurvivesClipboardFailureAndReplacement(t *testing.T) { | |
| 55 | + | path := filepath.Join(t.TempDir(), "shot.png") | |
| 56 | + | clip := mocks.NewMockClipboard(t) | |
| 57 | + | failure := errors.New("clipboard unavailable") | |
| 58 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { | |
| 59 | + | require.NoError(t, os.Rename(path, path+".saved")) | |
| 60 | + | require.NoError(t, os.Symlink(path+".replacement", path)) | |
| 61 | + | data, err := io.ReadAll(r) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.True(t, strings.HasPrefix(string(data), "\x89PNG")) | |
| 64 | + | return failure | |
| 65 | + | }).Once() | |
| 66 | + | w := NewWriter(png.New(), clip) | |
| 67 | + | require.ErrorIs(t, w.Save(context.Background(), frame, path), failure) | |
| 68 | + | require.True(t, w.Saved) | |
| 69 | + | disk, err := os.ReadFile(path + ".saved") | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | require.True(t, strings.HasPrefix(string(disk), "\x89PNG")) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestWriterRejectsBeforeClipboard(t *testing.T) { | |
| 75 | + | clip := mocks.NewMockClipboard(t) | |
| 76 | + | w := NewWriter(png.New(), clip) | |
| 77 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 78 | + | cancel() | |
| 79 | + | path := filepath.Join(t.TempDir(), "shot.png") | |
| 80 | + | require.ErrorIs(t, w.Save(ctx, frame, path), context.Canceled) | |
| 81 | + | require.Error(t, w.Save(context.Background(), ports.Frame{}, path)) | |
| 82 | + | require.NoFileExists(t, path) | |
| 83 | + | require.NoError(t, os.WriteFile(path, []byte("keep"), 0600)) | |
| 84 | + | require.ErrorIs(t, w.Save(context.Background(), frame, path), ports.ErrPathExists) | |
| 85 | + | disk, err := os.ReadFile(path) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | require.Equal(t, "keep", string(disk)) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestEncodeFailureAndEarlyClipboardExit(t *testing.T) { | |
| 91 | + | for _, path := range []string{"", filepath.Join(t.TempDir(), "failed.png")} { | |
| 92 | + | codec := mocks.NewMockScreenshotWriter(t) | |
| 93 | + | clip := mocks.NewMockClipboard(t) | |
| 94 | + | failure := errors.New("encode failed") | |
| 95 | + | codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).Return(failure).Once() | |
| 96 | + | if path == "" { | |
| 97 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }).Once() | |
| 98 | + | } | |
| 99 | + | w := NewWriter(codec, clip) | |
| 100 | + | require.ErrorIs(t, w.Save(context.Background(), frame, path), failure) | |
| 101 | + | require.False(t, w.Saved) | |
| 102 | + | if path != "" { | |
| 103 | + | require.NoFileExists(t, path) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | clip := mocks.NewMockClipboard(t) | |
| 107 | + | failure := errors.New("early exit") | |
| 108 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).Return(failure).Once() | |
| 109 | + | require.ErrorIs(t, NewWriter(png.New(), clip).Save(context.Background(), frame, ""), failure) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Real executable fixtures exercise os/exec without a handwritten process fake. | |
| 113 | + | func TestNativeProcessFailures(t *testing.T) { | |
| 114 | + | dir := t.TempDir() | |
| 115 | + | t.Setenv("PATH", dir) | |
| 116 | + | require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "install wl-clipboard") | |
| 117 | + | executable := filepath.Join(dir, "wl-copy") | |
| 118 | + | require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nprintf 'no compositor' >&2\nexit 7\n"), 0700)) | |
| 119 | + | require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "no compositor") | |
| 120 | + | require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nexec /bin/sleep 10\n"), 0700)) | |
| 121 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) | |
| 122 | + | defer cancel() | |
| 123 | + | began := time.Now() | |
| 124 | + | require.ErrorIs(t, New().Copy(ctx, strings.NewReader("PNG")), context.DeadlineExceeded) | |
| 125 | + | require.Less(t, time.Since(began), time.Second) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestNativeSuccessfulForkDoesNotWaitForStderrOwner(t *testing.T) { | |
| 129 | + | dir := t.TempDir() | |
| 130 | + | t.Setenv("PATH", dir) | |
| 131 | + | // This is a real child process, retaining only stderr after handoff. | |
| 132 | + | executable := filepath.Join(dir, "wl-copy") | |
| 133 | + | require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\n/bin/cat >/dev/null\n/bin/sleep 1 >/dev/null 2>&2 &\nexit 0\n"), 0o700)) | |
| 134 | + | began := time.Now() | |
| 135 | + | require.NoError(t, New().Copy(context.Background(), strings.NewReader("PNG"))) | |
| 136 | + | require.Less(t, time.Since(began), 500*time.Millisecond) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestCopyCancellationJoinsEncoder(t *testing.T) { | |
| 140 | + | codec := mocks.NewMockScreenshotWriter(t) | |
| 141 | + | clip := mocks.NewMockClipboard(t) | |
| 142 | + | started, encoded := make(chan struct{}), make(chan struct{}) | |
| 143 | + | codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).RunAndReturn(func(_ context.Context, out io.Writer, _ ports.Frame) error { | |
| 144 | + | close(started) | |
| 145 | + | _, err := out.Write([]byte("PNG")) // backpressure until the reader is closed | |
| 146 | + | close(encoded) | |
| 147 | + | return err | |
| 148 | + | }).Once() | |
| 149 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ io.Reader) error { | |
| 150 | + | <-ctx.Done() | |
| 151 | + | return ctx.Err() | |
| 152 | + | }).Once() | |
| 153 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 154 | + | defer cancel() | |
| 155 | + | done := make(chan error, 1) | |
| 156 | + | go func() { done <- NewWriter(codec, clip).Save(ctx, frame, "") }() | |
| 157 | + | select { | |
| 158 | + | case <-started: | |
| 159 | + | case <-time.After(time.Second): | |
| 160 | + | t.Fatal("encoder did not start") | |
| 161 | + | } | |
| 162 | + | cancel() | |
| 163 | + | select { | |
| 164 | + | case err := <-done: | |
| 165 | + | require.ErrorIs(t, err, context.Canceled) | |
| 166 | + | case <-time.After(time.Second): | |
| 167 | + | t.Fatal("clipboard cancellation did not terminate encoding") | |
| 168 | + | } | |
| 169 | + | select { | |
| 170 | + | case <-encoded: | |
| 171 | + | default: | |
| 172 | + | t.Fatal("Save returned before the encoder released its borrowed frame") | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestStderrBound(t *testing.T) { | |
| 177 | + | b := &limitedError{} | |
| 178 | + | n, err := b.Write(make([]byte, 1<<20)) | |
| 179 | + | require.NoError(t, err) | |
| 180 | + | require.Equal(t, 1<<20, n) | |
| 181 | + | require.Len(t, b.data, 4096) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func BenchmarkClipboardEncode(b *testing.B) { | |
| 185 | + | f := ports.Frame{Pixels: make([]byte, 1920*1080*4), Width: 1920, Height: 1080, Stride: 1920 * 4, Format: ports.XRGB8888} | |
| 186 | + | b.ReportAllocs() | |
| 187 | + | for b.Loop() { | |
| 188 | + | if err := png.New().Encode(context.Background(), io.Discard, f); err != nil { | |
| 189 | + | b.Fatal(err) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | package clipboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/nefercap/internal/adapters/png" | |||
| 14 | + | mocks "github.com/bnema/nefercap/internal/mocks/ports" | |||
| 15 | + | "github.com/bnema/nefercap/internal/ports" | |||
| 16 | + | "github.com/stretchr/testify/mock" | |||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var frame = ports.Frame{Pixels: []byte{1, 2, 3, 0}, Width: 1, Height: 1, Stride: 4, Format: ports.XRGB8888} | |||
| 21 | + | ||||
| 22 | + | func TestWriterDestinations(t *testing.T) { | |||
| 23 | + | for _, both := range []bool{false, true} { | |||
| 24 | + | t.Run(map[bool]string{false: "clipboard only", true: "file and clipboard"}[both], func(t *testing.T) { | |||
| 25 | + | dir := t.TempDir() | |||
| 26 | + | path := "" | |||
| 27 | + | if both { | |||
| 28 | + | path = filepath.Join(dir, "shot.png") | |||
| 29 | + | } | |||
| 30 | + | clip := mocks.NewMockClipboard(t) | |||
| 31 | + | var data []byte | |||
| 32 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { | |||
| 33 | + | var err error | |||
| 34 | + | data, err = io.ReadAll(r) | |||
| 35 | + | return err | |||
| 36 | + | }).Once() | |||
| 37 | + | w := NewWriter(png.New(), clip) | |||
| 38 | + | require.NoError(t, w.Save(context.Background(), frame, path)) | |||
| 39 | + | require.Equal(t, both, w.Saved) | |||
| 40 | + | require.True(t, strings.HasPrefix(string(data), "\x89PNG")) | |||
| 41 | + | if both { | |||
| 42 | + | disk, err := os.ReadFile(path) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.Equal(t, disk, data) | |||
| 45 | + | } else { | |||
| 46 | + | files, err := os.ReadDir(dir) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Empty(t, files) | |||
| 49 | + | } | |||
| 50 | + | }) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestSavedFileSurvivesClipboardFailureAndReplacement(t *testing.T) { | |||
| 55 | + | path := filepath.Join(t.TempDir(), "shot.png") | |||
| 56 | + | clip := mocks.NewMockClipboard(t) | |||
| 57 | + | failure := errors.New("clipboard unavailable") | |||
| 58 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { | |||
| 59 | + | require.NoError(t, os.Rename(path, path+".saved")) | |||
| 60 | + | require.NoError(t, os.Symlink(path+".replacement", path)) | |||
| 61 | + | data, err := io.ReadAll(r) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | require.True(t, strings.HasPrefix(string(data), "\x89PNG")) | |||
| 64 | + | return failure | |||
| 65 | + | }).Once() | |||
| 66 | + | w := NewWriter(png.New(), clip) | |||
| 67 | + | require.ErrorIs(t, w.Save(context.Background(), frame, path), failure) | |||
| 68 | + | require.True(t, w.Saved) | |||
| 69 | + | disk, err := os.ReadFile(path + ".saved") | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | require.True(t, strings.HasPrefix(string(disk), "\x89PNG")) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestWriterRejectsBeforeClipboard(t *testing.T) { | |||
| 75 | + | clip := mocks.NewMockClipboard(t) | |||
| 76 | + | w := NewWriter(png.New(), clip) | |||
| 77 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 78 | + | cancel() | |||
| 79 | + | path := filepath.Join(t.TempDir(), "shot.png") | |||
| 80 | + | require.ErrorIs(t, w.Save(ctx, frame, path), context.Canceled) | |||
| 81 | + | require.Error(t, w.Save(context.Background(), ports.Frame{}, path)) | |||
| 82 | + | require.NoFileExists(t, path) | |||
| 83 | + | require.NoError(t, os.WriteFile(path, []byte("keep"), 0600)) | |||
| 84 | + | require.ErrorIs(t, w.Save(context.Background(), frame, path), ports.ErrPathExists) | |||
| 85 | + | disk, err := os.ReadFile(path) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | require.Equal(t, "keep", string(disk)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestEncodeFailureAndEarlyClipboardExit(t *testing.T) { | |||
| 91 | + | for _, path := range []string{"", filepath.Join(t.TempDir(), "failed.png")} { | |||
| 92 | + | codec := mocks.NewMockScreenshotWriter(t) | |||
| 93 | + | clip := mocks.NewMockClipboard(t) | |||
| 94 | + | failure := errors.New("encode failed") | |||
| 95 | + | codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).Return(failure).Once() | |||
| 96 | + | if path == "" { | |||
| 97 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }).Once() | |||
| 98 | + | } | |||
| 99 | + | w := NewWriter(codec, clip) | |||
| 100 | + | require.ErrorIs(t, w.Save(context.Background(), frame, path), failure) | |||
| 101 | + | require.False(t, w.Saved) | |||
| 102 | + | if path != "" { | |||
| 103 | + | require.NoFileExists(t, path) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | clip := mocks.NewMockClipboard(t) | |||
| 107 | + | failure := errors.New("early exit") | |||
| 108 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).Return(failure).Once() | |||
| 109 | + | require.ErrorIs(t, NewWriter(png.New(), clip).Save(context.Background(), frame, ""), failure) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Real executable fixtures exercise os/exec without a handwritten process fake. | |||
| 113 | + | func TestNativeProcessFailures(t *testing.T) { | |||
| 114 | + | dir := t.TempDir() | |||
| 115 | + | t.Setenv("PATH", dir) | |||
| 116 | + | require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "install wl-clipboard") | |||
| 117 | + | executable := filepath.Join(dir, "wl-copy") | |||
| 118 | + | require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nprintf 'no compositor' >&2\nexit 7\n"), 0700)) | |||
| 119 | + | require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "no compositor") | |||
| 120 | + | require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nexec /bin/sleep 10\n"), 0700)) | |||
| 121 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) | |||
| 122 | + | defer cancel() | |||
| 123 | + | began := time.Now() | |||
| 124 | + | require.ErrorIs(t, New().Copy(ctx, strings.NewReader("PNG")), context.DeadlineExceeded) | |||
| 125 | + | require.Less(t, time.Since(began), time.Second) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestNativeSuccessfulForkDoesNotWaitForStderrOwner(t *testing.T) { | |||
| 129 | + | dir := t.TempDir() | |||
| 130 | + | t.Setenv("PATH", dir) | |||
| 131 | + | // This is a real child process, retaining only stderr after handoff. | |||
| 132 | + | executable := filepath.Join(dir, "wl-copy") | |||
| 133 | + | require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\n/bin/cat >/dev/null\n/bin/sleep 1 >/dev/null 2>&2 &\nexit 0\n"), 0o700)) | |||
| 134 | + | began := time.Now() | |||
| 135 | + | require.NoError(t, New().Copy(context.Background(), strings.NewReader("PNG"))) | |||
| 136 | + | require.Less(t, time.Since(began), 500*time.Millisecond) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestCopyCancellationJoinsEncoder(t *testing.T) { | |||
| 140 | + | codec := mocks.NewMockScreenshotWriter(t) | |||
| 141 | + | clip := mocks.NewMockClipboard(t) | |||
| 142 | + | started, encoded := make(chan struct{}), make(chan struct{}) | |||
| 143 | + | codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).RunAndReturn(func(_ context.Context, out io.Writer, _ ports.Frame) error { | |||
| 144 | + | close(started) | |||
| 145 | + | _, err := out.Write([]byte("PNG")) // backpressure until the reader is closed | |||
| 146 | + | close(encoded) | |||
| 147 | + | return err | |||
| 148 | + | }).Once() | |||
| 149 | + | clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ io.Reader) error { | |||
| 150 | + | <-ctx.Done() | |||
| 151 | + | return ctx.Err() | |||
| 152 | + | }).Once() | |||
| 153 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 154 | + | defer cancel() | |||
| 155 | + | done := make(chan error, 1) | |||
| 156 | + | go func() { done <- NewWriter(codec, clip).Save(ctx, frame, "") }() | |||
| 157 | + | select { | |||
| 158 | + | case <-started: | |||
| 159 | + | case <-time.After(time.Second): | |||
| 160 | + | t.Fatal("encoder did not start") | |||
| 161 | + | } | |||
| 162 | + | cancel() | |||
| 163 | + | select { | |||
| 164 | + | case err := <-done: | |||
| 165 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 166 | + | case <-time.After(time.Second): | |||
| 167 | + | t.Fatal("clipboard cancellation did not terminate encoding") | |||
| 168 | + | } | |||
| 169 | + | select { | |||
| 170 | + | case <-encoded: | |||
| 171 | + | default: | |||
| 172 | + | t.Fatal("Save returned before the encoder released its borrowed frame") | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestStderrBound(t *testing.T) { | |||
| 177 | + | b := &limitedError{} | |||
| 178 | + | n, err := b.Write(make([]byte, 1<<20)) | |||
| 179 | + | require.NoError(t, err) | |||
| 180 | + | require.Equal(t, 1<<20, n) | |||
| 181 | + | require.Len(t, b.data, 4096) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func BenchmarkClipboardEncode(b *testing.B) { | |||
| 185 | + | f := ports.Frame{Pixels: make([]byte, 1920*1080*4), Width: 1920, Height: 1080, Stride: 1920 * 4, Format: ports.XRGB8888} | |||
| 186 | + | b.ReportAllocs() | |||
| 187 | + | for b.Loop() { | |||
| 188 | + | if err := png.New().Encode(context.Background(), io.Discard, f); err != nil { | |||
| 189 | + | b.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | } | |||
M internal/adapters/png/png.go +8 -2
| @@ -59,13 +59,19 @@ func (*Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) | |||
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | }() | |
| 62 | - | return encode(ctx, file, f) | |
| 62 | + | return New().Encode(ctx, file, f) | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | 65 | // encode writes f to w. The frame is converted once to opaque RGBA because the | |
| 66 | 66 | // encoder only has fast paths for the standard image types and stored pixels | |
| 67 | 67 | // are BGRA; the copy is exactly one frame and is released on return. | |
| 68 | - | func encode(ctx context.Context, w io.Writer, f ports.Frame) error { | |
| 68 | + | func (*Writer) Encode(ctx context.Context, w io.Writer, f ports.Frame) error { | |
| 69 | + | if err := ctx.Err(); err != nil { | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | if err := f.Validate(); err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 69 | 75 | img, err := toRGBA(ctx, f) | |
| 70 | 76 | if err != nil { | |
| 71 | 77 | return err | |
| @@ -59,13 +59,19 @@ func (*Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) | |||||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
| 61 | }() | 61 | }() | ||
| 62 | - | return encode(ctx, file, f) | 62 | + | return New().Encode(ctx, file, f) |
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | // encode writes f to w. The frame is converted once to opaque RGBA because the | 65 | // encode writes f to w. The frame is converted once to opaque RGBA because the | ||
| 66 | // encoder only has fast paths for the standard image types and stored pixels | 66 | // encoder only has fast paths for the standard image types and stored pixels | ||
| 67 | // are BGRA; the copy is exactly one frame and is released on return. | 67 | // are BGRA; the copy is exactly one frame and is released on return. | ||
| 68 | - | func encode(ctx context.Context, w io.Writer, f ports.Frame) error { | 68 | + | func (*Writer) Encode(ctx context.Context, w io.Writer, f ports.Frame) error { |
| 69 | + | if err := ctx.Err(); err != nil { | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | if err := f.Validate(); err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 69 | img, err := toRGBA(ctx, f) | 75 | img, err := toRGBA(ctx, f) | ||
| 70 | if err != nil { | 76 | if err != nil { | ||
| 71 | return err | 77 | return err | ||
M internal/app/app.go +13 -3
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | |
| 12 | + | "github.com/bnema/nefercap/internal/adapters/clipboard" | |
| 12 | 13 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | |
| 13 | 14 | "github.com/bnema/nefercap/internal/adapters/png" | |
| 14 | 15 | "github.com/bnema/nefercap/internal/adapters/wayland" | |
| @@ -63,7 +64,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 63 | 64 | if err != nil { | |
| 64 | 65 | return err | |
| 65 | 66 | } | |
| 66 | - | selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Video: options.Video, Duration: options.Duration} | |
| 67 | + | selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration} | |
| 67 | 68 | switch options.Command { | |
| 68 | 69 | case cli.Screenshot: | |
| 69 | 70 | selection.Mode = ports.Screenshot | |
| @@ -88,8 +89,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||
| 88 | 89 | } | |
| 89 | 90 | selection.Target = state.Target | |
| 90 | 91 | } | |
| 91 | - | err = core.New(source, png.New(), ffmpeg.New()).Run(ctx, selection) | |
| 92 | - | saved = err == nil | |
| 92 | + | var writer ports.ScreenshotWriter = png.New() | |
| 93 | + | var copied *clipboard.Writer | |
| 94 | + | if selection.Clipboard { | |
| 95 | + | copied = clipboard.NewWriter(writer, clipboard.New()) | |
| 96 | + | writer = copied | |
| 97 | + | } | |
| 98 | + | err = core.New(source, writer, ffmpeg.New()).Run(ctx, selection) | |
| 99 | + | saved = err == nil && selection.Path != "" | |
| 100 | + | if copied != nil { | |
| 101 | + | saved = copied.Saved | |
| 102 | + | } | |
| 93 | 103 | } | |
| 94 | 104 | if saved && err == nil { | |
| 95 | 105 | log.Info().Msg("capture complete") | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/nefercap/internal/adapters/cli" | 11 | "github.com/bnema/nefercap/internal/adapters/cli" | ||
| 12 | + | "github.com/bnema/nefercap/internal/adapters/clipboard" | |||
| 12 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | 13 | "github.com/bnema/nefercap/internal/adapters/ffmpeg" | ||
| 13 | "github.com/bnema/nefercap/internal/adapters/png" | 14 | "github.com/bnema/nefercap/internal/adapters/png" | ||
| 14 | "github.com/bnema/nefercap/internal/adapters/wayland" | 15 | "github.com/bnema/nefercap/internal/adapters/wayland" | ||
| @@ -63,7 +64,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||||
| 63 | if err != nil { | 64 | if err != nil { | ||
| 64 | return err | 65 | return err | ||
| 65 | } | 66 | } | ||
| 66 | - | selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Video: options.Video, Duration: options.Duration} | 67 | + | selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration} |
| 67 | switch options.Command { | 68 | switch options.Command { | ||
| 68 | case cli.Screenshot: | 69 | case cli.Screenshot: | ||
| 69 | selection.Mode = ports.Screenshot | 70 | selection.Mode = ports.Screenshot | ||
| @@ -88,8 +89,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) | |||||
| 88 | } | 89 | } | ||
| 89 | selection.Target = state.Target | 90 | selection.Target = state.Target | ||
| 90 | } | 91 | } | ||
| 91 | - | err = core.New(source, png.New(), ffmpeg.New()).Run(ctx, selection) | 92 | + | var writer ports.ScreenshotWriter = png.New() |
| 92 | - | saved = err == nil | 93 | + | var copied *clipboard.Writer |
| 94 | + | if selection.Clipboard { | |||
| 95 | + | copied = clipboard.NewWriter(writer, clipboard.New()) | |||
| 96 | + | writer = copied | |||
| 97 | + | } | |||
| 98 | + | err = core.New(source, writer, ffmpeg.New()).Run(ctx, selection) | |||
| 99 | + | saved = err == nil && selection.Path != "" | |||
| 100 | + | if copied != nil { | |||
| 101 | + | saved = copied.Saved | |||
| 102 | + | } | |||
| 93 | } | 103 | } | ||
| 94 | if saved && err == nil { | 104 | if saved && err == nil { | ||
| 95 | log.Info().Msg("capture complete") | 105 | log.Info().Msg("capture complete") | ||
A internal/app/clipboard_test.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestClipboardSavedPathOnFailure(t *testing.T) { | |
| 11 | + | failure := errors.New("wl-copy failed") | |
| 12 | + | var out bytes.Buffer | |
| 13 | + | require.ErrorIs(t, finishCapture(&out, "shot.png", true, failure), failure) | |
| 14 | + | require.Equal(t, "shot.png\n", out.String()) | |
| 15 | + | out.Reset() | |
| 16 | + | require.ErrorIs(t, finishCapture(&out, "", false, failure), failure) | |
| 17 | + | require.Empty(t, out.String()) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestClipboardSavedPathOnFailure(t *testing.T) { | |||
| 11 | + | failure := errors.New("wl-copy failed") | |||
| 12 | + | var out bytes.Buffer | |||
| 13 | + | require.ErrorIs(t, finishCapture(&out, "shot.png", true, failure), failure) | |||
| 14 | + | require.Equal(t, "shot.png\n", out.String()) | |||
| 15 | + | out.Reset() | |||
| 16 | + | require.ErrorIs(t, finishCapture(&out, "", false, failure), failure) | |||
| 17 | + | require.Empty(t, out.String()) | |||
| 18 | + | } | |||
M internal/app/interactive.go +2 -1
| @@ -46,7 +46,8 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||
| 46 | 46 | } | |
| 47 | 47 | sel.Duration = options.Duration | |
| 48 | 48 | sel.Path = options.Path | |
| 49 | - | if sel.Path == "" { | |
| 49 | + | sel.Clipboard = options.Clipboard | |
| 50 | + | if sel.Path == "" && !sel.Clipboard { | |
| 50 | 51 | if mode == ports.Record { | |
| 51 | 52 | sel.Path, err = destination.Video() | |
| 52 | 53 | } else { | |
| @@ -46,7 +46,8 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po | |||||
| 46 | } | 46 | } | ||
| 47 | sel.Duration = options.Duration | 47 | sel.Duration = options.Duration | ||
| 48 | sel.Path = options.Path | 48 | sel.Path = options.Path | ||
| 49 | - | if sel.Path == "" { | 49 | + | sel.Clipboard = options.Clipboard |
| 50 | + | if sel.Path == "" && !sel.Clipboard { | |||
| 50 | if mode == ports.Record { | 51 | if mode == ports.Record { | ||
| 51 | sel.Path, err = destination.Video() | 52 | sel.Path, err = destination.Video() | ||
| 52 | } else { | 53 | } else { | ||
A internal/core/clipboard_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "github.com/bnema/nefercap/internal/core" | |
| 6 | + | "github.com/stretchr/testify/mock" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestClipboardSelectionValidation(t *testing.T) { | |
| 12 | + | h := newHarness(t) | |
| 13 | + | sel := shot() | |
| 14 | + | sel.Clipboard = true | |
| 15 | + | sel.Path = "" | |
| 16 | + | h.src.EXPECT().Capture(mock.Anything, sel.Target).Return(frame, nil).Once() | |
| 17 | + | h.png.EXPECT().Save(mock.Anything, frame, "").Return(nil).Once() | |
| 18 | + | require.NoError(t, h.svc.Run(context.Background(), sel)) | |
| 19 | + | h = newHarness(t) | |
| 20 | + | sel = rec(0) | |
| 21 | + | sel.Clipboard = true | |
| 22 | + | require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection) | |
| 23 | + | h = newHarness(t) | |
| 24 | + | sel = shot() | |
| 25 | + | sel.Clipboard = true | |
| 26 | + | sel.Path = "" | |
| 27 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 28 | + | cancel() | |
| 29 | + | require.ErrorIs(t, h.svc.Run(ctx, sel), context.Canceled) | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "github.com/bnema/nefercap/internal/core" | |||
| 6 | + | "github.com/stretchr/testify/mock" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestClipboardSelectionValidation(t *testing.T) { | |||
| 12 | + | h := newHarness(t) | |||
| 13 | + | sel := shot() | |||
| 14 | + | sel.Clipboard = true | |||
| 15 | + | sel.Path = "" | |||
| 16 | + | h.src.EXPECT().Capture(mock.Anything, sel.Target).Return(frame, nil).Once() | |||
| 17 | + | h.png.EXPECT().Save(mock.Anything, frame, "").Return(nil).Once() | |||
| 18 | + | require.NoError(t, h.svc.Run(context.Background(), sel)) | |||
| 19 | + | h = newHarness(t) | |||
| 20 | + | sel = rec(0) | |||
| 21 | + | sel.Clipboard = true | |||
| 22 | + | require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection) | |||
| 23 | + | h = newHarness(t) | |||
| 24 | + | sel = shot() | |||
| 25 | + | sel.Clipboard = true | |||
| 26 | + | sel.Path = "" | |||
| 27 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 28 | + | cancel() | |||
| 29 | + | require.ErrorIs(t, h.svc.Run(ctx, sel), context.Canceled) | |||
| 30 | + | } | |||
M internal/core/service.go +4 -1
| @@ -286,7 +286,10 @@ func validateSelection(sel ports.Selection) error { | |||
| 286 | 286 | if err := validateRegion(sel.Target.Region); err != nil { | |
| 287 | 287 | return err | |
| 288 | 288 | } | |
| 289 | - | if sel.Path == "" { | |
| 289 | + | if sel.Clipboard && sel.Mode != ports.Screenshot { | |
| 290 | + | return fmt.Errorf("%w: clipboard requires screenshot", ErrInvalidSelection) | |
| 291 | + | } | |
| 292 | + | if sel.Path == "" && !sel.Clipboard { | |
| 290 | 293 | return fmt.Errorf("%w: empty path", ErrInvalidSelection) | |
| 291 | 294 | } | |
| 292 | 295 | if sel.Duration < 0 { | |
| @@ -286,7 +286,10 @@ func validateSelection(sel ports.Selection) error { | |||||
| 286 | if err := validateRegion(sel.Target.Region); err != nil { | 286 | if err := validateRegion(sel.Target.Region); err != nil { | ||
| 287 | return err | 287 | return err | ||
| 288 | } | 288 | } | ||
| 289 | - | if sel.Path == "" { | 289 | + | if sel.Clipboard && sel.Mode != ports.Screenshot { |
| 290 | + | return fmt.Errorf("%w: clipboard requires screenshot", ErrInvalidSelection) | |||
| 291 | + | } | |||
| 292 | + | if sel.Path == "" && !sel.Clipboard { | |||
| 290 | return fmt.Errorf("%w: empty path", ErrInvalidSelection) | 293 | return fmt.Errorf("%w: empty path", ErrInvalidSelection) | ||
| 291 | } | 294 | } | ||
| 292 | if sel.Duration < 0 { | 295 | if sel.Duration < 0 { | ||
M internal/mocks/ports/capture_mock.go +157 -0
| @@ -6,6 +6,7 @@ package portsmocks | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "io" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/nefercap/internal/ports" | |
| 11 | 12 | mock "github.com/stretchr/testify/mock" | |
| @@ -255,6 +256,69 @@ func (_m *MockScreenshotWriter) EXPECT() *MockScreenshotWriter_Expecter { | |||
| 255 | 256 | return &MockScreenshotWriter_Expecter{mock: &_m.Mock} | |
| 256 | 257 | } | |
| 257 | 258 | ||
| 259 | + | // Encode provides a mock function for the type MockScreenshotWriter | |
| 260 | + | func (_mock *MockScreenshotWriter) Encode(context1 context.Context, writer io.Writer, frame ports.Frame) error { | |
| 261 | + | ret := _mock.Called(context1, writer, frame) | |
| 262 | + | ||
| 263 | + | if len(ret) == 0 { | |
| 264 | + | panic("no return value specified for Encode") | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | var r0 error | |
| 268 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, io.Writer, ports.Frame) error); ok { | |
| 269 | + | r0 = returnFunc(context1, writer, frame) | |
| 270 | + | } else { | |
| 271 | + | r0 = ret.Error(0) | |
| 272 | + | } | |
| 273 | + | return r0 | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // MockScreenshotWriter_Encode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encode' | |
| 277 | + | type MockScreenshotWriter_Encode_Call struct { | |
| 278 | + | *mock.Call | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // Encode is a helper method to define mock.On call | |
| 282 | + | // - context1 context.Context | |
| 283 | + | // - writer io.Writer | |
| 284 | + | // - frame ports.Frame | |
| 285 | + | func (_e *MockScreenshotWriter_Expecter) Encode(context1 any, writer any, frame any) *MockScreenshotWriter_Encode_Call { | |
| 286 | + | return &MockScreenshotWriter_Encode_Call{Call: _e.mock.On("Encode", context1, writer, frame)} | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (_c *MockScreenshotWriter_Encode_Call) Run(run func(context1 context.Context, writer io.Writer, frame ports.Frame)) *MockScreenshotWriter_Encode_Call { | |
| 290 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 291 | + | var arg0 context.Context | |
| 292 | + | if args[0] != nil { | |
| 293 | + | arg0 = args[0].(context.Context) | |
| 294 | + | } | |
| 295 | + | var arg1 io.Writer | |
| 296 | + | if args[1] != nil { | |
| 297 | + | arg1 = args[1].(io.Writer) | |
| 298 | + | } | |
| 299 | + | var arg2 ports.Frame | |
| 300 | + | if args[2] != nil { | |
| 301 | + | arg2 = args[2].(ports.Frame) | |
| 302 | + | } | |
| 303 | + | run( | |
| 304 | + | arg0, | |
| 305 | + | arg1, | |
| 306 | + | arg2, | |
| 307 | + | ) | |
| 308 | + | }) | |
| 309 | + | return _c | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (_c *MockScreenshotWriter_Encode_Call) Return(err error) *MockScreenshotWriter_Encode_Call { | |
| 313 | + | _c.Call.Return(err) | |
| 314 | + | return _c | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (_c *MockScreenshotWriter_Encode_Call) RunAndReturn(run func(context1 context.Context, writer io.Writer, frame ports.Frame) error) *MockScreenshotWriter_Encode_Call { | |
| 318 | + | _c.Call.Return(run) | |
| 319 | + | return _c | |
| 320 | + | } | |
| 321 | + | ||
| 258 | 322 | // Save provides a mock function for the type MockScreenshotWriter | |
| 259 | 323 | func (_mock *MockScreenshotWriter) Save(context1 context.Context, frame ports.Frame, s string) error { | |
| 260 | 324 | ret := _mock.Called(context1, frame, s) | |
| @@ -318,6 +382,99 @@ func (_c *MockScreenshotWriter_Save_Call) RunAndReturn(run func(context1 context | |||
| 318 | 382 | return _c | |
| 319 | 383 | } | |
| 320 | 384 | ||
| 385 | + | // NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 386 | + | // The first argument is typically a *testing.T value. | |
| 387 | + | func NewMockClipboard(t interface { | |
| 388 | + | mock.TestingT | |
| 389 | + | Cleanup(func()) | |
| 390 | + | }) *MockClipboard { | |
| 391 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 392 | + | helper.Helper() | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | mock := &MockClipboard{} | |
| 396 | + | mock.Mock.Test(t) | |
| 397 | + | ||
| 398 | + | t.Cleanup(func() { | |
| 399 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 400 | + | helper.Helper() | |
| 401 | + | } | |
| 402 | + | mock.AssertExpectations(t) | |
| 403 | + | }) | |
| 404 | + | ||
| 405 | + | return mock | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // MockClipboard is an autogenerated mock type for the Clipboard type | |
| 409 | + | type MockClipboard struct { | |
| 410 | + | mock.Mock | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | type MockClipboard_Expecter struct { | |
| 414 | + | mock *mock.Mock | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (_m *MockClipboard) EXPECT() *MockClipboard_Expecter { | |
| 418 | + | return &MockClipboard_Expecter{mock: &_m.Mock} | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // Copy provides a mock function for the type MockClipboard | |
| 422 | + | func (_mock *MockClipboard) Copy(context1 context.Context, reader io.Reader) error { | |
| 423 | + | ret := _mock.Called(context1, reader) | |
| 424 | + | ||
| 425 | + | if len(ret) == 0 { | |
| 426 | + | panic("no return value specified for Copy") | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | var r0 error | |
| 430 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, io.Reader) error); ok { | |
| 431 | + | r0 = returnFunc(context1, reader) | |
| 432 | + | } else { | |
| 433 | + | r0 = ret.Error(0) | |
| 434 | + | } | |
| 435 | + | return r0 | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // MockClipboard_Copy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Copy' | |
| 439 | + | type MockClipboard_Copy_Call struct { | |
| 440 | + | *mock.Call | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // Copy is a helper method to define mock.On call | |
| 444 | + | // - context1 context.Context | |
| 445 | + | // - reader io.Reader | |
| 446 | + | func (_e *MockClipboard_Expecter) Copy(context1 any, reader any) *MockClipboard_Copy_Call { | |
| 447 | + | return &MockClipboard_Copy_Call{Call: _e.mock.On("Copy", context1, reader)} | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func (_c *MockClipboard_Copy_Call) Run(run func(context1 context.Context, reader io.Reader)) *MockClipboard_Copy_Call { | |
| 451 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 452 | + | var arg0 context.Context | |
| 453 | + | if args[0] != nil { | |
| 454 | + | arg0 = args[0].(context.Context) | |
| 455 | + | } | |
| 456 | + | var arg1 io.Reader | |
| 457 | + | if args[1] != nil { | |
| 458 | + | arg1 = args[1].(io.Reader) | |
| 459 | + | } | |
| 460 | + | run( | |
| 461 | + | arg0, | |
| 462 | + | arg1, | |
| 463 | + | ) | |
| 464 | + | }) | |
| 465 | + | return _c | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | func (_c *MockClipboard_Copy_Call) Return(err error) *MockClipboard_Copy_Call { | |
| 469 | + | _c.Call.Return(err) | |
| 470 | + | return _c | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func (_c *MockClipboard_Copy_Call) RunAndReturn(run func(context1 context.Context, reader io.Reader) error) *MockClipboard_Copy_Call { | |
| 474 | + | _c.Call.Return(run) | |
| 475 | + | return _c | |
| 476 | + | } | |
| 477 | + | ||
| 321 | 478 | // NewMockVideoWriter creates a new instance of MockVideoWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 322 | 479 | // The first argument is typically a *testing.T value. | |
| 323 | 480 | func NewMockVideoWriter(t interface { | |
| @@ -6,6 +6,7 @@ package portsmocks | |||||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | + | "io" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/nefercap/internal/ports" | 11 | "github.com/bnema/nefercap/internal/ports" | ||
| 11 | mock "github.com/stretchr/testify/mock" | 12 | mock "github.com/stretchr/testify/mock" | ||
| @@ -255,6 +256,69 @@ func (_m *MockScreenshotWriter) EXPECT() *MockScreenshotWriter_Expecter { | |||||
| 255 | return &MockScreenshotWriter_Expecter{mock: &_m.Mock} | 256 | return &MockScreenshotWriter_Expecter{mock: &_m.Mock} | ||
| 256 | } | 257 | } | ||
| 257 | 258 | ||||
| 259 | + | // Encode provides a mock function for the type MockScreenshotWriter | |||
| 260 | + | func (_mock *MockScreenshotWriter) Encode(context1 context.Context, writer io.Writer, frame ports.Frame) error { | |||
| 261 | + | ret := _mock.Called(context1, writer, frame) | |||
| 262 | + | ||||
| 263 | + | if len(ret) == 0 { | |||
| 264 | + | panic("no return value specified for Encode") | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | var r0 error | |||
| 268 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, io.Writer, ports.Frame) error); ok { | |||
| 269 | + | r0 = returnFunc(context1, writer, frame) | |||
| 270 | + | } else { | |||
| 271 | + | r0 = ret.Error(0) | |||
| 272 | + | } | |||
| 273 | + | return r0 | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // MockScreenshotWriter_Encode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encode' | |||
| 277 | + | type MockScreenshotWriter_Encode_Call struct { | |||
| 278 | + | *mock.Call | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // Encode is a helper method to define mock.On call | |||
| 282 | + | // - context1 context.Context | |||
| 283 | + | // - writer io.Writer | |||
| 284 | + | // - frame ports.Frame | |||
| 285 | + | func (_e *MockScreenshotWriter_Expecter) Encode(context1 any, writer any, frame any) *MockScreenshotWriter_Encode_Call { | |||
| 286 | + | return &MockScreenshotWriter_Encode_Call{Call: _e.mock.On("Encode", context1, writer, frame)} | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func (_c *MockScreenshotWriter_Encode_Call) Run(run func(context1 context.Context, writer io.Writer, frame ports.Frame)) *MockScreenshotWriter_Encode_Call { | |||
| 290 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 291 | + | var arg0 context.Context | |||
| 292 | + | if args[0] != nil { | |||
| 293 | + | arg0 = args[0].(context.Context) | |||
| 294 | + | } | |||
| 295 | + | var arg1 io.Writer | |||
| 296 | + | if args[1] != nil { | |||
| 297 | + | arg1 = args[1].(io.Writer) | |||
| 298 | + | } | |||
| 299 | + | var arg2 ports.Frame | |||
| 300 | + | if args[2] != nil { | |||
| 301 | + | arg2 = args[2].(ports.Frame) | |||
| 302 | + | } | |||
| 303 | + | run( | |||
| 304 | + | arg0, | |||
| 305 | + | arg1, | |||
| 306 | + | arg2, | |||
| 307 | + | ) | |||
| 308 | + | }) | |||
| 309 | + | return _c | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func (_c *MockScreenshotWriter_Encode_Call) Return(err error) *MockScreenshotWriter_Encode_Call { | |||
| 313 | + | _c.Call.Return(err) | |||
| 314 | + | return _c | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func (_c *MockScreenshotWriter_Encode_Call) RunAndReturn(run func(context1 context.Context, writer io.Writer, frame ports.Frame) error) *MockScreenshotWriter_Encode_Call { | |||
| 318 | + | _c.Call.Return(run) | |||
| 319 | + | return _c | |||
| 320 | + | } | |||
| 321 | + | ||||
| 258 | // Save provides a mock function for the type MockScreenshotWriter | 322 | // Save provides a mock function for the type MockScreenshotWriter | ||
| 259 | func (_mock *MockScreenshotWriter) Save(context1 context.Context, frame ports.Frame, s string) error { | 323 | func (_mock *MockScreenshotWriter) Save(context1 context.Context, frame ports.Frame, s string) error { | ||
| 260 | ret := _mock.Called(context1, frame, s) | 324 | ret := _mock.Called(context1, frame, s) | ||
| @@ -318,6 +382,99 @@ func (_c *MockScreenshotWriter_Save_Call) RunAndReturn(run func(context1 context | |||||
| 318 | return _c | 382 | return _c | ||
| 319 | } | 383 | } | ||
| 320 | 384 | ||||
| 385 | + | // NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 386 | + | // The first argument is typically a *testing.T value. | |||
| 387 | + | func NewMockClipboard(t interface { | |||
| 388 | + | mock.TestingT | |||
| 389 | + | Cleanup(func()) | |||
| 390 | + | }) *MockClipboard { | |||
| 391 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 392 | + | helper.Helper() | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | mock := &MockClipboard{} | |||
| 396 | + | mock.Mock.Test(t) | |||
| 397 | + | ||||
| 398 | + | t.Cleanup(func() { | |||
| 399 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 400 | + | helper.Helper() | |||
| 401 | + | } | |||
| 402 | + | mock.AssertExpectations(t) | |||
| 403 | + | }) | |||
| 404 | + | ||||
| 405 | + | return mock | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // MockClipboard is an autogenerated mock type for the Clipboard type | |||
| 409 | + | type MockClipboard struct { | |||
| 410 | + | mock.Mock | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | type MockClipboard_Expecter struct { | |||
| 414 | + | mock *mock.Mock | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func (_m *MockClipboard) EXPECT() *MockClipboard_Expecter { | |||
| 418 | + | return &MockClipboard_Expecter{mock: &_m.Mock} | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | // Copy provides a mock function for the type MockClipboard | |||
| 422 | + | func (_mock *MockClipboard) Copy(context1 context.Context, reader io.Reader) error { | |||
| 423 | + | ret := _mock.Called(context1, reader) | |||
| 424 | + | ||||
| 425 | + | if len(ret) == 0 { | |||
| 426 | + | panic("no return value specified for Copy") | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | var r0 error | |||
| 430 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, io.Reader) error); ok { | |||
| 431 | + | r0 = returnFunc(context1, reader) | |||
| 432 | + | } else { | |||
| 433 | + | r0 = ret.Error(0) | |||
| 434 | + | } | |||
| 435 | + | return r0 | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | // MockClipboard_Copy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Copy' | |||
| 439 | + | type MockClipboard_Copy_Call struct { | |||
| 440 | + | *mock.Call | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // Copy is a helper method to define mock.On call | |||
| 444 | + | // - context1 context.Context | |||
| 445 | + | // - reader io.Reader | |||
| 446 | + | func (_e *MockClipboard_Expecter) Copy(context1 any, reader any) *MockClipboard_Copy_Call { | |||
| 447 | + | return &MockClipboard_Copy_Call{Call: _e.mock.On("Copy", context1, reader)} | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func (_c *MockClipboard_Copy_Call) Run(run func(context1 context.Context, reader io.Reader)) *MockClipboard_Copy_Call { | |||
| 451 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 452 | + | var arg0 context.Context | |||
| 453 | + | if args[0] != nil { | |||
| 454 | + | arg0 = args[0].(context.Context) | |||
| 455 | + | } | |||
| 456 | + | var arg1 io.Reader | |||
| 457 | + | if args[1] != nil { | |||
| 458 | + | arg1 = args[1].(io.Reader) | |||
| 459 | + | } | |||
| 460 | + | run( | |||
| 461 | + | arg0, | |||
| 462 | + | arg1, | |||
| 463 | + | ) | |||
| 464 | + | }) | |||
| 465 | + | return _c | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | func (_c *MockClipboard_Copy_Call) Return(err error) *MockClipboard_Copy_Call { | |||
| 469 | + | _c.Call.Return(err) | |||
| 470 | + | return _c | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func (_c *MockClipboard_Copy_Call) RunAndReturn(run func(context1 context.Context, reader io.Reader) error) *MockClipboard_Copy_Call { | |||
| 474 | + | _c.Call.Return(run) | |||
| 475 | + | return _c | |||
| 476 | + | } | |||
| 477 | + | ||||
| 321 | // NewMockVideoWriter creates a new instance of MockVideoWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 478 | // NewMockVideoWriter creates a new instance of MockVideoWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 322 | // The first argument is typically a *testing.T value. | 479 | // The first argument is typically a *testing.T value. | ||
| 323 | func NewMockVideoWriter(t interface { | 480 | func NewMockVideoWriter(t interface { | ||
M internal/ports/capture.go +14 -5
| @@ -3,6 +3,7 @@ package ports | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "io" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -67,6 +68,13 @@ type Source interface { | |||
| 67 | 68 | // Save completes all reads from borrowed frame storage before returning. | |
| 68 | 69 | type ScreenshotWriter interface { | |
| 69 | 70 | Save(context.Context, Frame, string) error | |
| 71 | + | // Encode consumes borrowed pixels synchronously without creating a file. | |
| 72 | + | Encode(context.Context, io.Writer, Frame) error | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Clipboard accepts PNG bytes synchronously. Its owner may outlive this process. | |
| 76 | + | type Clipboard interface { | |
| 77 | + | Copy(context.Context, io.Reader) error | |
| 70 | 78 | } | |
| 71 | 79 | ||
| 72 | 80 | // VideoSettings describes fixed-rate silent SDR video. Zero output dimensions | |
| @@ -107,11 +115,12 @@ const ( | |||
| 107 | 115 | ||
| 108 | 116 | // Selection is returned by a control panel before capture begins. | |
| 109 | 117 | type Selection struct { | |
| 110 | - | Mode Mode | |
| 111 | - | Target Target | |
| 112 | - | Path string | |
| 113 | - | Video VideoSettings | |
| 114 | - | Duration time.Duration // zero records until context cancellation | |
| 118 | + | Mode Mode | |
| 119 | + | Target Target | |
| 120 | + | Path string | |
| 121 | + | Clipboard bool // screenshot only; permits an empty Path | |
| 122 | + | Video VideoSettings | |
| 123 | + | Duration time.Duration // zero records until context cancellation | |
| 115 | 124 | } | |
| 116 | 125 | ||
| 117 | 126 | // Selector must close its surface before returning an accepted selection. | |
| @@ -3,6 +3,7 @@ package ports | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "io" | |||
| 6 | "time" | 7 | "time" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| @@ -67,6 +68,13 @@ type Source interface { | |||||
| 67 | // Save completes all reads from borrowed frame storage before returning. | 68 | // Save completes all reads from borrowed frame storage before returning. | ||
| 68 | type ScreenshotWriter interface { | 69 | type ScreenshotWriter interface { | ||
| 69 | Save(context.Context, Frame, string) error | 70 | Save(context.Context, Frame, string) error | ||
| 71 | + | // Encode consumes borrowed pixels synchronously without creating a file. | |||
| 72 | + | Encode(context.Context, io.Writer, Frame) error | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Clipboard accepts PNG bytes synchronously. Its owner may outlive this process. | |||
| 76 | + | type Clipboard interface { | |||
| 77 | + | Copy(context.Context, io.Reader) error | |||
| 70 | } | 78 | } | ||
| 71 | 79 | ||||
| 72 | // VideoSettings describes fixed-rate silent SDR video. Zero output dimensions | 80 | // VideoSettings describes fixed-rate silent SDR video. Zero output dimensions | ||
| @@ -107,11 +115,12 @@ const ( | |||||
| 107 | 115 | ||||
| 108 | // Selection is returned by a control panel before capture begins. | 116 | // Selection is returned by a control panel before capture begins. | ||
| 109 | type Selection struct { | 117 | type Selection struct { | ||
| 110 | - | Mode Mode | 118 | + | Mode Mode |
| 111 | - | Target Target | 119 | + | Target Target |
| 112 | - | Path string | 120 | + | Path string |
| 113 | - | Video VideoSettings | 121 | + | Clipboard bool // screenshot only; permits an empty Path |
| 114 | - | Duration time.Duration // zero records until context cancellation | 122 | + | Video VideoSettings |
| 123 | + | Duration time.Duration // zero records until context cancellation | |||
| 115 | } | 124 | } | ||
| 116 | 125 | ||||
| 117 | // Selector must close its surface before returning an accepted selection. | 126 | // Selector must close its surface before returning an accepted selection. | ||