detached 08cf307

Merge pull request 'refactor(config): parse and watch the config with kvconf' (#2) from refactor/kvconf into main

commit
08cf3076b2b729dbdc4106eb8c761492a59c4bad
author
bnema · · 2026-10-10 08:55 UTC
merge of
31f302d + 3421f16 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
7 files · +264 -231
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.2
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.2
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 go.sum +2 -0 generated file: collapsed
M internal/adapters/config/config.go +51 -71
@@ -2,15 +2,14 @@
22 package config
33
44 import (
5- "bufio"
5+ "errors"
66 "fmt"
77 "io"
88 "os"
99 "path/filepath"
1010 "strings"
11- "time"
12- "unicode/utf8"
1311
12+ "github.com/bnema/kvconf"
1413 "github.com/bnema/neferafk/internal/ports"
1514 )
1615
@@ -26,13 +25,16 @@ func DefaultPath() string {
2625 }
2726 return filepath.Join(base, "neferafk", "config")
2827 }
28+
2929 func Load(path string) (ports.Config, error) {
30- f, err := os.Open(path)
30+ data, err := kvconf.ReadFile(path, options)
3131 if err != nil {
32+ if errors.Is(err, kvconf.ErrTooLarge) {
33+ return ports.Config{}, errTooLarge
34+ }
3235 return ports.Config{}, err
3336 }
34- defer f.Close()
35- return Parse(f)
37+ return parse(data)
3638 }
3739
3840 // Parse never returns partial configuration and never resolves PIN references.
@@ -41,75 +43,53 @@ func Parse(r io.Reader) (ports.Config, error) {
4143 if err != nil {
4244 return ports.Config{}, err
4345 }
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")
46+ return parse(data)
47+}
48+
49+// options: values are never unquoted, and parse cuts each one at its first
50+// "#", even inside quotes. parse rejects the whole file on any problem.
51+var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine}
52+
53+var errTooLarge = errors.New("config exceeds 64KiB")
54+
55+// parse fails on the first problem in line order. Strict mode would report
56+// a line without "=" or a duplicate before an earlier bad value, so the file
57+// is decoded leniently and its problems compared; a syntax problem wins a tie.
58+func parse(data []byte) (ports.Config, error) {
59+ entries, syntax, err := kvconf.ParseBytes(data, options)
60+ if err != nil {
61+ return ports.Config{}, reject(err)
4962 }
50- 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
63+ for i := range entries {
64+ if v, _, cut := strings.Cut(entries[i].Value, "#"); cut {
65+ entries[i].Value = strings.TrimSpace(v)
10666 }
10767 }
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 {
68+ cfg := ports.Defaults()
69+ values, err := kvconf.Decode(entries, &cfg, options)
70+ switch {
71+ case len(syntax) > 0 && (len(values) == 0 || syntax[0].Line <= values[0].Line):
72+ return ports.Config{}, lineError(syntax[0])
73+ case len(values) > 0:
74+ return ports.Config{}, lineError(values[0])
75+ case err != nil:
11276 return ports.Config{}, err
11377 }
11478 return cfg, nil
11579 }
80+
81+func reject(err error) error {
82+ switch {
83+ case errors.Is(err, kvconf.ErrTooLarge):
84+ return errTooLarge
85+ case errors.Is(err, kvconf.ErrNotUTF8):
86+ return errors.New("config is not valid UTF-8")
87+ }
88+ return err
89+}
90+
91+// lineError carries a line number and a fixed message, never the key or
92+// value, which may be secret.
93+func lineError(w kvconf.Warning) error {
94+ return fmt.Errorf("line %d: %s", w.Line, w.Msg)
95+}
@@ -2,15 +2,14 @@
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"10 "strings"
11- "time"
12- "unicode/utf8"
13 11
12+ "github.com/bnema/kvconf"
14 "github.com/bnema/neferafk/internal/ports"13 "github.com/bnema/neferafk/internal/ports"
15 )14 )
16 15
@@ -26,13 +25,16 @@ func DefaultPath() string {
26 }25 }
27 return filepath.Join(base, "neferafk", "config")26 return filepath.Join(base, "neferafk", "config")
28 }27 }
28+
29 func Load(path string) (ports.Config, error) {29 func Load(path string) (ports.Config, error) {
30- f, err := os.Open(path)30+ data, err := kvconf.ReadFile(path, options)
31 if err != nil {31 if err != nil {
32+ if errors.Is(err, kvconf.ErrTooLarge) {
33+ return ports.Config{}, errTooLarge
34+ }
32 return ports.Config{}, err35 return ports.Config{}, err
33 }36 }
34- defer f.Close()37+ return parse(data)
35- return Parse(f)
36 }38 }
37 39
38 // Parse never returns partial configuration and never resolves PIN references.40 // Parse never returns partial configuration and never resolves PIN references.
@@ -41,75 +43,53 @@ func Parse(r io.Reader) (ports.Config, error) {
41 if err != nil {43 if err != nil {
42 return ports.Config{}, err44 return ports.Config{}, err
43 }45 }
44- if !utf8.Valid(data) {46+ return parse(data)
45- return ports.Config{}, fmt.Errorf("config is not valid UTF-8")47+}
46- }48+
47- if len(data) > MaxBytes {49+// options: values are never unquoted, and parse cuts each one at its first
48- return ports.Config{}, fmt.Errorf("config exceeds 64KiB")50+// "#", even inside quotes. parse rejects the whole file on any problem.
51+var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine}
52+
53+var errTooLarge = errors.New("config exceeds 64KiB")
54+
55+// parse fails on the first problem in line order. Strict mode would report
56+// a line without "=" or a duplicate before an earlier bad value, so the file
57+// is decoded leniently and its problems compared; a syntax problem wins a tie.
58+func parse(data []byte) (ports.Config, error) {
59+ entries, syntax, err := kvconf.ParseBytes(data, options)
60+ if err != nil {
61+ return ports.Config{}, reject(err)
49 }62 }
50- cfg := ports.Defaults()63+ for i := range entries {
51- seen := map[string]bool{}64+ if v, _, cut := strings.Cut(entries[i].Value, "#"); cut {
52- scanner := bufio.NewScanner(strings.NewReader(string(data)))65+ entries[i].Value = strings.TrimSpace(v)
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 }66 }
107 }67 }
108- if err := scanner.Err(); err != nil {68+ cfg := ports.Defaults()
109- return ports.Config{}, fmt.Errorf("config line exceeds bound")69+ values, err := kvconf.Decode(entries, &cfg, options)
110- }70+ switch {
111- if err := cfg.Validate(); err != nil {71+ case len(syntax) > 0 && (len(values) == 0 || syntax[0].Line <= values[0].Line):
72+ return ports.Config{}, lineError(syntax[0])
73+ case len(values) > 0:
74+ return ports.Config{}, lineError(values[0])
75+ case err != nil:
112 return ports.Config{}, err76 return ports.Config{}, err
113 }77 }
114 return cfg, nil78 return cfg, nil
115 }79 }
80+
81+func reject(err error) error {
82+ switch {
83+ case errors.Is(err, kvconf.ErrTooLarge):
84+ return errTooLarge
85+ case errors.Is(err, kvconf.ErrNotUTF8):
86+ return errors.New("config is not valid UTF-8")
87+ }
88+ return err
89+}
90+
91+// lineError carries a line number and a fixed message, never the key or
92+// value, which may be secret.
93+func lineError(w kvconf.Warning) error {
94+ return fmt.Errorf("line %d: %s", w.Line, w.Msg)
95+}
M internal/adapters/config/config_test.go +62 -0
@@ -72,3 +72,65 @@ func TestRejectInvalidUTF8BeforeParsingAndDirectMetadata(t *testing.T) {
7272 t.Fatal("direct invalid UTF-8 metadata admitted")
7373 }
7474 }
75+
76+func TestParseReportsFirstProblemInLineOrder(t *testing.T) {
77+ long := strings.Repeat("#", MaxLine+1)
78+ for _, tc := range []struct{ name, text, want string }{
79+ {"bad value before missing =", "lock.after=bad\nfoo", "line 1: "},
80+ {"bad value before duplicate", "lock.after=bad\nlock.after=1s", "line 1: "},
81+ {"unknown key before missing =", "unknown=x\nnoeq", "line 1: unknown key"},
82+ {"bad value before long line", "lock.after=bad\n" + long, "line 1: "},
83+ {"missing = before bad value", "noeq\nlock.after=bad", "line 1: expected key = value"},
84+ {"duplicate before later bad value", "lock.after=1s\nlock.after=2s\nsleep.after=bad", "line 2: overrides line 1"},
85+ {"duplicate wins tie with its bad value", "lock.after=1s\nlock.after=bad", "line 2: overrides line 1"},
86+ } {
87+ t.Run(tc.name, func(t *testing.T) {
88+ c, err := Parse(strings.NewReader(tc.text))
89+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) || c != (ports.Config{}) {
90+ t.Fatalf("got %v, %+v; want error starting %q", err, c, tc.want)
91+ }
92+ })
93+ }
94+}
95+
96+func TestParseHashStartsCommentEvenInQuotes(t *testing.T) {
97+ for _, tc := range []struct{ name, text, want string }{
98+ {"plain", "lock.output = DP-2 # left", "DP-2"},
99+ {"no space", "lock.output = DP-2#left", "DP-2"},
100+ {"inside double quotes", `lock.output = "DP#2"`, `"DP`},
101+ {"after unbalanced quote", `lock.output = DP-2 "# note`, `DP-2 "`},
102+ {"inside single quotes", "lock.output = 'DP#2'", "'DP"},
103+ } {
104+ t.Run(tc.name, func(t *testing.T) {
105+ c, err := Parse(strings.NewReader(tc.text))
106+ if err != nil || c.LockOutput != tc.want {
107+ t.Fatalf("got %q, %v; want %q", c.LockOutput, err, tc.want)
108+ }
109+ })
110+ }
111+ if _, err := Parse(strings.NewReader("lock.output = # only a comment")); err == nil {
112+ t.Fatal("value made empty by a comment accepted")
113+ }
114+}
115+
116+// The kv tags on ports.Config repeat MaxDuration and the auth constants.
117+func TestTagsMatchPortsConstants(t *testing.T) {
118+ max := ports.MaxDuration.String()
119+ for _, key := range []string{"lock.after", "fade.after", "screens.off-after", "sleep.after", "fade.duration"} {
120+ if _, err := Parse(strings.NewReader(key + "=" + max)); err != nil {
121+ t.Errorf("%s=%s rejected: %v", key, max, err)
122+ }
123+ if _, err := Parse(strings.NewReader(key + "=" + (ports.MaxDuration + 1).String())); err == nil {
124+ t.Errorf("%s above %s accepted", key, max)
125+ }
126+ }
127+ for _, text := range []string{
128+ "auth.mode=" + string(ports.AuthModePassword),
129+ "auth.mode=" + string(ports.AuthModePIN) + "\nauth.pin-source=" + string(ports.PINSourceEnv) + "\nauth.pin-env=PIN",
130+ "auth.pin-source=" + string(ports.PINSourcePass) + "\nauth.pin-entry=a/b",
131+ } {
132+ if _, err := Parse(strings.NewReader(text)); err != nil {
133+ t.Errorf("%q rejected: %v", text, err)
134+ }
135+ }
136+}
@@ -72,3 +72,65 @@ func TestRejectInvalidUTF8BeforeParsingAndDirectMetadata(t *testing.T) {
72 t.Fatal("direct invalid UTF-8 metadata admitted")72 t.Fatal("direct invalid UTF-8 metadata admitted")
73 }73 }
74 }74 }
75+
76+func TestParseReportsFirstProblemInLineOrder(t *testing.T) {
77+ long := strings.Repeat("#", MaxLine+1)
78+ for _, tc := range []struct{ name, text, want string }{
79+ {"bad value before missing =", "lock.after=bad\nfoo", "line 1: "},
80+ {"bad value before duplicate", "lock.after=bad\nlock.after=1s", "line 1: "},
81+ {"unknown key before missing =", "unknown=x\nnoeq", "line 1: unknown key"},
82+ {"bad value before long line", "lock.after=bad\n" + long, "line 1: "},
83+ {"missing = before bad value", "noeq\nlock.after=bad", "line 1: expected key = value"},
84+ {"duplicate before later bad value", "lock.after=1s\nlock.after=2s\nsleep.after=bad", "line 2: overrides line 1"},
85+ {"duplicate wins tie with its bad value", "lock.after=1s\nlock.after=bad", "line 2: overrides line 1"},
86+ } {
87+ t.Run(tc.name, func(t *testing.T) {
88+ c, err := Parse(strings.NewReader(tc.text))
89+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) || c != (ports.Config{}) {
90+ t.Fatalf("got %v, %+v; want error starting %q", err, c, tc.want)
91+ }
92+ })
93+ }
94+}
95+
96+func TestParseHashStartsCommentEvenInQuotes(t *testing.T) {
97+ for _, tc := range []struct{ name, text, want string }{
98+ {"plain", "lock.output = DP-2 # left", "DP-2"},
99+ {"no space", "lock.output = DP-2#left", "DP-2"},
100+ {"inside double quotes", `lock.output = "DP#2"`, `"DP`},
101+ {"after unbalanced quote", `lock.output = DP-2 "# note`, `DP-2 "`},
102+ {"inside single quotes", "lock.output = 'DP#2'", "'DP"},
103+ } {
104+ t.Run(tc.name, func(t *testing.T) {
105+ c, err := Parse(strings.NewReader(tc.text))
106+ if err != nil || c.LockOutput != tc.want {
107+ t.Fatalf("got %q, %v; want %q", c.LockOutput, err, tc.want)
108+ }
109+ })
110+ }
111+ if _, err := Parse(strings.NewReader("lock.output = # only a comment")); err == nil {
112+ t.Fatal("value made empty by a comment accepted")
113+ }
114+}
115+
116+// The kv tags on ports.Config repeat MaxDuration and the auth constants.
117+func TestTagsMatchPortsConstants(t *testing.T) {
118+ max := ports.MaxDuration.String()
119+ for _, key := range []string{"lock.after", "fade.after", "screens.off-after", "sleep.after", "fade.duration"} {
120+ if _, err := Parse(strings.NewReader(key + "=" + max)); err != nil {
121+ t.Errorf("%s=%s rejected: %v", key, max, err)
122+ }
123+ if _, err := Parse(strings.NewReader(key + "=" + (ports.MaxDuration + 1).String())); err == nil {
124+ t.Errorf("%s above %s accepted", key, max)
125+ }
126+ }
127+ for _, text := range []string{
128+ "auth.mode=" + string(ports.AuthModePassword),
129+ "auth.mode=" + string(ports.AuthModePIN) + "\nauth.pin-source=" + string(ports.PINSourceEnv) + "\nauth.pin-env=PIN",
130+ "auth.pin-source=" + string(ports.PINSourcePass) + "\nauth.pin-entry=a/b",
131+ } {
132+ if _, err := Parse(strings.NewReader(text)); err != nil {
133+ t.Errorf("%q rejected: %v", text, err)
134+ }
135+ }
136+}
M internal/adapters/config/watch.go +63 -153
@@ -2,113 +2,79 @@ package config
22
33 import (
44 "context"
5- "encoding/binary"
65 "errors"
7- "os"
8- "path/filepath"
9- "strings"
6+ "io/fs"
107 "time"
118
9+ "github.com/bnema/kvconf"
1210 "github.com/bnema/neferafk/internal/ports"
13- "golang.org/x/sys/unix"
1411 )
1512
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-}
13+// fallbackPoll is the polling interval used when native watching fails.
14+var fallbackPoll = 2 * time.Second
4215
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-}
16+// kvWatch is kvconf.Watch; tests replace it to make native watching fail.
17+var kvWatch = kvconf.Watch
6018
6119 // 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.
20+// config symlink targets (kvconf.Watch). It always emits the first valid
21+// candidate, including a candidate equal to defaults: this closes an app
22+// Load -> Watch startup gap. Ready closes after initial watch installation,
23+// before candidate delivery. No invalid/deleted candidate emits a defaults
24+// reset. Errors never echo file content or paths, which may themselves
25+// contain sensitive source metadata. When the OS refuses to watch (inotify
26+// limits, permissions), Watch reports it on failures and polls instead: a
27+// watch error must never stop the daemon and with it idle locking.
6728 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
29+ ctx, cancel := context.WithCancel(ctx)
30+ changed := make(chan struct{}, 1)
31+ installed := make(chan struct{})
32+ done := make(chan struct{})
33+ var watchErr error
34+ go func() {
35+ defer close(done)
36+ err := kvWatch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed)
37+ if err == nil || ctx.Err() != nil {
38+ return
9439 }
95- for wd := range names {
96- if want[wd] == nil {
97- _, _ = unix.InotifyRmWatch(fd, uint32(wd))
40+ if failures != nil {
41+ select {
42+ case failures <- errors.New("config watch failed; polling"):
43+ default:
9844 }
9945 }
100- names = want
46+ opts := kvconf.WatchOptions{Poll: fallbackPoll}
47+ select {
48+ case <-installed: // closed by the native watch before it failed
49+ default:
50+ opts.Ready = installed
51+ }
52+ // Changes made while nothing watched are seen by this reload.
53+ select {
54+ case changed <- struct{}{}:
55+ default:
56+ }
57+ watchErr = kvWatch(ctx, path, opts, changed)
58+ }()
59+ defer func() { cancel(); <-done }()
60+
61+ select {
62+ case <-installed:
63+ case <-done:
64+ if watchErr != nil {
65+ return errors.New("initialize config watch")
66+ }
67+ return nil
10168 }
102- rewatch()
10369 if ready != nil {
10470 close(ready)
10571 }
10672 var last ports.Config
10773 haveLast := false
10874 publish := func() bool {
109- cfg, e := Load(path)
110- if e != nil {
111- if !os.IsNotExist(e) && failures != nil {
75+ cfg, err := Load(path)
76+ if err != nil {
77+ if !errors.Is(err, fs.ErrNotExist) && failures != nil {
11278 select {
11379 case failures <- errors.New("config reload rejected"):
11480 default:
@@ -127,73 +93,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai
12793 return false
12894 }
12995 }
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- }
96+ for ok := publish(); ok; {
97+ select {
98+ case <-ctx.Done():
99+ return nil
100+ case <-done:
101+ if watchErr != nil {
102+ return errors.New("config watch")
195103 }
196- offset += size
104+ return nil
105+ case <-changed:
106+ ok = publish()
197107 }
198108 }
199109 return nil
@@ -2,113 +2,79 @@ 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"7 "time"
11 8
9+ "github.com/bnema/kvconf"
12 "github.com/bnema/neferafk/internal/ports"10 "github.com/bnema/neferafk/internal/ports"
13- "golang.org/x/sys/unix"
14 )11 )
15 12
16-func watchedFiles(path string) []string {13+// fallbackPoll is the polling interval used when native watching fails.
17- files := []string{filepath.Clean(path)}14+var fallbackPoll = 2 * time.Second
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 15
43-// watchDirectory finds the nearest existing ancestor. The missing component's16+// kvWatch is kvconf.Watch; tests replace it to make native watching fail.
44-// creation wakes inotify; the two-second retry also covers lost/overflow events.17+var kvWatch = kvconf.Watch
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 18
61 // Watch retains last-valid state across missing files/directories and follows19 // Watch retains last-valid state across missing files/directories and follows
62-// config symlink targets. It always emits the first valid candidate, including20+// 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.21+// candidate, including a candidate equal to defaults: this closes an app
64-// Ready closes after initial watch installation, before candidate delivery.22+// Load -> Watch startup gap. Ready closes after initial watch installation,
65-// No invalid/deleted candidate emits a defaults reset. Errors never echo file23+// before candidate delivery. No invalid/deleted candidate emits a defaults
66-// content or paths, which may themselves contain sensitive source metadata.24+// reset. Errors never echo file content or paths, which may themselves
25+// contain sensitive source metadata. When the OS refuses to watch (inotify
26+// limits, permissions), Watch reports it on failures and polls instead: a
27+// watch error must never stop the daemon and with it idle locking.
67 func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error {28 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)29+ ctx, cancel := context.WithCancel(ctx)
69- if err != nil {30+ changed := make(chan struct{}, 1)
70- return errors.New("initialize config watch")31+ installed := make(chan struct{})
71- }32+ done := make(chan struct{})
72- defer unix.Close(fd)33+ 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_SELF34+ go func() {
74- names := map[int32]map[string]bool{}35+ defer close(done)
75- retry := time.Time{}36+ err := kvWatch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed)
76- rewatch := func() {37+ if err == nil || ctx.Err() != nil {
77- // Keep unchanged watches, avoiding IN_IGNORED from our own removals.38+ return
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 }39 }
95- for wd := range names {40+ if failures != nil {
96- if want[wd] == nil {41+ select {
97- _, _ = unix.InotifyRmWatch(fd, uint32(wd))42+ case failures <- errors.New("config watch failed; polling"):
43+ default:
98 }44 }
99 }45 }
100- names = want46+ opts := kvconf.WatchOptions{Poll: fallbackPoll}
47+ select {
48+ case <-installed: // closed by the native watch before it failed
49+ default:
50+ opts.Ready = installed
51+ }
52+ // Changes made while nothing watched are seen by this reload.
53+ select {
54+ case changed <- struct{}{}:
55+ default:
56+ }
57+ watchErr = kvWatch(ctx, path, opts, changed)
58+ }()
59+ defer func() { cancel(); <-done }()
60+
61+ select {
62+ case <-installed:
63+ case <-done:
64+ if watchErr != nil {
65+ return errors.New("initialize config watch")
66+ }
67+ return nil
101 }68 }
102- rewatch()
103 if ready != nil {69 if ready != nil {
104 close(ready)70 close(ready)
105 }71 }
106 var last ports.Config72 var last ports.Config
107 haveLast := false73 haveLast := false
108 publish := func() bool {74 publish := func() bool {
109- cfg, e := Load(path)75+ cfg, err := Load(path)
110- if e != nil {76+ if err != nil {
111- if !os.IsNotExist(e) && failures != nil {77+ if !errors.Is(err, fs.ErrNotExist) && failures != nil {
112 select {78 select {
113 case failures <- errors.New("config reload rejected"):79 case failures <- errors.New("config reload rejected"):
114 default:80 default:
@@ -127,73 +93,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai
127 return false93 return false
128 }94 }
129 }95 }
130- if !publish() {96+ for ok := publish(); ok; {
131- return nil97+ select {
132- }98+ case <-ctx.Done():
133- pending := time.Time{}99+ return nil
134- buf := make([]byte, 8192)100+ case <-done:
135- for ctx.Err() == nil {101+ if watchErr != nil {
136- now := time.Now()102+ 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 }103 }
196- offset += size104+ return nil
105+ case <-changed:
106+ ok = publish()
197 }107 }
198 }108 }
199 return nil109 return nil
M internal/adapters/config/watch_test.go +70 -0
@@ -2,6 +2,8 @@ package config
22
33 import (
44 "context"
5+ "errors"
6+ "github.com/bnema/kvconf"
57 "github.com/bnema/neferafk/internal/ports"
68 "os"
79 "path/filepath"
@@ -197,3 +199,71 @@ func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testin
197199 t.Fatal(err)
198200 }
199201 }
202+
203+// A watch the OS refuses must fall back to polling, never stop the daemon.
204+func TestWatchFallsBackToPolling(t *testing.T) {
205+ orig, origPoll := kvWatch, fallbackPoll
206+ t.Cleanup(func() { kvWatch, fallbackPoll = orig, origPoll })
207+ fallbackPoll = 20 * time.Millisecond
208+ for _, tc := range []struct {
209+ name string
210+ setup func(t *testing.T, dir string)
211+ }{
212+ {"native watch fails at startup", func(t *testing.T, _ string) {
213+ kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
214+ if opts.Poll == 0 {
215+ return errors.New("no space left on device")
216+ }
217+ return orig(ctx, path, opts, out)
218+ }
219+ }},
220+ {"native watch fails after install", func(t *testing.T, _ string) {
221+ kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
222+ if opts.Poll == 0 {
223+ close(opts.Ready)
224+ return errors.New("no space left on device")
225+ }
226+ return orig(ctx, path, opts, out)
227+ }
228+ }},
229+ {"directory not readable", func(t *testing.T, dir string) {
230+ if os.Geteuid() == 0 {
231+ t.Skip("root ignores directory permissions")
232+ }
233+ kvWatch = orig
234+ if err := os.Chmod(dir, 0300); err != nil {
235+ t.Fatal(err)
236+ }
237+ t.Cleanup(func() { os.Chmod(dir, 0700) })
238+ }},
239+ } {
240+ t.Run(tc.name, func(t *testing.T) {
241+ dir := t.TempDir()
242+ path := filepath.Join(dir, "config")
243+ save(t, path, "lock.after=1m\n")
244+ tc.setup(t, dir)
245+ out := make(chan ports.ConfigChanged, 4)
246+ errs := make(chan error, 4)
247+ ready := make(chan struct{})
248+ ctx, cancel := context.WithCancel(context.Background())
249+ defer cancel()
250+ done := make(chan error, 1)
251+ go func() { done <- Watch(ctx, path, out, errs, ready) }()
252+ recv(t, ready)
253+ if err := recv(t, errs); err.Error() != "config watch failed; polling" {
254+ t.Fatal(err)
255+ }
256+ if got := recv(t, out); got.Config.LockAfter != time.Minute {
257+ t.Fatal(got)
258+ }
259+ save(t, path, "lock.after=2m\n")
260+ if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
261+ t.Fatal(got)
262+ }
263+ cancel()
264+ if err := recv(t, done); err != nil {
265+ t.Fatal(err)
266+ }
267+ })
268+ }
269+}
@@ -2,6 +2,8 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "github.com/bnema/kvconf"
5 "github.com/bnema/neferafk/internal/ports"7 "github.com/bnema/neferafk/internal/ports"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
@@ -197,3 +199,71 @@ func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testin
197 t.Fatal(err)199 t.Fatal(err)
198 }200 }
199 }201 }
202+
203+// A watch the OS refuses must fall back to polling, never stop the daemon.
204+func TestWatchFallsBackToPolling(t *testing.T) {
205+ orig, origPoll := kvWatch, fallbackPoll
206+ t.Cleanup(func() { kvWatch, fallbackPoll = orig, origPoll })
207+ fallbackPoll = 20 * time.Millisecond
208+ for _, tc := range []struct {
209+ name string
210+ setup func(t *testing.T, dir string)
211+ }{
212+ {"native watch fails at startup", func(t *testing.T, _ string) {
213+ kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
214+ if opts.Poll == 0 {
215+ return errors.New("no space left on device")
216+ }
217+ return orig(ctx, path, opts, out)
218+ }
219+ }},
220+ {"native watch fails after install", func(t *testing.T, _ string) {
221+ kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
222+ if opts.Poll == 0 {
223+ close(opts.Ready)
224+ return errors.New("no space left on device")
225+ }
226+ return orig(ctx, path, opts, out)
227+ }
228+ }},
229+ {"directory not readable", func(t *testing.T, dir string) {
230+ if os.Geteuid() == 0 {
231+ t.Skip("root ignores directory permissions")
232+ }
233+ kvWatch = orig
234+ if err := os.Chmod(dir, 0300); err != nil {
235+ t.Fatal(err)
236+ }
237+ t.Cleanup(func() { os.Chmod(dir, 0700) })
238+ }},
239+ } {
240+ t.Run(tc.name, func(t *testing.T) {
241+ dir := t.TempDir()
242+ path := filepath.Join(dir, "config")
243+ save(t, path, "lock.after=1m\n")
244+ tc.setup(t, dir)
245+ out := make(chan ports.ConfigChanged, 4)
246+ errs := make(chan error, 4)
247+ ready := make(chan struct{})
248+ ctx, cancel := context.WithCancel(context.Background())
249+ defer cancel()
250+ done := make(chan error, 1)
251+ go func() { done <- Watch(ctx, path, out, errs, ready) }()
252+ recv(t, ready)
253+ if err := recv(t, errs); err.Error() != "config watch failed; polling" {
254+ t.Fatal(err)
255+ }
256+ if got := recv(t, out); got.Config.LockAfter != time.Minute {
257+ t.Fatal(got)
258+ }
259+ save(t, path, "lock.after=2m\n")
260+ if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
261+ t.Fatal(got)
262+ }
263+ cancel()
264+ if err := recv(t, done); err != nil {
265+ t.Fatal(err)
266+ }
267+ })
268+ }
269+}
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