1// Package app wires the capture adapters to the core workflow.
2package app
3
4import (
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/clipboard"
13 "github.com/bnema/nefercap/internal/adapters/config"
14 "github.com/bnema/nefercap/internal/adapters/flash"
15 "github.com/bnema/nefercap/internal/adapters/png"
16 "github.com/bnema/nefercap/internal/adapters/wayland"
17 "github.com/bnema/nefercap/internal/core"
18 "github.com/bnema/nefercap/internal/logging"
19 "github.com/bnema/nefercap/internal/ports"
20)
21
22const discoveryTimeout = 5 * time.Second
23
24// Run owns the source connection and all capture work for one invocation.
25func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) {
26 if handled, controlErr := controlCommand(ctx, options.Command, output); handled {
27 return controlErr
28 }
29 discovery, cancel := context.WithTimeout(ctx, discoveryTimeout)
30 source, err := wayland.New(discovery, "")
31 if err != nil {
32 cancel()
33 return discoveryError(ctx, "connect Wayland", err)
34 }
35 defer func() {
36 if closeErr := source.Close(); closeErr != nil {
37 err = errors.Join(err, closeErr)
38 }
39 }()
40 outputs, err := source.Outputs(discovery)
41 cancel()
42 if err != nil {
43 return discoveryError(ctx, "list outputs", err)
44 }
45 if options.Command == cli.Outputs {
46 for _, item := range outputs {
47 if _, err := fmt.Fprintf(output, "%s\t%dx%d\tscale=%d\n", item.Name, item.Width, item.Height, item.Scale); err != nil {
48 return err
49 }
50 }
51 return nil
52 }
53 var selection ports.Selection
54 interactive := options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne
55 if interactive {
56 // Fail on a bad config before anything is shown.
57 settings, loadErr := config.Load()
58 if loadErr != nil {
59 return fmt.Errorf("load config: %w", loadErr)
60 }
61 if err := rejectCompetingSelector(ctx); err != nil {
62 return err
63 }
64 var accepted bool
65 selection, accepted, err = chooseInteractive(ctx, source, outputs, options, settings)
66 if err != nil || !accepted {
67 return err
68 }
69 } else {
70 target, err := resolveOutput(outputs, options.Output)
71 if err != nil {
72 return err
73 }
74 selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration}
75 switch options.Command {
76 case cli.Screenshot:
77 selection.Mode = ports.Screenshot
78 case cli.Record:
79 selection.Mode = ports.Record
80 default:
81 return fmt.Errorf("unsupported command %q", options.Command)
82 }
83 }
84 log := logging.For(ctx, "app")
85 log.Info().Str("mode", string(selection.Mode)).Msg("capture started")
86 var saved bool
87 if selection.Mode == ports.Record {
88 var outcome recordingOutcome
89 outcome, err = recordInteractive(ctx, output, source, outputs, selection)
90 saved = outcome.Saved
91 } else {
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, nil).Run(ctx, selection)
99 saved = err == nil && selection.Path != ""
100 if copied != nil {
101 saved = copied.Saved
102 }
103 }
104 if saved && err == nil {
105 log.Info().Msg("capture complete")
106 }
107 err = finishCapture(output, selection.Path, saved, err)
108 if err == nil && interactive && selection.Mode == ports.Screenshot {
109 confirmScreenshot(ctx, source, outputs, selection.Target)
110 }
111 return err
112}
113
114// confirmScreenshot flashes the captured area once the screenshot is done. The
115// screenshot already succeeded, so a failed flash is only logged. One budget
116// bounds the workspace lookup and the flash, so a silent compositor cannot
117// keep the process alive.
118func confirmScreenshot(ctx context.Context, source *wayland.Source, outputs []ports.Output, target ports.Target) {
119 ctx, cancel := context.WithTimeout(ctx, flash.Limit)
120 defer cancel()
121 for _, o := range outputs {
122 if o.ID != target.OutputID {
123 continue
124 }
125 area := target.Region
126 if target.WorkspaceID != 0 {
127 area = recordedFrame(ctx, source, target)
128 if ctx.Err() != nil {
129 return // the budget went on the lookup
130 }
131 }
132 if err := flash.Show(ctx, o, area); err != nil {
133 log := logging.For(ctx, "app")
134 log.Warn().Err(err).Msg("screenshot confirmation not shown")
135 }
136 return
137 }
138}
139
140// finishCapture prints the path of a file that exists, whatever error the
141// capture ended with, so a reported failure never hides where the file is.
142// Nothing is printed when no file was saved. The original error and a failed
143// write are both returned.
144func finishCapture(output io.Writer, path string, saved bool, err error) error {
145 if !saved {
146 return err
147 }
148 if _, printErr := fmt.Fprintln(output, path); printErr != nil {
149 return errors.Join(err, printErr)
150 }
151 return err
152}
153
154// discoveryError preserves a user interrupt, but reports local timeouts and
155// actual protocol failures with the failing phase.
156func discoveryError(ctx context.Context, phase string, err error) error {
157 if ctx.Err() != nil && err == ctx.Err() {
158 return err
159 }
160 return fmt.Errorf("%s: %w", phase, err)
161}
162
163func resolveOutput(outputs []ports.Output, name string) (ports.Output, error) {
164 if name == "" {
165 if len(outputs) == 1 {
166 return outputs[0], nil
167 }
168 return ports.Output{}, fmt.Errorf("select an output with -output (use outputs to list names)")
169 }
170 for _, output := range outputs {
171 if output.Name == name {
172 return output, nil
173 }
174 }
175 return ports.Output{}, fmt.Errorf("%w: %q", ports.ErrOutputNotFound, name)
176}