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/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
21const discoveryTimeout = 5 * time.Second
22
23// Run owns the source connection and all capture work for one invocation.
24func Run(ctx context.Context, options cli.Options, output io.Writer) (err error) {
25 if handled, controlErr := controlCommand(ctx, options.Command, output); handled {
26 return controlErr
27 }
28 discovery, cancel := context.WithTimeout(ctx, discoveryTimeout)
29 source, err := wayland.New(discovery, "")
30 if err != nil {
31 cancel()
32 return discoveryError(ctx, "connect Wayland", err)
33 }
34 defer func() {
35 if closeErr := source.Close(); closeErr != nil {
36 err = errors.Join(err, closeErr)
37 }
38 }()
39 outputs, err := source.Outputs(discovery)
40 cancel()
41 if err != nil {
42 return discoveryError(ctx, "list outputs", err)
43 }
44 if options.Command == cli.Outputs {
45 for _, item := range outputs {
46 if _, err := fmt.Fprintf(output, "%s\t%dx%d\tscale=%d\n", item.Name, item.Width, item.Height, item.Scale); err != nil {
47 return err
48 }
49 }
50 return nil
51 }
52 var selection ports.Selection
53 if options.Command == cli.Shot || options.Command == cli.Rec || options.Command == cli.AllInOne {
54 // Fail on a bad config before anything is shown.
55 settings, loadErr := config.Load()
56 if loadErr != nil {
57 return fmt.Errorf("load config: %w", loadErr)
58 }
59 if err := rejectCompetingSelector(ctx); err != nil {
60 return err
61 }
62 var accepted bool
63 selection, accepted, err = chooseInteractive(ctx, source, outputs, options, settings)
64 if err != nil || !accepted {
65 return err
66 }
67 } else {
68 target, err := resolveOutput(outputs, options.Output)
69 if err != nil {
70 return err
71 }
72 selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration}
73 switch options.Command {
74 case cli.Screenshot:
75 selection.Mode = ports.Screenshot
76 case cli.Record:
77 selection.Mode = ports.Record
78 default:
79 return fmt.Errorf("unsupported command %q", options.Command)
80 }
81 }
82 log := logging.For(ctx, "app")
83 log.Info().Str("mode", string(selection.Mode)).Msg("capture started")
84 var saved bool
85 if selection.Mode == ports.Record {
86 var outcome recordingOutcome
87 outcome, err = recordInteractive(ctx, output, source, outputs, selection)
88 saved = outcome.Saved
89 } else {
90 var writer ports.ScreenshotWriter = png.New()
91 var copied *clipboard.Writer
92 if selection.Clipboard {
93 copied = clipboard.NewWriter(writer, clipboard.New())
94 writer = copied
95 }
96 err = core.New(source, writer, nil).Run(ctx, selection)
97 saved = err == nil && selection.Path != ""
98 if copied != nil {
99 saved = copied.Saved
100 }
101 }
102 if saved && err == nil {
103 log.Info().Msg("capture complete")
104 }
105 return finishCapture(output, selection.Path, saved, err)
106}
107
108// finishCapture prints the path of a file that exists, whatever error the
109// capture ended with, so a reported failure never hides where the file is.
110// Nothing is printed when no file was saved. The original error and a failed
111// write are both returned.
112func finishCapture(output io.Writer, path string, saved bool, err error) error {
113 if !saved {
114 return err
115 }
116 if _, printErr := fmt.Fprintln(output, path); printErr != nil {
117 return errors.Join(err, printErr)
118 }
119 return err
120}
121
122// discoveryError preserves a user interrupt, but reports local timeouts and
123// actual protocol failures with the failing phase.
124func discoveryError(ctx context.Context, phase string, err error) error {
125 if ctx.Err() != nil && err == ctx.Err() {
126 return err
127 }
128 return fmt.Errorf("%s: %w", phase, err)
129}
130
131func resolveOutput(outputs []ports.Output, name string) (ports.Output, error) {
132 if name == "" {
133 if len(outputs) == 1 {
134 return outputs[0], nil
135 }
136 return ports.Output{}, fmt.Errorf("select an output with -output (use outputs to list names)")
137 }
138 for _, output := range outputs {
139 if output.Name == name {
140 return output, nil
141 }
142 }
143 return ports.Output{}, fmt.Errorf("%w: %q", ports.ErrOutputNotFound, name)
144}