feat(app): wire xkb keyboard and headless script input
- commit
- 6ac92fb60ed1b27484f156dd04f43b581cce49c9
- author
- brice · · 2026-09-23 15:47 UTC
- parent
- 18a2776
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +301 -4
M README.md +2 -0
| @@ -27,3 +27,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until | |||
| 27 | 27 | ||
| 28 | 28 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | |
| 29 | 29 | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | |
| 30 | + | Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is | |
| 31 | + | `type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`. | |
| @@ -27,3 +27,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until | |||||
| 27 | 27 | ||||
| 28 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | 28 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | ||
| 29 | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | 29 | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | ||
| 30 | + | Use `--input path` (or `--input -` for stdin) to inject a headless script. Each line is | |||
| 31 | + | `type text`, `key Super+Return` (also Shift, Ctrl, Alt or a bare key), or `sleep 1s`. | |||
M cmd/nefertty/main.go +20 -1
| @@ -79,6 +79,7 @@ func run() error { | |||
| 79 | 79 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| 80 | 80 | backend := flags.String("backend", "drm", "drm or headless") | |
| 81 | 81 | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |
| 82 | + | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 82 | 83 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 83 | 84 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 84 | 85 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |
| @@ -99,6 +100,11 @@ func run() error { | |||
| 99 | 100 | fmt.Fprintln(os.Stderr, err) | |
| 100 | 101 | return err | |
| 101 | 102 | } | |
| 103 | + | if *inputPath != "" && *backend != "headless" { | |
| 104 | + | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |
| 105 | + | fmt.Fprintln(os.Stderr, err) | |
| 106 | + | return err | |
| 107 | + | } | |
| 102 | 108 | if *timeout < 0 { | |
| 103 | 109 | err := usageError{fmt.Errorf("negative timeout")} | |
| 104 | 110 | fmt.Fprintln(os.Stderr, err) | |
| @@ -128,6 +134,19 @@ func run() error { | |||
| 128 | 134 | fmt.Fprintln(os.Stderr, cfgErr) | |
| 129 | 135 | return cfgErr | |
| 130 | 136 | } | |
| 137 | + | var script *os.File | |
| 138 | + | if *inputPath != "" { | |
| 139 | + | if *inputPath == "-" { | |
| 140 | + | script = os.Stdin | |
| 141 | + | } else { | |
| 142 | + | var err error | |
| 143 | + | script, err = os.Open(*inputPath) | |
| 144 | + | if err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | defer script.Close() | |
| 148 | + | } | |
| 149 | + | } | |
| 131 | 150 | selected := mergeDebug(cfg.Log.Debug, *debugFlag) | |
| 132 | 151 | if _, err := logging.ParseDebug(selected); err != nil { | |
| 133 | 152 | fmt.Fprintln(os.Stderr, err) | |
| @@ -144,7 +163,7 @@ func run() error { | |||
| 144 | 163 | log := logging.For(ctx, "app") | |
| 145 | 164 | configLog := logging.For(ctx, "config") | |
| 146 | 165 | configLog.Info().Str("path", path).Msg("loaded config") | |
| 147 | - | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil { | |
| 166 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil { | |
| 148 | 167 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 149 | 168 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 150 | 169 | return nil | |
| @@ -79,6 +79,7 @@ func run() error { | |||||
| 79 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | 79 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | ||
| 80 | backend := flags.String("backend", "drm", "drm or headless") | 80 | backend := flags.String("backend", "drm", "drm or headless") | ||
| 81 | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | 81 | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | ||
| 82 | + | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |||
| 82 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | 83 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | ||
| 83 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | 84 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | ||
| 84 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | 85 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | ||
| @@ -99,6 +100,11 @@ func run() error { | |||||
| 99 | fmt.Fprintln(os.Stderr, err) | 100 | fmt.Fprintln(os.Stderr, err) | ||
| 100 | return err | 101 | return err | ||
| 101 | } | 102 | } | ||
| 103 | + | if *inputPath != "" && *backend != "headless" { | |||
| 104 | + | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |||
| 105 | + | fmt.Fprintln(os.Stderr, err) | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 102 | if *timeout < 0 { | 108 | if *timeout < 0 { | ||
| 103 | err := usageError{fmt.Errorf("negative timeout")} | 109 | err := usageError{fmt.Errorf("negative timeout")} | ||
| 104 | fmt.Fprintln(os.Stderr, err) | 110 | fmt.Fprintln(os.Stderr, err) | ||
| @@ -128,6 +134,19 @@ func run() error { | |||||
| 128 | fmt.Fprintln(os.Stderr, cfgErr) | 134 | fmt.Fprintln(os.Stderr, cfgErr) | ||
| 129 | return cfgErr | 135 | return cfgErr | ||
| 130 | } | 136 | } | ||
| 137 | + | var script *os.File | |||
| 138 | + | if *inputPath != "" { | |||
| 139 | + | if *inputPath == "-" { | |||
| 140 | + | script = os.Stdin | |||
| 141 | + | } else { | |||
| 142 | + | var err error | |||
| 143 | + | script, err = os.Open(*inputPath) | |||
| 144 | + | if err != nil { | |||
| 145 | + | return err | |||
| 146 | + | } | |||
| 147 | + | defer script.Close() | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 131 | selected := mergeDebug(cfg.Log.Debug, *debugFlag) | 150 | selected := mergeDebug(cfg.Log.Debug, *debugFlag) | ||
| 132 | if _, err := logging.ParseDebug(selected); err != nil { | 151 | if _, err := logging.ParseDebug(selected); err != nil { | ||
| 133 | fmt.Fprintln(os.Stderr, err) | 152 | fmt.Fprintln(os.Stderr, err) | ||
| @@ -144,7 +163,7 @@ func run() error { | |||||
| 144 | log := logging.For(ctx, "app") | 163 | log := logging.For(ctx, "app") | ||
| 145 | configLog := logging.For(ctx, "config") | 164 | configLog := logging.For(ctx, "config") | ||
| 146 | configLog.Info().Str("path", path).Msg("loaded config") | 165 | configLog.Info().Str("path", path).Msg("loaded config") | ||
| 147 | - | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil { | 166 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Script: script}); err != nil { |
| 148 | // SIGINT and SIGTERM cancel the context and are clean exits. | 167 | // SIGINT and SIGTERM cancel the context and are clean exits. | ||
| 149 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | 168 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | ||
| 150 | return nil | 169 | return nil | ||
A internal/adapters/headlessinput/input.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | // Package headlessinput converts scripted text into keyboard events. | |
| 2 | + | package headlessinput | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | "unicode" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| 11 | + | "github.com/bnema/nefertty/internal/ports" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Run owns the keymap and converts script lines into input events. | |
| 16 | + | func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error { | |
| 17 | + | defer km.Close() | |
| 18 | + | emit := func(code uint32, down bool) error { | |
| 19 | + | ev := km.Key(code, down, uint32(time.Now().UnixMilli())) | |
| 20 | + | select { | |
| 21 | + | case <-ctx.Done(): | |
| 22 | + | return ctx.Err() | |
| 23 | + | case input <- ev: | |
| 24 | + | } | |
| 25 | + | timer := time.NewTimer(2 * time.Millisecond) | |
| 26 | + | defer timer.Stop() | |
| 27 | + | select { | |
| 28 | + | case <-ctx.Done(): | |
| 29 | + | return ctx.Err() | |
| 30 | + | case <-timer.C: | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | stroke := func(name string, mods []uint32) error { | |
| 35 | + | code, shift, ok := km.KeycodeFor(name) | |
| 36 | + | if !ok { | |
| 37 | + | log.Warn().Str("key", name).Msg("unknown key") | |
| 38 | + | return nil | |
| 39 | + | } | |
| 40 | + | if shift { | |
| 41 | + | mods = append(mods, 42) | |
| 42 | + | } | |
| 43 | + | for _, m := range mods { | |
| 44 | + | if err := emit(m, true); err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if err := emit(code, true); err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | if err := emit(code, false); err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | for i := len(mods) - 1; i >= 0; i-- { | |
| 55 | + | if err := emit(mods[i], false); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | punctuation := map[rune]string{' ': "space", '\n': "Return", '.': "period", ',': "comma", '-': "minus", '_': "underscore", '/': "slash", ':': "colon", ';': "semicolon", '!': "exclam", '?': "question", '\'': "apostrophe", '"': "quotedbl", '(': "parenleft", ')': "parenright", '=': "equal", '+': "plus", '\\': "backslash", '@': "at", '#': "numbersign", '$': "dollar", '%': "percent", '&': "ampersand", '*': "asterisk", '[': "bracketleft", ']': "bracketright", '{': "braceleft", '}': "braceright", '<': "less", '>': "greater", '|': "bar", '`': "grave", '~': "asciitilde", '^': "asciicircum"} | |
| 62 | + | for { | |
| 63 | + | select { | |
| 64 | + | case <-ctx.Done(): | |
| 65 | + | return ctx.Err() | |
| 66 | + | case line, ok := <-script: | |
| 67 | + | if !ok { | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | switch { | |
| 71 | + | case strings.HasPrefix(line, "type "): | |
| 72 | + | for _, r := range strings.TrimPrefix(line, "type ") { | |
| 73 | + | name := punctuation[r] | |
| 74 | + | if name == "" && (unicode.IsLetter(r) || unicode.IsDigit(r)) { | |
| 75 | + | name = string(r) | |
| 76 | + | } | |
| 77 | + | if name == "" { | |
| 78 | + | log.Warn().Str("rune", string(r)).Msg("unknown rune") | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | if err := stroke(name, nil); err != nil { | |
| 82 | + | return err | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | case strings.HasPrefix(line, "key "): | |
| 86 | + | parts := strings.Split(strings.TrimPrefix(line, "key "), "+") | |
| 87 | + | var mods []uint32 | |
| 88 | + | valid := true | |
| 89 | + | for _, part := range parts[:len(parts)-1] { | |
| 90 | + | switch part { | |
| 91 | + | case "Super": | |
| 92 | + | mods = append(mods, 125) | |
| 93 | + | case "Shift": | |
| 94 | + | mods = append(mods, 42) | |
| 95 | + | case "Ctrl": | |
| 96 | + | mods = append(mods, 29) | |
| 97 | + | case "Alt": | |
| 98 | + | mods = append(mods, 56) | |
| 99 | + | default: | |
| 100 | + | valid = false | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if !valid { | |
| 104 | + | log.Warn().Str("combo", line).Msg("unknown modifier") | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | if err := stroke(parts[len(parts)-1], mods); err != nil { | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | case strings.HasPrefix(line, "sleep "): | |
| 111 | + | d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep ")) | |
| 112 | + | if err != nil || d < 0 { | |
| 113 | + | log.Warn().Str("line", line).Msg("invalid sleep") | |
| 114 | + | continue | |
| 115 | + | } | |
| 116 | + | timer := time.NewTimer(d) | |
| 117 | + | select { | |
| 118 | + | case <-ctx.Done(): | |
| 119 | + | timer.Stop() | |
| 120 | + | return ctx.Err() | |
| 121 | + | case <-timer.C: | |
| 122 | + | } | |
| 123 | + | default: | |
| 124 | + | log.Warn().Str("line", line).Msg("unknown script line") | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | // Package headlessinput converts scripted text into keyboard events. | |||
| 2 | + | package headlessinput | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | "unicode" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |||
| 11 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Run owns the keymap and converts script lines into input events. | |||
| 16 | + | func Run(ctx context.Context, km *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, log zerowrap.Logger) error { | |||
| 17 | + | defer km.Close() | |||
| 18 | + | emit := func(code uint32, down bool) error { | |||
| 19 | + | ev := km.Key(code, down, uint32(time.Now().UnixMilli())) | |||
| 20 | + | select { | |||
| 21 | + | case <-ctx.Done(): | |||
| 22 | + | return ctx.Err() | |||
| 23 | + | case input <- ev: | |||
| 24 | + | } | |||
| 25 | + | timer := time.NewTimer(2 * time.Millisecond) | |||
| 26 | + | defer timer.Stop() | |||
| 27 | + | select { | |||
| 28 | + | case <-ctx.Done(): | |||
| 29 | + | return ctx.Err() | |||
| 30 | + | case <-timer.C: | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | stroke := func(name string, mods []uint32) error { | |||
| 35 | + | code, shift, ok := km.KeycodeFor(name) | |||
| 36 | + | if !ok { | |||
| 37 | + | log.Warn().Str("key", name).Msg("unknown key") | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | if shift { | |||
| 41 | + | mods = append(mods, 42) | |||
| 42 | + | } | |||
| 43 | + | for _, m := range mods { | |||
| 44 | + | if err := emit(m, true); err != nil { | |||
| 45 | + | return err | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | if err := emit(code, true); err != nil { | |||
| 49 | + | return err | |||
| 50 | + | } | |||
| 51 | + | if err := emit(code, false); err != nil { | |||
| 52 | + | return err | |||
| 53 | + | } | |||
| 54 | + | for i := len(mods) - 1; i >= 0; i-- { | |||
| 55 | + | if err := emit(mods[i], false); err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | punctuation := map[rune]string{' ': "space", '\n': "Return", '.': "period", ',': "comma", '-': "minus", '_': "underscore", '/': "slash", ':': "colon", ';': "semicolon", '!': "exclam", '?': "question", '\'': "apostrophe", '"': "quotedbl", '(': "parenleft", ')': "parenright", '=': "equal", '+': "plus", '\\': "backslash", '@': "at", '#': "numbersign", '$': "dollar", '%': "percent", '&': "ampersand", '*': "asterisk", '[': "bracketleft", ']': "bracketright", '{': "braceleft", '}': "braceright", '<': "less", '>': "greater", '|': "bar", '`': "grave", '~': "asciitilde", '^': "asciicircum"} | |||
| 62 | + | for { | |||
| 63 | + | select { | |||
| 64 | + | case <-ctx.Done(): | |||
| 65 | + | return ctx.Err() | |||
| 66 | + | case line, ok := <-script: | |||
| 67 | + | if !ok { | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | switch { | |||
| 71 | + | case strings.HasPrefix(line, "type "): | |||
| 72 | + | for _, r := range strings.TrimPrefix(line, "type ") { | |||
| 73 | + | name := punctuation[r] | |||
| 74 | + | if name == "" && (unicode.IsLetter(r) || unicode.IsDigit(r)) { | |||
| 75 | + | name = string(r) | |||
| 76 | + | } | |||
| 77 | + | if name == "" { | |||
| 78 | + | log.Warn().Str("rune", string(r)).Msg("unknown rune") | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | if err := stroke(name, nil); err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | case strings.HasPrefix(line, "key "): | |||
| 86 | + | parts := strings.Split(strings.TrimPrefix(line, "key "), "+") | |||
| 87 | + | var mods []uint32 | |||
| 88 | + | valid := true | |||
| 89 | + | for _, part := range parts[:len(parts)-1] { | |||
| 90 | + | switch part { | |||
| 91 | + | case "Super": | |||
| 92 | + | mods = append(mods, 125) | |||
| 93 | + | case "Shift": | |||
| 94 | + | mods = append(mods, 42) | |||
| 95 | + | case "Ctrl": | |||
| 96 | + | mods = append(mods, 29) | |||
| 97 | + | case "Alt": | |||
| 98 | + | mods = append(mods, 56) | |||
| 99 | + | default: | |||
| 100 | + | valid = false | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if !valid { | |||
| 104 | + | log.Warn().Str("combo", line).Msg("unknown modifier") | |||
| 105 | + | continue | |||
| 106 | + | } | |||
| 107 | + | if err := stroke(parts[len(parts)-1], mods); err != nil { | |||
| 108 | + | return err | |||
| 109 | + | } | |||
| 110 | + | case strings.HasPrefix(line, "sleep "): | |||
| 111 | + | d, err := time.ParseDuration(strings.TrimPrefix(line, "sleep ")) | |||
| 112 | + | if err != nil || d < 0 { | |||
| 113 | + | log.Warn().Str("line", line).Msg("invalid sleep") | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | timer := time.NewTimer(d) | |||
| 117 | + | select { | |||
| 118 | + | case <-ctx.Done(): | |||
| 119 | + | timer.Stop() | |||
| 120 | + | return ctx.Err() | |||
| 121 | + | case <-timer.C: | |||
| 122 | + | } | |||
| 123 | + | default: | |||
| 124 | + | log.Warn().Str("line", line).Msg("unknown script line") | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | } | |||
A internal/adapters/headlessinput/input_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package headlessinput | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| 6 | + | "github.com/bnema/nefertty/internal/logging" | |
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestScript(t *testing.T) { | |
| 13 | + | for _, tt := range []struct { | |
| 14 | + | line string | |
| 15 | + | names []string | |
| 16 | + | codes []uint32 | |
| 17 | + | }{ | |
| 18 | + | {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil}, | |
| 19 | + | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | |
| 20 | + | } { | |
| 21 | + | t.Run(tt.line, func(t *testing.T) { | |
| 22 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |
| 23 | + | if err != nil { | |
| 24 | + | if strings.Contains(err.Error(), "libxkbcommon.so.0") { | |
| 25 | + | t.Skip(err) | |
| 26 | + | } | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | script := make(chan string, 1) | |
| 30 | + | script <- tt.line | |
| 31 | + | close(script) | |
| 32 | + | input := make(chan ports.InputEvent, 32) | |
| 33 | + | if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if len(input) != len(tt.names) { | |
| 37 | + | t.Fatalf("events=%d want %d", len(input), len(tt.names)) | |
| 38 | + | } | |
| 39 | + | for i, name := range tt.names { | |
| 40 | + | ev := (<-input).(ports.KeyEvent) | |
| 41 | + | if ev.Keysym != name { | |
| 42 | + | t.Errorf("event %d: %s want %s", i, ev.Keysym, name) | |
| 43 | + | } | |
| 44 | + | if tt.codes != nil && ev.Keycode != tt.codes[i] { | |
| 45 | + | t.Errorf("code %d: %d", i, ev.Keycode) | |
| 46 | + | } | |
| 47 | + | if tt.codes != nil && i == 1 && ev.Mods&ports.ModSuper == 0 { | |
| 48 | + | t.Error("Return missing Super") | |
| 49 | + | } | |
| 50 | + | if tt.codes != nil && i == 3 && ev.Mods&ports.ModSuper != 0 { | |
| 51 | + | t.Error("Super not released") | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package headlessinput | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |||
| 6 | + | "github.com/bnema/nefertty/internal/logging" | |||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestScript(t *testing.T) { | |||
| 13 | + | for _, tt := range []struct { | |||
| 14 | + | line string | |||
| 15 | + | names []string | |||
| 16 | + | codes []uint32 | |||
| 17 | + | }{ | |||
| 18 | + | {"type Hi!", []string{"Shift_L", "H", "H", "Shift_L", "i", "i", "Shift_L", "exclam", "exclam", "Shift_L"}, nil}, | |||
| 19 | + | {"key Super+Return", []string{"Super_L", "Return", "Return", "Super_L"}, []uint32{125, 28, 28, 125}}, | |||
| 20 | + | } { | |||
| 21 | + | t.Run(tt.line, func(t *testing.T) { | |||
| 22 | + | km, err := xkb.New(xkb.RMLVO{Layout: "us"}) | |||
| 23 | + | if err != nil { | |||
| 24 | + | if strings.Contains(err.Error(), "libxkbcommon.so.0") { | |||
| 25 | + | t.Skip(err) | |||
| 26 | + | } | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | script := make(chan string, 1) | |||
| 30 | + | script <- tt.line | |||
| 31 | + | close(script) | |||
| 32 | + | input := make(chan ports.InputEvent, 32) | |||
| 33 | + | if err := Run(context.Background(), km, script, input, logging.For(context.Background(), "input")); err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if len(input) != len(tt.names) { | |||
| 37 | + | t.Fatalf("events=%d want %d", len(input), len(tt.names)) | |||
| 38 | + | } | |||
| 39 | + | for i, name := range tt.names { | |||
| 40 | + | ev := (<-input).(ports.KeyEvent) | |||
| 41 | + | if ev.Keysym != name { | |||
| 42 | + | t.Errorf("event %d: %s want %s", i, ev.Keysym, name) | |||
| 43 | + | } | |||
| 44 | + | if tt.codes != nil && ev.Keycode != tt.codes[i] { | |||
| 45 | + | t.Errorf("code %d: %d", i, ev.Keycode) | |||
| 46 | + | } | |||
| 47 | + | if tt.codes != nil && i == 1 && ev.Mods&ports.ModSuper == 0 { | |||
| 48 | + | t.Error("Return missing Super") | |||
| 49 | + | } | |||
| 50 | + | if tt.codes != nil && i == 3 && ev.Mods&ports.ModSuper != 0 { | |||
| 51 | + | t.Error("Super not released") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/app/app.go +39 -3
| @@ -2,17 +2,21 @@ | |||
| 2 | 2 | package app | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "bufio" | |
| 5 | 6 | "context" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| 9 | + | "io" | |
| 8 | 10 | "os" | |
| 9 | 11 | "sync" | |
| 10 | 12 | "time" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/nefertty/internal/adapters/headless" | |
| 15 | + | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |
| 13 | 16 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 14 | 17 | "github.com/bnema/nefertty/internal/adapters/vulkan" | |
| 15 | 18 | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 19 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| 16 | 20 | "github.com/bnema/nefertty/internal/core" | |
| 17 | 21 | "github.com/bnema/nefertty/internal/logging" | |
| 18 | 22 | "github.com/bnema/nefertty/internal/ports" | |
| @@ -24,6 +28,7 @@ type Options struct { | |||
| 24 | 28 | Timeout time.Duration | |
| 25 | 29 | NoTerminal bool | |
| 26 | 30 | ScreenshotDir string | |
| 31 | + | Script io.Reader | |
| 27 | 32 | testScenes chan<- ports.Scene | |
| 28 | 33 | } | |
| 29 | 34 | ||
| @@ -58,9 +63,15 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 58 | 63 | return err | |
| 59 | 64 | } | |
| 60 | 65 | output <- ports.OutputMode{Width: 1920, Height: 1080} | |
| 66 | + | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | |
| 67 | + | if err != nil { | |
| 68 | + | return err | |
| 69 | + | } | |
| 70 | + | keymap := km.String() | |
| 61 | 71 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | |
| 62 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) | |
| 72 | + | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) | |
| 63 | 73 | if err != nil { | |
| 74 | + | km.Close() | |
| 64 | 75 | return err | |
| 65 | 76 | } | |
| 66 | 77 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | |
| @@ -72,8 +83,33 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 72 | 83 | inject(input) | |
| 73 | 84 | } | |
| 74 | 85 | var workers sync.WaitGroup | |
| 75 | - | workers.Add(5) | |
| 76 | - | done := make(chan error, 4) | |
| 86 | + | workers.Add(6) | |
| 87 | + | done := make(chan error, 6) | |
| 88 | + | script := make(chan string) | |
| 89 | + | go func() { | |
| 90 | + | defer workers.Done() | |
| 91 | + | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | |
| 92 | + | }() | |
| 93 | + | if opts.Script != nil { | |
| 94 | + | workers.Add(1) | |
| 95 | + | go func() { | |
| 96 | + | defer workers.Done() | |
| 97 | + | defer close(script) | |
| 98 | + | scanner := bufio.NewScanner(opts.Script) | |
| 99 | + | for scanner.Scan() { | |
| 100 | + | select { | |
| 101 | + | case <-ctx.Done(): | |
| 102 | + | return | |
| 103 | + | case script <- scanner.Text(): | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | if err := scanner.Err(); err != nil { | |
| 107 | + | log.Warn().Err(err).Msg("reading input script") | |
| 108 | + | } | |
| 109 | + | }() | |
| 110 | + | } else { | |
| 111 | + | close(script) | |
| 112 | + | } | |
| 77 | 113 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | |
| 78 | 114 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | |
| 79 | 115 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| @@ -2,17 +2,21 @@ | |||||
| 2 | package app | 2 | package app | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "bufio" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 9 | + | "io" | |||
| 8 | "os" | 10 | "os" | ||
| 9 | "sync" | 11 | "sync" | ||
| 10 | "time" | 12 | "time" | ||
| 11 | 13 | ||||
| 12 | "github.com/bnema/nefertty/internal/adapters/headless" | 14 | "github.com/bnema/nefertty/internal/adapters/headless" | ||
| 15 | + | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |||
| 13 | "github.com/bnema/nefertty/internal/adapters/launcher" | 16 | "github.com/bnema/nefertty/internal/adapters/launcher" | ||
| 14 | "github.com/bnema/nefertty/internal/adapters/vulkan" | 17 | "github.com/bnema/nefertty/internal/adapters/vulkan" | ||
| 15 | "github.com/bnema/nefertty/internal/adapters/wayland" | 18 | "github.com/bnema/nefertty/internal/adapters/wayland" | ||
| 19 | + | "github.com/bnema/nefertty/internal/adapters/xkb" | |||
| 16 | "github.com/bnema/nefertty/internal/core" | 20 | "github.com/bnema/nefertty/internal/core" | ||
| 17 | "github.com/bnema/nefertty/internal/logging" | 21 | "github.com/bnema/nefertty/internal/logging" | ||
| 18 | "github.com/bnema/nefertty/internal/ports" | 22 | "github.com/bnema/nefertty/internal/ports" | ||
| @@ -24,6 +28,7 @@ type Options struct { | |||||
| 24 | Timeout time.Duration | 28 | Timeout time.Duration | ||
| 25 | NoTerminal bool | 29 | NoTerminal bool | ||
| 26 | ScreenshotDir string | 30 | ScreenshotDir string | ||
| 31 | + | Script io.Reader | |||
| 27 | testScenes chan<- ports.Scene | 32 | testScenes chan<- ports.Scene | ||
| 28 | } | 33 | } | ||
| 29 | 34 | ||||
| @@ -58,9 +63,15 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 58 | return err | 63 | return err | ||
| 59 | } | 64 | } | ||
| 60 | output <- ports.OutputMode{Width: 1920, Height: 1080} | 65 | output <- ports.OutputMode{Width: 1920, Height: 1080} | ||
| 66 | + | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | keymap := km.String() | |||
| 61 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | 71 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | ||
| 62 | - | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) | 72 | + | server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland")) |
| 63 | if err != nil { | 73 | if err != nil { | ||
| 74 | + | km.Close() | |||
| 64 | return err | 75 | return err | ||
| 65 | } | 76 | } | ||
| 66 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | 77 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | ||
| @@ -72,8 +83,33 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 72 | inject(input) | 83 | inject(input) | ||
| 73 | } | 84 | } | ||
| 74 | var workers sync.WaitGroup | 85 | var workers sync.WaitGroup | ||
| 75 | - | workers.Add(5) | 86 | + | workers.Add(6) |
| 76 | - | done := make(chan error, 4) | 87 | + | done := make(chan error, 6) |
| 88 | + | script := make(chan string) | |||
| 89 | + | go func() { | |||
| 90 | + | defer workers.Done() | |||
| 91 | + | _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input")) | |||
| 92 | + | }() | |||
| 93 | + | if opts.Script != nil { | |||
| 94 | + | workers.Add(1) | |||
| 95 | + | go func() { | |||
| 96 | + | defer workers.Done() | |||
| 97 | + | defer close(script) | |||
| 98 | + | scanner := bufio.NewScanner(opts.Script) | |||
| 99 | + | for scanner.Scan() { | |||
| 100 | + | select { | |||
| 101 | + | case <-ctx.Done(): | |||
| 102 | + | return | |||
| 103 | + | case script <- scanner.Text(): | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if err := scanner.Err(); err != nil { | |||
| 107 | + | log.Warn().Err(err).Msg("reading input script") | |||
| 108 | + | } | |||
| 109 | + | }() | |||
| 110 | + | } else { | |||
| 111 | + | close(script) | |||
| 112 | + | } | |||
| 77 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | 113 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | ||
| 78 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | 114 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | ||
| 79 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | 115 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | ||
M internal/app/app_test.go +56 -0
| @@ -4,7 +4,11 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 6 | 6 | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | "image/png" | |
| 8 | + | "os" | |
| 7 | 9 | "os/exec" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 8 | 12 | "testing" | |
| 9 | 13 | "time" | |
| 10 | 14 | ) | |
| @@ -84,3 +88,55 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||
| 84 | 88 | input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true} | |
| 85 | 89 | waitScene(0, 5*time.Second) | |
| 86 | 90 | } | |
| 91 | + | ||
| 92 | + | func TestHeadlessTyping(t *testing.T) { | |
| 93 | + | if _, err := exec.LookPath("foot"); err != nil { | |
| 94 | + | t.Skip("foot unavailable") | |
| 95 | + | } | |
| 96 | + | t.Setenv("HOME", t.TempDir()) | |
| 97 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 98 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 99 | + | cfg := config.Defaults() | |
| 100 | + | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |
| 101 | + | dir := t.TempDir() | |
| 102 | + | err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n"), Timeout: 6 * time.Second}) | |
| 103 | + | if err != nil { | |
| 104 | + | if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") { | |
| 105 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 106 | + | } | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | path := filepath.Join(dir, "latest.png") | |
| 110 | + | f, err := os.Open(path) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | defer f.Close() | |
| 115 | + | img, err := png.Decode(f) | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | // The default column occupies the central portion of the 1920x1080 output. | |
| 120 | + | first := img.At(600, 300) | |
| 121 | + | varied := false | |
| 122 | + | for y := 200; y < 850 && !varied; y += 4 { | |
| 123 | + | for x := 500; x < 1400; x += 4 { | |
| 124 | + | if img.At(x, y) != first { | |
| 125 | + | varied = true | |
| 126 | + | break | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | if !varied { | |
| 131 | + | t.Fatal("window image is uniform") | |
| 132 | + | } | |
| 133 | + | if os.Getenv("NEFERTTY_KEEP_SHOTS") == "1" { | |
| 134 | + | data, err := os.ReadFile(path) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | if err := os.WriteFile("/tmp/nefertty-typing.png", data, 0600); err != nil { | |
| 139 | + | t.Fatal(err) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -4,7 +4,11 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "github.com/bnema/nefertty/internal/adapters/config" | 5 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| 6 | "github.com/bnema/nefertty/internal/ports" | 6 | "github.com/bnema/nefertty/internal/ports" | ||
| 7 | + | "image/png" | |||
| 8 | + | "os" | |||
| 7 | "os/exec" | 9 | "os/exec" | ||
| 10 | + | "path/filepath" | |||
| 11 | + | "strings" | |||
| 8 | "testing" | 12 | "testing" | ||
| 9 | "time" | 13 | "time" | ||
| 10 | ) | 14 | ) | ||
| @@ -84,3 +88,55 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||||
| 84 | input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true} | 88 | input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true} | ||
| 85 | waitScene(0, 5*time.Second) | 89 | waitScene(0, 5*time.Second) | ||
| 86 | } | 90 | } | ||
| 91 | + | ||||
| 92 | + | func TestHeadlessTyping(t *testing.T) { | |||
| 93 | + | if _, err := exec.LookPath("foot"); err != nil { | |||
| 94 | + | t.Skip("foot unavailable") | |||
| 95 | + | } | |||
| 96 | + | t.Setenv("HOME", t.TempDir()) | |||
| 97 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 98 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 99 | + | cfg := config.Defaults() | |||
| 100 | + | cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"} | |||
| 101 | + | dir := t.TempDir() | |||
| 102 | + | err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n"), Timeout: 6 * time.Second}) | |||
| 103 | + | if err != nil { | |||
| 104 | + | if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") { | |||
| 105 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 106 | + | } | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | path := filepath.Join(dir, "latest.png") | |||
| 110 | + | f, err := os.Open(path) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | defer f.Close() | |||
| 115 | + | img, err := png.Decode(f) | |||
| 116 | + | if err != nil { | |||
| 117 | + | t.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | // The default column occupies the central portion of the 1920x1080 output. | |||
| 120 | + | first := img.At(600, 300) | |||
| 121 | + | varied := false | |||
| 122 | + | for y := 200; y < 850 && !varied; y += 4 { | |||
| 123 | + | for x := 500; x < 1400; x += 4 { | |||
| 124 | + | if img.At(x, y) != first { | |||
| 125 | + | varied = true | |||
| 126 | + | break | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | if !varied { | |||
| 131 | + | t.Fatal("window image is uniform") | |||
| 132 | + | } | |||
| 133 | + | if os.Getenv("NEFERTTY_KEEP_SHOTS") == "1" { | |||
| 134 | + | data, err := os.ReadFile(path) | |||
| 135 | + | if err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | if err := os.WriteFile("/tmp/nefertty-typing.png", data, 0600); err != nil { | |||
| 139 | + | t.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||