detached 5adeb53

refactor(config): parse and watch the config with kvconf

commit
5adeb5330d5e0d0d831fdd9bd0b7262538b71233
author
brice · · 2026-10-10 03:01 UTC
committer
brice · · 2026-10-10 03:02 UTC
parent
31f302d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +89 -240
M go.mod +1 -0
@@ -4,6 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/kvconf v0.1.0
78 github.com/bnema/neferclient v0.2.0
89 github.com/bnema/nefergui v0.6.1
910 github.com/bnema/purego-pam v0.1.0
@@ -4,6 +4,7 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/kvconf v0.1.0
7 github.com/bnema/neferclient v0.2.08 github.com/bnema/neferclient v0.2.0
8 github.com/bnema/nefergui v0.6.19 github.com/bnema/nefergui v0.6.1
9 github.com/bnema/purego-pam v0.1.010 github.com/bnema/purego-pam v0.1.0
M internal/adapters/config/config.go +35 -75
@@ -2,15 +2,13 @@
22 package config
33
44 import (
5- "bufio"
5+ "errors"
66 "fmt"
77 "io"
88 "os"
99 "path/filepath"
10- "strings"
11- "time"
12- "unicode/utf8"
1310
11+ "github.com/bnema/kvconf"
1412 "github.com/bnema/neferafk/internal/ports"
1513 )
1614
@@ -26,13 +24,16 @@ func DefaultPath() string {
2624 }
2725 return filepath.Join(base, "neferafk", "config")
2826 }
27+
2928 func Load(path string) (ports.Config, error) {
30- f, err := os.Open(path)
29+ data, err := kvconf.ReadFile(path, options)
3130 if err != nil {
31+ if errors.Is(err, kvconf.ErrTooLarge) {
32+ return ports.Config{}, errTooLarge
33+ }
3234 return ports.Config{}, err
3335 }
34- defer f.Close()
35- return Parse(f)
36+ return parse(data)
3637 }
3738
3839 // Parse never returns partial configuration and never resolves PIN references.
@@ -41,75 +42,34 @@ func Parse(r io.Reader) (ports.Config, error) {
4142 if err != nil {
4243 return ports.Config{}, err
4344 }
44- if !utf8.Valid(data) {
45- return ports.Config{}, fmt.Errorf("config is not valid UTF-8")
46- }
47- if len(data) > MaxBytes {
48- return ports.Config{}, fmt.Errorf("config exceeds 64KiB")
49- }
45+ return parse(data)
46+}
47+
48+// options: any problem rejects the whole file; "#" starts a comment anywhere
49+// and values are never unquoted.
50+var options = kvconf.Options{Strict: true, Comments: kvconf.CommentAny, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine}
51+
52+var errTooLarge = errors.New("config exceeds 64KiB")
53+
54+func parse(data []byte) (ports.Config, error) {
5055 cfg := ports.Defaults()
51- seen := map[string]bool{}
52- scanner := bufio.NewScanner(strings.NewReader(string(data)))
53- scanner.Buffer(make([]byte, MaxLine+1), MaxLine+2)
54- line := 0
55- for scanner.Scan() {
56- line++
57- text := scanner.Text()
58- if len(text) > MaxLine {
59- return ports.Config{}, fmt.Errorf("line %d exceeds 4KiB", line)
60- }
61- text = strings.TrimSpace(strings.SplitN(text, "#", 2)[0])
62- if text == "" {
63- continue
64- }
65- key, value, ok := strings.Cut(text, "=")
66- key, value = strings.TrimSpace(key), strings.TrimSpace(value)
67- if !ok || value == "" || seen[key] {
68- return ports.Config{}, fmt.Errorf("line %d: missing value or duplicate key", line)
69- }
70- seen[key] = true
71- var target *time.Duration
72- switch key {
73- case "lock.after":
74- target = &cfg.LockAfter
75- case "fade.after":
76- target = &cfg.FadeAfter
77- case "fade.duration":
78- target = &cfg.FadeDuration
79- case "screens.off-after":
80- target = &cfg.OffAfter
81- case "sleep.after":
82- target = &cfg.SleepAfter
83- case "lock.output":
84- cfg.LockOutput = value
85- case "auth.mode":
86- cfg.Auth.Mode = ports.AuthMode(value)
87- case "auth.pin-source":
88- cfg.Auth.PINSource = ports.PINSource(value)
89- case "auth.pin-env":
90- cfg.Auth.PINEnv = value
91- case "auth.pin-entry":
92- cfg.Auth.PINEntry = value
93- default:
94- return ports.Config{}, fmt.Errorf("line %d: unknown key", line)
95- }
96- if target != nil {
97- if value == "off" && key != "fade.duration" {
98- *target = 0
99- continue
100- }
101- d, e := time.ParseDuration(value)
102- if e != nil || d < 0 || d > ports.MaxDuration || d == 0 && key != "fade.duration" {
103- return ports.Config{}, fmt.Errorf("line %d: invalid duration", line)
104- }
105- *target = d
106- }
107- }
108- if err := scanner.Err(); err != nil {
109- return ports.Config{}, fmt.Errorf("config line exceeds bound")
110- }
111- if err := cfg.Validate(); err != nil {
112- return ports.Config{}, err
56+ if _, err := kvconf.Unmarshal(data, &cfg, options); err != nil {
57+ return ports.Config{}, reject(err)
11358 }
11459 return cfg, nil
11560 }
61+
62+// reject rewrites kvconf errors so they carry a line number and a fixed
63+// message, never a key or value, which may be secret.
64+func reject(err error) error {
65+ var w kvconf.Warning
66+ switch {
67+ case errors.Is(err, kvconf.ErrTooLarge):
68+ return errTooLarge
69+ case errors.Is(err, kvconf.ErrNotUTF8):
70+ return errors.New("config is not valid UTF-8")
71+ case errors.As(err, &w):
72+ return fmt.Errorf("line %d: %s", w.Line, w.Msg)
73+ }
74+ return err
75+}
@@ -2,15 +2,13 @@
2 package config2 package config
3 3
4 import (4 import (
5- "bufio"5+ "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10- "strings"
11- "time"
12- "unicode/utf8"
13 10
11+ "github.com/bnema/kvconf"
14 "github.com/bnema/neferafk/internal/ports"12 "github.com/bnema/neferafk/internal/ports"
15 )13 )
16 14
@@ -26,13 +24,16 @@ func DefaultPath() string {
26 }24 }
27 return filepath.Join(base, "neferafk", "config")25 return filepath.Join(base, "neferafk", "config")
28 }26 }
27+
29 func Load(path string) (ports.Config, error) {28 func Load(path string) (ports.Config, error) {
30- f, err := os.Open(path)29+ data, err := kvconf.ReadFile(path, options)
31 if err != nil {30 if err != nil {
31+ if errors.Is(err, kvconf.ErrTooLarge) {
32+ return ports.Config{}, errTooLarge
33+ }
32 return ports.Config{}, err34 return ports.Config{}, err
33 }35 }
34- defer f.Close()36+ return parse(data)
35- return Parse(f)
36 }37 }
37 38
38 // Parse never returns partial configuration and never resolves PIN references.39 // Parse never returns partial configuration and never resolves PIN references.
@@ -41,75 +42,34 @@ func Parse(r io.Reader) (ports.Config, error) {
41 if err != nil {42 if err != nil {
42 return ports.Config{}, err43 return ports.Config{}, err
43 }44 }
44- if !utf8.Valid(data) {45+ return parse(data)
45- return ports.Config{}, fmt.Errorf("config is not valid UTF-8")46+}
46- }47+
47- if len(data) > MaxBytes {48+// options: any problem rejects the whole file; "#" starts a comment anywhere
48- return ports.Config{}, fmt.Errorf("config exceeds 64KiB")49+// and values are never unquoted.
49- }50+var options = kvconf.Options{Strict: true, Comments: kvconf.CommentAny, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine}
51+
52+var errTooLarge = errors.New("config exceeds 64KiB")
53+
54+func parse(data []byte) (ports.Config, error) {
50 cfg := ports.Defaults()55 cfg := ports.Defaults()
51- seen := map[string]bool{}56+ if _, err := kvconf.Unmarshal(data, &cfg, options); err != nil {
52- scanner := bufio.NewScanner(strings.NewReader(string(data)))57+ return ports.Config{}, reject(err)
53- scanner.Buffer(make([]byte, MaxLine+1), MaxLine+2)
54- line := 0
55- for scanner.Scan() {
56- line++
57- text := scanner.Text()
58- if len(text) > MaxLine {
59- return ports.Config{}, fmt.Errorf("line %d exceeds 4KiB", line)
60- }
61- text = strings.TrimSpace(strings.SplitN(text, "#", 2)[0])
62- if text == "" {
63- continue
64- }
65- key, value, ok := strings.Cut(text, "=")
66- key, value = strings.TrimSpace(key), strings.TrimSpace(value)
67- if !ok || value == "" || seen[key] {
68- return ports.Config{}, fmt.Errorf("line %d: missing value or duplicate key", line)
69- }
70- seen[key] = true
71- var target *time.Duration
72- switch key {
73- case "lock.after":
74- target = &cfg.LockAfter
75- case "fade.after":
76- target = &cfg.FadeAfter
77- case "fade.duration":
78- target = &cfg.FadeDuration
79- case "screens.off-after":
80- target = &cfg.OffAfter
81- case "sleep.after":
82- target = &cfg.SleepAfter
83- case "lock.output":
84- cfg.LockOutput = value
85- case "auth.mode":
86- cfg.Auth.Mode = ports.AuthMode(value)
87- case "auth.pin-source":
88- cfg.Auth.PINSource = ports.PINSource(value)
89- case "auth.pin-env":
90- cfg.Auth.PINEnv = value
91- case "auth.pin-entry":
92- cfg.Auth.PINEntry = value
93- default:
94- return ports.Config{}, fmt.Errorf("line %d: unknown key", line)
95- }
96- if target != nil {
97- if value == "off" && key != "fade.duration" {
98- *target = 0
99- continue
100- }
101- d, e := time.ParseDuration(value)
102- if e != nil || d < 0 || d > ports.MaxDuration || d == 0 && key != "fade.duration" {
103- return ports.Config{}, fmt.Errorf("line %d: invalid duration", line)
104- }
105- *target = d
106- }
107- }
108- if err := scanner.Err(); err != nil {
109- return ports.Config{}, fmt.Errorf("config line exceeds bound")
110- }
111- if err := cfg.Validate(); err != nil {
112- return ports.Config{}, err
113 }58 }
114 return cfg, nil59 return cfg, nil
115 }60 }
61+
62+// reject rewrites kvconf errors so they carry a line number and a fixed
63+// message, never a key or value, which may be secret.
64+func reject(err error) error {
65+ var w kvconf.Warning
66+ switch {
67+ case errors.Is(err, kvconf.ErrTooLarge):
68+ return errTooLarge
69+ case errors.Is(err, kvconf.ErrNotUTF8):
70+ return errors.New("config is not valid UTF-8")
71+ case errors.As(err, &w):
72+ return fmt.Errorf("line %d: %s", w.Line, w.Msg)
73+ }
74+ return err
75+}
M internal/adapters/config/watch.go +38 -158
@@ -2,113 +2,49 @@ package config
22
33 import (
44 "context"
5- "encoding/binary"
65 "errors"
7- "os"
8- "path/filepath"
9- "strings"
10- "time"
6+ "io/fs"
117
8+ "github.com/bnema/kvconf"
129 "github.com/bnema/neferafk/internal/ports"
13- "golang.org/x/sys/unix"
1410 )
1511
16-func watchedFiles(path string) []string {
17- files := []string{filepath.Clean(path)}
18- for len(files) <= 40 {
19- cur := files[len(files)-1]
20- target, err := os.Readlink(cur)
21- if err != nil {
22- break
23- }
24- if !filepath.IsAbs(target) {
25- target = filepath.Join(filepath.Dir(cur), target)
26- }
27- target = filepath.Clean(target)
28- duplicate := false
29- for _, p := range files {
30- if p == target {
31- duplicate = true
32- break
33- }
34- }
35- if duplicate {
36- break
37- }
38- files = append(files, target)
39- }
40- return files
41-}
42-
43-// watchDirectory finds the nearest existing ancestor. The missing component's
44-// creation wakes inotify; the two-second retry also covers lost/overflow events.
45-func watchDirectory(file string) (directory, name string, missing bool) {
46- directory, name = filepath.Dir(file), filepath.Base(file)
47- for {
48- st, err := os.Stat(directory)
49- if err == nil && st.IsDir() {
50- return
51- }
52- missing = true
53- parent := filepath.Dir(directory)
54- if parent == directory {
55- return
56- }
57- name, directory = filepath.Base(directory), parent
58- }
59-}
60-
6112 // Watch retains last-valid state across missing files/directories and follows
62-// config symlink targets. It always emits the first valid candidate, including
63-// a candidate equal to defaults: this closes an app Load -> Watch startup gap.
64-// Ready closes after initial watch installation, before candidate delivery.
65-// No invalid/deleted candidate emits a defaults reset. Errors never echo file
66-// content or paths, which may themselves contain sensitive source metadata.
13+// config symlink targets (kvconf.Watch). It always emits the first valid
14+// candidate, including a candidate equal to defaults: this closes an app
15+// Load -> Watch startup gap. Ready closes after initial watch installation,
16+// before candidate delivery. No invalid/deleted candidate emits a defaults
17+// reset. Errors never echo file content or paths, which may themselves
18+// contain sensitive source metadata.
6719 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {
68- fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
69- if err != nil {
70- return errors.New("initialize config watch")
71- }
72- defer unix.Close(fd)
73- const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF
74- names := map[int32]map[string]bool{}
75- retry := time.Time{}
76- rewatch := func() {
77- // Keep unchanged watches, avoiding IN_IGNORED from our own removals.
78- want := map[int32]map[string]bool{}
79- retry = time.Time{}
80- for _, file := range watchedFiles(path) {
81- dir, name, missing := watchDirectory(file)
82- wd, e := unix.InotifyAddWatch(fd, dir, mask)
83- if e != nil {
84- retry = time.Now().Add(2 * time.Second)
85- continue
86- }
87- if missing {
88- retry = time.Now().Add(2 * time.Second)
89- }
90- if want[int32(wd)] == nil {
91- want[int32(wd)] = map[string]bool{}
92- }
93- want[int32(wd)][name] = true
94- }
95- for wd := range names {
96- if want[wd] == nil {
97- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
98- }
20+ ctx, cancel := context.WithCancel(ctx)
21+ changed := make(chan struct{}, 1)
22+ installed := make(chan struct{})
23+ done := make(chan struct{})
24+ var watchErr error
25+ go func() {
26+ defer close(done)
27+ watchErr = kvconf.Watch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed)
28+ }()
29+ defer func() { cancel(); <-done }()
30+
31+ select {
32+ case <-installed:
33+ case <-done:
34+ if watchErr != nil {
35+ return errors.New("initialize config watch")
9936 }
100- names = want
37+ return nil
10138 }
102- rewatch()
10339 if ready != nil {
10440 close(ready)
10541 }
10642 var last ports.Config
10743 haveLast := false
10844 publish := func() bool {
109- cfg, e := Load(path)
110- if e != nil {
111- if !os.IsNotExist(e) && failures != nil {
45+ cfg, err := Load(path)
46+ if err != nil {
47+ if !errors.Is(err, fs.ErrNotExist) && failures != nil {
11248 select {
11349 case failures <- errors.New("config reload rejected"):
11450 default:
@@ -127,73 +63,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai
12763 return false
12864 }
12965 }
130- if !publish() {
131- return nil
132- }
133- pending := time.Time{}
134- buf := make([]byte, 8192)
135- for ctx.Err() == nil {
136- now := time.Now()
137- if !retry.IsZero() && !now.Before(retry) {
138- rewatch()
139- pending = now
140- }
141- if !pending.IsZero() && !now.Before(pending) {
142- pending = time.Time{}
143- rewatch()
144- if !publish() {
145- return nil
146- }
147- }
148- timeout := 100 // cancellation bound, no periodic reads while watches are live
149- if !pending.IsZero() {
150- timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds())))
151- }
152- fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
153- _, e := unix.Poll(fds, timeout)
154- if errors.Is(e, unix.EINTR) {
155- continue
156- }
157- if e != nil {
158- return errors.New("poll config watch")
159- }
160- if fds[0].Revents&unix.POLLIN == 0 {
161- continue
162- }
163- n, e := unix.Read(fd, buf)
164- if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) {
165- continue
166- }
167- if e != nil {
168- return errors.New("read config watch")
169- }
170- for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
171- b := buf[offset:]
172- wd := int32(binary.NativeEndian.Uint32(b))
173- flags := binary.NativeEndian.Uint32(b[4:])
174- length := int(binary.NativeEndian.Uint32(b[12:]))
175- size := unix.SizeofInotifyEvent + length
176- if size > n-offset {
177- break
178- }
179- if flags&unix.IN_Q_OVERFLOW != 0 {
180- pending = time.Now().Add(100 * time.Millisecond)
181- }
182- if names[wd] != nil {
183- if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 {
184- if flags&unix.IN_IGNORED == 0 {
185- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
186- }
187- delete(names, wd)
188- pending = time.Now().Add(100 * time.Millisecond)
189- retry = time.Now().Add(2 * time.Second)
190- }
191- name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00")
192- if names[wd][name] {
193- pending = time.Now().Add(100 * time.Millisecond)
194- }
66+ for ok := publish(); ok; {
67+ select {
68+ case <-ctx.Done():
69+ return nil
70+ case <-done:
71+ if watchErr != nil {
72+ return errors.New("config watch")
19573 }
196- offset += size
74+ return nil
75+ case <-changed:
76+ ok = publish()
19777 }
19878 }
19979 return nil
@@ -2,113 +2,49 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5- "encoding/binary"
6 "errors"5 "errors"
7- "os"6+ "io/fs"
8- "path/filepath"
9- "strings"
10- "time"
11 7
8+ "github.com/bnema/kvconf"
12 "github.com/bnema/neferafk/internal/ports"9 "github.com/bnema/neferafk/internal/ports"
13- "golang.org/x/sys/unix"
14 )10 )
15 11
16-func watchedFiles(path string) []string {
17- files := []string{filepath.Clean(path)}
18- for len(files) <= 40 {
19- cur := files[len(files)-1]
20- target, err := os.Readlink(cur)
21- if err != nil {
22- break
23- }
24- if !filepath.IsAbs(target) {
25- target = filepath.Join(filepath.Dir(cur), target)
26- }
27- target = filepath.Clean(target)
28- duplicate := false
29- for _, p := range files {
30- if p == target {
31- duplicate = true
32- break
33- }
34- }
35- if duplicate {
36- break
37- }
38- files = append(files, target)
39- }
40- return files
41-}
42-
43-// watchDirectory finds the nearest existing ancestor. The missing component's
44-// creation wakes inotify; the two-second retry also covers lost/overflow events.
45-func watchDirectory(file string) (directory, name string, missing bool) {
46- directory, name = filepath.Dir(file), filepath.Base(file)
47- for {
48- st, err := os.Stat(directory)
49- if err == nil && st.IsDir() {
50- return
51- }
52- missing = true
53- parent := filepath.Dir(directory)
54- if parent == directory {
55- return
56- }
57- name, directory = filepath.Base(directory), parent
58- }
59-}
60-
61 // Watch retains last-valid state across missing files/directories and follows12 // Watch retains last-valid state across missing files/directories and follows
62-// config symlink targets. It always emits the first valid candidate, including13+// config symlink targets (kvconf.Watch). It always emits the first valid
63-// a candidate equal to defaults: this closes an app Load -> Watch startup gap.14+// candidate, including a candidate equal to defaults: this closes an app
64-// Ready closes after initial watch installation, before candidate delivery.15+// Load -> Watch startup gap. Ready closes after initial watch installation,
65-// No invalid/deleted candidate emits a defaults reset. Errors never echo file16+// before candidate delivery. No invalid/deleted candidate emits a defaults
66-// content or paths, which may themselves contain sensitive source metadata.17+// reset. Errors never echo file content or paths, which may themselves
18+// contain sensitive source metadata.
67 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {19 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {
68- fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)20+ ctx, cancel := context.WithCancel(ctx)
69- if err != nil {21+ changed := make(chan struct{}, 1)
70- return errors.New("initialize config watch")22+ installed := make(chan struct{})
71- }23+ done := make(chan struct{})
72- defer unix.Close(fd)24+ var watchErr error
73- const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF25+ go func() {
74- names := map[int32]map[string]bool{}26+ defer close(done)
75- retry := time.Time{}27+ watchErr = kvconf.Watch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed)
76- rewatch := func() {28+ }()
77- // Keep unchanged watches, avoiding IN_IGNORED from our own removals.29+ defer func() { cancel(); <-done }()
78- want := map[int32]map[string]bool{}30+
79- retry = time.Time{}31+ select {
80- for _, file := range watchedFiles(path) {32+ case <-installed:
81- dir, name, missing := watchDirectory(file)33+ case <-done:
82- wd, e := unix.InotifyAddWatch(fd, dir, mask)34+ if watchErr != nil {
83- if e != nil {35+ return errors.New("initialize config watch")
84- retry = time.Now().Add(2 * time.Second)
85- continue
86- }
87- if missing {
88- retry = time.Now().Add(2 * time.Second)
89- }
90- if want[int32(wd)] == nil {
91- want[int32(wd)] = map[string]bool{}
92- }
93- want[int32(wd)][name] = true
94- }
95- for wd := range names {
96- if want[wd] == nil {
97- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
98- }
99 }36 }
100- names = want37+ return nil
101 }38 }
102- rewatch()
103 if ready != nil {39 if ready != nil {
104 close(ready)40 close(ready)
105 }41 }
106 var last ports.Config42 var last ports.Config
107 haveLast := false43 haveLast := false
108 publish := func() bool {44 publish := func() bool {
109- cfg, e := Load(path)45+ cfg, err := Load(path)
110- if e != nil {46+ if err != nil {
111- if !os.IsNotExist(e) && failures != nil {47+ if !errors.Is(err, fs.ErrNotExist) && failures != nil {
112 select {48 select {
113 case failures <- errors.New("config reload rejected"):49 case failures <- errors.New("config reload rejected"):
114 default:50 default:
@@ -127,73 +63,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai
127 return false63 return false
128 }64 }
129 }65 }
130- if !publish() {66+ for ok := publish(); ok; {
131- return nil67+ select {
132- }68+ case <-ctx.Done():
133- pending := time.Time{}69+ return nil
134- buf := make([]byte, 8192)70+ case <-done:
135- for ctx.Err() == nil {71+ if watchErr != nil {
136- now := time.Now()72+ return errors.New("config watch")
137- if !retry.IsZero() && !now.Before(retry) {
138- rewatch()
139- pending = now
140- }
141- if !pending.IsZero() && !now.Before(pending) {
142- pending = time.Time{}
143- rewatch()
144- if !publish() {
145- return nil
146- }
147- }
148- timeout := 100 // cancellation bound, no periodic reads while watches are live
149- if !pending.IsZero() {
150- timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds())))
151- }
152- fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
153- _, e := unix.Poll(fds, timeout)
154- if errors.Is(e, unix.EINTR) {
155- continue
156- }
157- if e != nil {
158- return errors.New("poll config watch")
159- }
160- if fds[0].Revents&unix.POLLIN == 0 {
161- continue
162- }
163- n, e := unix.Read(fd, buf)
164- if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) {
165- continue
166- }
167- if e != nil {
168- return errors.New("read config watch")
169- }
170- for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
171- b := buf[offset:]
172- wd := int32(binary.NativeEndian.Uint32(b))
173- flags := binary.NativeEndian.Uint32(b[4:])
174- length := int(binary.NativeEndian.Uint32(b[12:]))
175- size := unix.SizeofInotifyEvent + length
176- if size > n-offset {
177- break
178- }
179- if flags&unix.IN_Q_OVERFLOW != 0 {
180- pending = time.Now().Add(100 * time.Millisecond)
181- }
182- if names[wd] != nil {
183- if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 {
184- if flags&unix.IN_IGNORED == 0 {
185- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
186- }
187- delete(names, wd)
188- pending = time.Now().Add(100 * time.Millisecond)
189- retry = time.Now().Add(2 * time.Second)
190- }
191- name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00")
192- if names[wd][name] {
193- pending = time.Now().Add(100 * time.Millisecond)
194- }
195 }73 }
196- offset += size74+ return nil
75+ case <-changed:
76+ ok = publish()
197 }77 }
198 }78 }
199 return nil79 return nil
M internal/ports/config.go +15 -7
@@ -11,14 +11,21 @@ import (
1111 const MaxDuration = 24 * time.Hour
1212
1313 // Zero After disables only that action. No deadline implies another action.
14+//
15+// The kv tags describe the config file keys (decoded by adapters/config). A
16+// zero duration is written "off", except fade.duration where 0s is a value.
17+// max:"24h" mirrors MaxDuration (kept for the line number; Validate also checks).
1418 type Config struct {
15- LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration
16- FadeDuration time.Duration
17- Auth AuthConfig
19+ LockAfter time.Duration `kv:"lock.after,off" min:"1ns" max:"24h"`
20+ FadeAfter time.Duration `kv:"fade.after,off" min:"1ns" max:"24h"`
21+ OffAfter time.Duration `kv:"screens.off-after,off" min:"1ns" max:"24h"`
22+ SleepAfter time.Duration `kv:"sleep.after,off" min:"1ns" max:"24h"`
23+ FadeDuration time.Duration `kv:"fade.duration" min:"0s" max:"24h"`
24+ Auth AuthConfig `kv:"auth"`
1825 // LockOutput names the only output (wl_output name, e.g. DP-2) that
1926 // shows the lock prompt while it is connected. Empty, or absent: every
2027 // output shows it. A change applies from the next lock.
21- LockOutput string
28+ LockOutput string `kv:"lock.output,nonempty"`
2229 }
2330
2431 // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output
@@ -43,9 +50,10 @@ const (
4350 // PIN mode always falls back to PAM when its selected source is unavailable or
4451 // invalid. A wrong PIN is authentication failure, never a fallback trigger.
4552 type AuthConfig struct {
46- Mode AuthMode
47- PINSource PINSource
48- PINEnv, PINEntry string
53+ Mode AuthMode `kv:"mode" oneof:"password|pin"`
54+ PINSource PINSource `kv:"pin-source" oneof:"env|pass"`
55+ PINEnv string `kv:"pin-env,nonempty"`
56+ PINEntry string `kv:"pin-entry,nonempty"`
4957 }
5058
5159 func Defaults() Config {
@@ -11,14 +11,21 @@ import (
11 const MaxDuration = 24 * time.Hour11 const MaxDuration = 24 * time.Hour
12 12
13 // Zero After disables only that action. No deadline implies another action.13 // Zero After disables only that action. No deadline implies another action.
14+//
15+// The kv tags describe the config file keys (decoded by adapters/config). A
16+// zero duration is written "off", except fade.duration where 0s is a value.
17+// max:"24h" mirrors MaxDuration (kept for the line number; Validate also checks).
14 type Config struct {18 type Config struct {
15- LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration19+ LockAfter time.Duration `kv:"lock.after,off" min:"1ns" max:"24h"`
16- FadeDuration time.Duration20+ FadeAfter time.Duration `kv:"fade.after,off" min:"1ns" max:"24h"`
17- Auth AuthConfig21+ OffAfter time.Duration `kv:"screens.off-after,off" min:"1ns" max:"24h"`
22+ SleepAfter time.Duration `kv:"sleep.after,off" min:"1ns" max:"24h"`
23+ FadeDuration time.Duration `kv:"fade.duration" min:"0s" max:"24h"`
24+ Auth AuthConfig `kv:"auth"`
18 // LockOutput names the only output (wl_output name, e.g. DP-2) that25 // LockOutput names the only output (wl_output name, e.g. DP-2) that
19 // shows the lock prompt while it is connected. Empty, or absent: every26 // shows the lock prompt while it is connected. Empty, or absent: every
20 // output shows it. A change applies from the next lock.27 // output shows it. A change applies from the next lock.
21- LockOutput string28+ LockOutput string `kv:"lock.output,nonempty"`
22 }29 }
23 30
24 // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output31 // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output
@@ -43,9 +50,10 @@ const (
43 // PIN mode always falls back to PAM when its selected source is unavailable or50 // PIN mode always falls back to PAM when its selected source is unavailable or
44 // invalid. A wrong PIN is authentication failure, never a fallback trigger.51 // invalid. A wrong PIN is authentication failure, never a fallback trigger.
45 type AuthConfig struct {52 type AuthConfig struct {
46- Mode AuthMode53+ Mode AuthMode `kv:"mode" oneof:"password|pin"`
47- PINSource PINSource54+ PINSource PINSource `kv:"pin-source" oneof:"env|pass"`
48- PINEnv, PINEntry string55+ PINEnv string `kv:"pin-env,nonempty"`
56+ PINEntry string `kv:"pin-entry,nonempty"`
49 }57 }
50 58
51 func Defaults() Config {59 func Defaults() Config {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close