1// Package destination chooses default capture locations.
2//
3// Screenshot and Video return a fresh, timestamped file path inside the user's
4// default directory, which exists on return. The path is only a proposal: it is not
5// reserved, so the writer must still create the file with O_EXCL (all capture
6// writers do) and report a conflict instead of overwriting.
7package destination
8
9import (
10 "bufio"
11 "errors"
12 "fmt"
13 "io"
14 "os"
15 "path/filepath"
16 "strings"
17 "time"
18)
19
20const (
21 dirMode = 0o700
22 // stampLayout has nanosecond precision so two captures started in the
23 // same second get different names.
24 stampLayout = "20060102-150405.000000000"
25 prefix = "nefercap-"
26 maxPath = 4096
27 maxConfig = 64 << 10 // user-dirs.dirs is a handful of short lines
28)
29
30// ErrNoHome means no absolute home directory is available for a default.
31var ErrNoHome = errors.New("destination: no absolute home directory")
32
33// Screenshot returns a new .png path in the user's XDG pictures directory
34// (Screenshots subdirectory), falling back to $HOME/Pictures/Screenshots.
35func Screenshot() (string, error) { return ScreenshotIn("") }
36
37// ScreenshotIn is Screenshot inside dir, an absolute directory that is created
38// when missing. An empty dir selects the XDG default.
39func ScreenshotIn(dir string) (string, error) {
40 if dir != "" {
41 return propose(".png", func() (string, error) { return dir, nil })
42 }
43 return propose(".png", func() (string, error) {
44 return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots")
45 })
46}
47
48// Video returns a new .mp4 path in the user's XDG videos directory, falling
49// back to $HOME/Videos.
50func Video() (string, error) { return VideoIn("") }
51
52// VideoIn is Video inside dir, an absolute directory that is created when
53// missing. An empty dir selects the XDG default.
54func VideoIn(dir string) (string, error) {
55 if dir != "" {
56 return propose(".mp4", func() (string, error) { return dir, nil })
57 }
58 return propose(".mp4", func() (string, error) {
59 return userDir("XDG_VIDEOS_DIR", "Videos")
60 })
61}
62
63func propose(ext string, auto func() (string, error)) (string, error) {
64 dir, err := auto()
65 if err != nil {
66 return "", err
67 }
68 if err := os.MkdirAll(dir, dirMode); err != nil {
69 return "", fmt.Errorf("destination: create directory: %w", err)
70 }
71 fi, err := os.Stat(dir)
72 if err != nil {
73 return "", fmt.Errorf("destination: stat directory: %w", err)
74 }
75 if !fi.IsDir() {
76 return "", fmt.Errorf("destination: %w: not a directory", os.ErrInvalid)
77 }
78 return filepath.Join(dir, prefix+time.Now().Format(stampLayout)+ext), nil
79}
80
81// userDir resolves the XDG user directory named by key: the environment
82// variable first, then user-dirs.dirs, then $HOME/<fallback...>. Elements of
83// fallback after the first (Screenshots) are also appended to an XDG-provided
84// directory.
85func userDir(key string, fallback ...string) (string, error) {
86 home, err := os.UserHomeDir()
87 if err != nil || !filepath.IsAbs(home) {
88 return "", ErrNoHome
89 }
90 home = filepath.Clean(home)
91 sub := fallback[1:]
92
93 if d, ok := expand(os.Getenv(key), home); ok {
94 return filepath.Join(append([]string{d}, sub...)...), nil
95 }
96 if d, ok := expand(fromConfig(key, home), home); ok {
97 return filepath.Join(append([]string{d}, sub...)...), nil
98 }
99 return filepath.Join(append([]string{home}, fallback...)...), nil
100}
101
102// expand accepts an absolute path, optionally starting with $HOME or ${HOME}.
103// No shell, no other variables, no command substitution. The home directory
104// itself is rejected: user-dirs uses it to mean "disabled".
105func expand(v, home string) (string, bool) {
106 if v == "" || len(v) > maxPath || strings.IndexByte(v, 0) >= 0 {
107 return "", false
108 }
109 switch {
110 case v == "$HOME" || strings.HasPrefix(v, "$HOME/"):
111 v = home + v[len("$HOME"):]
112 case v == "${HOME}" || strings.HasPrefix(v, "${HOME}/"):
113 v = home + v[len("${HOME}"):]
114 }
115 if strings.ContainsAny(v, "$`") || !filepath.IsAbs(v) {
116 return "", false
117 }
118 v = filepath.Clean(v)
119 if v == home || v == string(filepath.Separator) {
120 return "", false
121 }
122 return v, true
123}
124
125// fromConfig reads KEY="value" from the user-dirs.dirs file, bounded in size.
126func fromConfig(key, home string) string {
127 cfg := os.Getenv("XDG_CONFIG_HOME")
128 if !filepath.IsAbs(cfg) {
129 cfg = filepath.Join(home, ".config")
130 }
131 f, err := os.Open(filepath.Join(cfg, "user-dirs.dirs"))
132 if err != nil {
133 return ""
134 }
135 defer f.Close()
136 sc := bufio.NewScanner(io.LimitReader(f, maxConfig))
137 sc.Buffer(make([]byte, 0, 1024), maxPath+256)
138 var val string
139 for sc.Scan() {
140 line := strings.TrimSpace(sc.Text())
141 k, v, ok := strings.Cut(line, "=")
142 if !ok || strings.TrimSpace(k) != key {
143 continue
144 }
145 val = unquote(strings.TrimSpace(v)) // the last assignment wins
146 }
147 return val
148}
149
150// unquote handles the double-quoted, backslash-escaped form written by
151// xdg-user-dirs-update, and bare values.
152func unquote(v string) string {
153 if len(v) < 2 || v[0] != '"' {
154 return v
155 }
156 var b strings.Builder
157 for i := 1; i < len(v); i++ {
158 switch c := v[i]; c {
159 case '\\':
160 if i+1 < len(v) {
161 i++
162 b.WriteByte(v[i])
163 }
164 case '"':
165 return b.String()
166 default:
167 b.WriteByte(c)
168 }
169 }
170 return "" // unterminated
171}