From eaf450587c120d1dcc80877bbf14f9f118c316a5 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 28 Oct 2026 00:58:49 -0400 Subject: [PATCH] Standard fenrir backgrounding and -d flags restored. --- README.md | 8 +- cmd/fenrir/daemon_linux.go | 98 +++++++++++++++++++++ cmd/fenrir/daemon_linux_test.go | 148 ++++++++++++++++++++++++++++++++ cmd/fenrir/main.go | 29 +++++-- cmd/fenrir/main_test.go | 3 + docs/fenrir.1 | 25 +++++- docs/fenrir.adoc | 19 +++- docs/user.md | 16 +++- docs/user.txt | 20 ++++- internal/app/app.go | 1 + internal/app/app_test.go | 42 +++++++++ internal/app/event_loop.go | 5 ++ internal/app/startup.go | 14 ++- 13 files changed, 408 insertions(+), 20 deletions(-) create mode 100644 cmd/fenrir/daemon_linux.go create mode 100644 cmd/fenrir/daemon_linux_test.go diff --git a/README.md b/README.md index 0690e75e..3bd3ce6f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,11 @@ From a Linux text console: sudo ./fenrir ``` +Console mode starts in the background by default. Use `sudo ./fenrir -f` to +keep it in the foreground, including when running under a service manager. +The launcher waits for startup and reports initialization errors. Use +`--debug-file PATH` to keep diagnostics after it detaches. + Console mode needs access to the console devices, keyboard event devices, and `/dev/uinput`. Run only one reader on the same console. Fenrir releases keyboard input on graphical virtual terminals and on text consoles listed in @@ -212,7 +217,8 @@ requests can also be enabled separately in the `[remote]` section. Run `./fenrir --help` for all options. Useful options include `--config`, `--keyboard-layout`, `--punctuation-profile`, `--speech-driver`, `--speech-rate`, `--speech-module`, `--input-device`, and `--debug-file`. `--version` prints the -version and release channel. Fenrir runs in the foreground. +version and release channel. Console mode runs in the background unless `-f` +is given; `-x` always runs in the foreground. ## Localization diff --git a/cmd/fenrir/daemon_linux.go b/cmd/fenrir/daemon_linux.go new file mode 100644 index 00000000..72778a74 --- /dev/null +++ b/cmd/fenrir/daemon_linux.go @@ -0,0 +1,98 @@ +// Fenrir console screen reader. +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "syscall" +) + +const daemonStatusEnv = "FENRIR_DAEMON_STATUS_FD" + +type daemonStatus struct { + Code int + Error string +} + +// Re-exec rather than forking a running Go runtime. Keep the working directory +// and environment so relative configuration paths and audio access still work. +func start_daemon(args []string, stderr io.Writer) int { + executable, err := os.Executable() + if err != nil { + fmt.Fprintln(stderr, "fenrir: daemon executable:", err) + return 1 + } + child := exec.Command(executable, append(append([]string{}, args...), "-f")...) + return launch_daemon(child, stderr) +} + +func launch_daemon(child *exec.Cmd, stderr io.Writer) int { + reader, writer, err := os.Pipe() + if err != nil { + fmt.Fprintln(stderr, "fenrir: daemon startup pipe:", err) + return 1 + } + defer reader.Close() + defer writer.Close() + child.Env = append(os.Environ(), daemonStatusEnv+"=3") + child.ExtraFiles = []*os.File{writer} + child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + // Nil standard streams connect to /dev/null, releasing the launching TTY. + if err := child.Start(); err != nil { + fmt.Fprintln(stderr, "fenrir: start daemon:", err) + return 1 + } + writer.Close() + var status daemonStatus + if err := json.NewDecoder(reader).Decode(&status); err != nil { + // A child that cannot report readiness must not keep input grabbed. + child.Process.Kill() + child.Wait() + fmt.Fprintln(stderr, "fenrir: daemon startup:", err) + return 1 + } + if status.Code != 0 { + child.Wait() + fmt.Fprint(stderr, status.Error) + return status.Code + } + child.Process.Release() + return 0 +} + +func run_daemon_child(args []string, stdout io.Writer, statusFile *os.File) int { + defer statusFile.Close() + var diagnostics bytes.Buffer + reported := false + code := run_with_ready(args, stdout, &diagnostics, func() error { + if err := json.NewEncoder(statusFile).Encode(daemonStatus{}); err != nil { + return fmt.Errorf("report daemon readiness: %w", err) + } + reported = true + return statusFile.Close() + }) + if !reported { + json.NewEncoder(statusFile).Encode(daemonStatus{Code: code, Error: diagnostics.String()}) + } + return code +} diff --git a/cmd/fenrir/daemon_linux_test.go b/cmd/fenrir/daemon_linux_test.go new file mode 100644 index 00000000..31a79d90 --- /dev/null +++ b/cmd/fenrir/daemon_linux_test.go @@ -0,0 +1,148 @@ +// Fenrir console screen reader. +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// Runs only in private subprocesses: no console, speech or keyboard devices. +func TestDaemonProcess(t *testing.T) { + if os.Getenv(daemonStatusEnv) != "3" { + return + } + os.Unsetenv(daemonStatusEnv) + statusFile := os.NewFile(3, "daemon startup") + mode := os.Args[len(os.Args)-2] + recordPath := os.Args[len(os.Args)-1] + switch mode { + case "failure": + os.Exit(run_daemon_child([]string{"--unknown"}, os.Stdout, statusFile)) + case "crash": + os.Exit(17) + case "ready": + ctx, stop := termination_context() + defer stop() + sessionID, _, err := syscall.Syscall(syscall.SYS_GETSID, 0, 0, 0) + if err != 0 || int(sessionID) != os.Getpid() || syscall.Getpgrp() != os.Getpid() { + os.Exit(18) + } + if tty, err := os.Open("/dev/tty"); err == nil { + tty.Close() + os.Exit(19) + } + for _, fd := range []string{"0", "1", "2"} { + target, err := os.Readlink("/proc/self/fd/" + fd) + if err != nil || target != os.DevNull { + os.Exit(20) + } + } + cwd, _ := os.Getwd() + record := fmt.Sprintf("%d\n%s\n%s", os.Getpid(), cwd, os.Getenv("FENRIR_DAEMON_TEST_VALUE")) + if err := os.WriteFile(recordPath, []byte(record), 0600); err != nil { + os.Exit(21) + } + json.NewEncoder(statusFile).Encode(daemonStatus{}) + statusFile.Close() + select { + case <-ctx.Done(): + case <-time.After(10 * time.Second): + } + os.Exit(0) + } + os.Exit(22) +} + +func TestDaemonDetachesAndReportsReadiness(t *testing.T) { + root := t.TempDir() + t.Setenv("FENRIR_DAEMON_TEST_VALUE", "preserved") + recordPath := filepath.Join(root, "child") + child := exec.Command(os.Args[0], "-test.run=^TestDaemonProcess$", "--", "ready", recordPath) + child.Dir = root + var stderr bytes.Buffer + if status := launch_daemon(child, &stderr); status != 0 { + t.Fatal(status, stderr.String()) + } + record, err := os.ReadFile(recordPath) + if err != nil { + t.Fatal(err) + } + // Release invalidates child.Process.Pid. Read the child's own recorded PID + // and reject process-group/broadcast values before any signal or wait. + var pid int + if _, err := fmt.Sscanf(string(record), "%d", &pid); err != nil || pid <= 1 { + t.Fatal("invalid private child PID", pid, err) + } + privateChild, err := os.FindProcess(pid) + if err != nil { + t.Fatal(err) + } + defer privateChild.Release() + reaped := false + defer func() { + if !reaped { + privateChild.Kill() + syscall.Wait4(pid, nil, 0, nil) + } + }() + if string(record) != fmt.Sprintf("%d\n%s\npreserved", pid, root) { + t.Fatal("detached child lost working directory or environment", string(record)) + } + if err := privateChild.Signal(syscall.Signal(0)); err != nil { + t.Fatal("launcher did not return while child was running", err) + } + // The existing termination handler must still shut down a detached reader. + if err := privateChild.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + var status syscall.WaitStatus + waited, err := syscall.Wait4(pid, &status, 0, nil) + reaped = waited == pid + if err != nil || !status.Exited() || status.ExitStatus() != 0 { + t.Fatal("detached child did not handle SIGTERM", status, err) + } +} + +func TestDaemonStartupFailuresReturnToLauncher(t *testing.T) { + for _, mode := range []string{"failure", "crash", "missing-executable"} { + t.Run(mode, func(t *testing.T) { + child := exec.Command(os.Args[0], "-test.run=^TestDaemonProcess$", "--", mode, "unused") + wantCode, wantText := 1, "daemon startup:" + if mode == "failure" { + wantCode, wantText = 2, "flag provided but not defined" + } + if mode == "missing-executable" { + child = exec.Command(filepath.Join(t.TempDir(), "missing")) + wantText = "start daemon:" + } + var stderr bytes.Buffer + if code := launch_daemon(child, &stderr); code != wantCode || !strings.Contains(stderr.String(), wantText) { + t.Fatal(code, stderr.String()) + } + }) + } +} diff --git a/cmd/fenrir/main.go b/cmd/fenrir/main.go index 09056884..740bdc59 100644 --- a/cmd/fenrir/main.go +++ b/cmd/fenrir/main.go @@ -39,14 +39,26 @@ import ( func main() { i18n.Use(i18n.New(i18n.LocaleDir, i18n.Language())) + if os.Getenv(daemonStatusEnv) == "3" { + // Do not pass the private startup channel to scripts or synthesizers. + os.Unsetenv(daemonStatusEnv) + syscall.CloseOnExec(3) + statusFile := os.NewFile(3, "daemon startup") + os.Exit(run_daemon_child(os.Args[1:], os.Stdout, statusFile)) + } os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, stdout, stderr io.Writer) int { + return run_with_ready(args, stdout, stderr, nil) +} + +func run_with_ready(args []string, stdout, stderr io.Writer, ready func() error) int { flags := flag.NewFlagSet("fenrir", flag.ContinueOnError) flags.SetOutput(stderr) showVersion := flags.Bool("version", false, "print the version and release channel") terminalMode := flags.Bool("x", false, "run an unprivileged shell in this X11 terminal") + foreground := flags.Bool("f", false, "stay in the foreground (always enabled with -x)") configPath := flags.String("config", "", "settings.toml path (default: Fenrir user/system settings directories)") keyboardLayout := flags.String("keyboard-layout", "", "keyboard layout name or TOML path (overrides keyboard.layout)") punctuationProfile := flags.String("punctuation-profile", "", "punctuation profile name or TOML path (overrides reading.punctuation_profile)") @@ -127,6 +139,12 @@ func run(args []string, stdout, stderr io.Writer) int { defer file.Close() logOutput, logLevel = file, slog.LevelDebug } + if !*foreground && !*terminalMode && ready == nil { + return start_daemon(args, stderr) + } + if ready != nil && *debugFile == "" { + logOutput = io.Discard + } previousLogger := slog.Default() defer slog.SetDefault(previousLogger) slog.SetDefault(slog.New(slog.NewTextHandler(logOutput, &slog.HandlerOptions{ @@ -142,10 +160,6 @@ func run(args []string, stdout, stderr io.Writer) int { slog.Info("reader starting", "pid", os.Getpid(), "terminal_mode", *terminalMode, "at", time.Now()) ctx, stop := termination_context() defer stop() - start := app.Start - if *terminalMode { - start = app.Start_terminal - } if path == "" { home, err := os.UserHomeDir() if err != nil { @@ -154,7 +168,12 @@ func run(args []string, stdout, stderr io.Writer) int { } path = config.Paths(home, os.Geteuid())[0] } - if err := start(ctx, settings, path, bindings); err != nil { + if *terminalMode { + err = app.Start_terminal(ctx, settings, path, bindings) + } else { + err = app.Start_with_ready(ctx, settings, path, bindings, ready) + } + if err != nil { if *debugFile != "" { slog.Error("reader stopped", "pid", os.Getpid(), "error", err, "at", time.Now()) } diff --git a/cmd/fenrir/main_test.go b/cmd/fenrir/main_test.go index 418ed37f..0bf28238 100644 --- a/cmd/fenrir/main_test.go +++ b/cmd/fenrir/main_test.go @@ -36,11 +36,14 @@ func TestCLIWithoutOpeningDevices(t *testing.T) { }{ {[]string{"--help"}, 0, "keypad 7/8/9"}, {[]string{"--help"}, 0, "(default 80)"}, + {[]string{"-f", "--help"}, 0, "stay in the foreground"}, + {[]string{"-f", "--version"}, 0, "fenrir " + version.Label()}, {[]string{"--version"}, 0, "fenrir " + version.Label()}, {[]string{"--unknown"}, 2, "flag provided but not defined"}, {[]string{"--speech-socket=/tmp/unused.sock"}, 2, "flag provided but not defined"}, {[]string{"unexpected"}, 2, "unexpected positional arguments"}, {[]string{"--speech-rate=101"}, 2, "presentation.speech.rate must be"}, + {[]string{"-f", "--keyboard-layout", "../../config/keyboard/desktop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, 0, "Settings valid"}, {[]string{"-x", "--keyboard-layout", "../../config/keyboard/desktop.toml", "--punctuation-profile", "../../config/punctuation/default.toml", "--check-config"}, 0, "Settings valid"}, {[]string{"-x", "--input-device=/dev/input/event1"}, 2, "-x does not use"}, } { diff --git a/docs/fenrir.1 b/docs/fenrir.1 index 6cc98d8a..dede57da 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -21,6 +21,9 @@ Run these commands from the checkout root after building \f[I]# Linux text console, with console and keyboard device access\f[R] sudo ./fenrir +\f[I]# Linux console in the foreground (also use \-f with service managers)\f[R] +sudo ./fenrir \-f + \f[I]# X11 terminal, as your regular user\f[R] \&./fenrir \-x @@ -28,8 +31,19 @@ sudo ./fenrir \&./fenrir \-x \-\-keyboard\-layout laptop .EE .PP -Fenrir runs in the foreground. -Press \f[B]Fenrir+Q\f[R] to quit. +Console mode starts in the background by default. +Add \f[CR]\-f\f[R] to keep it in the foreground, including under a +service manager. +The launcher returns after the reader initializes and reports startup +failures in the launching terminal. +Use \f[CR]\-\-debug\-file PATH\f[R] to retain diagnostics after +detaching. +Help, version, and configuration\-only commands always run in the +foreground. +.PP +X11 mode (\f[CR]\-x\f[R]) always stays in the foreground because it owns +the terminal and its child shell. +Press \f[B]Fenrir+Q\f[R] to quit either mode. Run only one reader on a given Linux console. Separate X11 terminal instances can run at once. .PP @@ -550,6 +564,13 @@ Purpose: Show usage and options Purpose: Print version and release channel .RE .IP \(bu 2 +\f[B]\-f\f[R] +.RS 2 +.PP +Purpose: Keep console mode in the foreground (always enabled with +\f[CR]\-x\f[R]) +.RE +.IP \(bu 2 \f[B]\-x\f[R] .RS 2 .PP diff --git a/docs/fenrir.adoc b/docs/fenrir.adoc index 6cbb1d71..048ecef3 100644 --- a/docs/fenrir.adoc +++ b/docs/fenrir.adoc @@ -17,6 +17,9 @@ Run these commands from the checkout root after building `fenrir`: # Linux text console, with console and keyboard device access sudo ./fenrir +# Linux console in the foreground (also use -f with service managers) +sudo ./fenrir -f + # X11 terminal, as your regular user ./fenrir -x @@ -24,9 +27,17 @@ sudo ./fenrir ./fenrir -x --keyboard-layout laptop ---- -Fenrir runs in the foreground. Press *Fenrir{plus}Q* to quit. Run only -one reader on a given Linux console. Separate X11 terminal instances can -run at once. +Console mode starts in the background by default. Add `-f` to keep it in +the foreground, including under a service manager. The launcher returns +after the reader initializes and reports startup failures in the +launching terminal. Use `--debug-file PATH` to retain diagnostics after +detaching. Help, version, and configuration-only commands always run in +the foreground. + +X11 mode (`-x`) always stays in the foreground because it owns the +terminal and its child shell. Press *Fenrir{plus}Q* to quit either mode. +Run only one reader on a given Linux console. Separate X11 terminal +instances can run at once. X11 mode starts a shell in a new PTY. The launching terminal must supply valid `DISPLAY` and `WINDOWID` environment variables. Keyboard @@ -384,6 +395,8 @@ reference. |`--version` |Print version and release channel +|`-f` |Keep console mode in the foreground (always enabled with `-x`) + |`-x` |Start a shell inside this X11 terminal |`--config PATH` |Load a specific TOML settings file diff --git a/docs/user.md b/docs/user.md index 3ee33057..1c749be6 100644 --- a/docs/user.md +++ b/docs/user.md @@ -16,6 +16,9 @@ Run these commands from the checkout root after building `fenrir`: # Linux text console, with console and keyboard device access sudo ./fenrir +# Linux console in the foreground (also use -f with service managers) +sudo ./fenrir -f + # X11 terminal, as your regular user ./fenrir -x @@ -23,8 +26,16 @@ sudo ./fenrir ./fenrir -x --keyboard-layout laptop ``` -Fenrir runs in the foreground. Press **Fenrir+Q** to quit. Run only one reader -on a given Linux console. Separate X11 terminal instances can run at once. +Console mode starts in the background by default. Add `-f` to keep it in the +foreground, including under a service manager. The launcher returns after the +reader initializes and reports startup failures in the launching terminal. +Use `--debug-file PATH` to retain diagnostics after detaching. Help, version, +and configuration-only commands always run in the foreground. + +X11 mode (`-x`) always stays in the foreground because it owns the terminal +and its child shell. Press **Fenrir+Q** to quit either mode. Run only one +reader on a given Linux console. Separate X11 terminal instances can run at +once. X11 mode starts a shell in a new PTY. The launching terminal must supply valid `DISPLAY` and `WINDOWID` environment variables. Keyboard interception is scoped @@ -327,6 +338,7 @@ Fenrir runs as `fenrir [options]`. Use `--help` for the built-in reference. | --- | --- | | `--help` | Show usage and options | | `--version` | Print version and release channel | +| `-f` | Keep console mode in the foreground (always enabled with `-x`) | | `-x` | Start a shell inside this X11 terminal | | `--config PATH` | Load a specific TOML settings file | | `--keyboard-layout NAME_OR_PATH` | Select a named layout or TOML file | diff --git a/docs/user.txt b/docs/user.txt index 69c249d1..13c1c237 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -15,15 +15,26 @@ Run these commands from the checkout root after building fenrir: # Linux text console, with console and keyboard device access sudo ./fenrir + # Linux console in the foreground (also use -f with service managers) + sudo ./fenrir -f + # X11 terminal, as your regular user ./fenrir -x # Either mode can select the laptop layout ./fenrir -x --keyboard-layout laptop -Fenrir runs in the foreground. Press Fenrir+Q to quit. Run only one -reader on a given Linux console. Separate X11 terminal instances can run -at once. +Console mode starts in the background by default. Add -f to keep it in +the foreground, including under a service manager. The launcher returns +after the reader initializes and reports startup failures in the +launching terminal. Use --debug-file PATH to retain diagnostics after +detaching. Help, version, and configuration-only commands always run in +the foreground. + +X11 mode (-x) always stays in the foreground because it owns the +terminal and its child shell. Press Fenrir+Q to quit either mode. Run +only one reader on a given Linux console. Separate X11 terminal +instances can run at once. X11 mode starts a shell in a new PTY. The launching terminal must supply valid DISPLAY and WINDOWID environment variables. Keyboard interception @@ -357,6 +368,9 @@ Fenrir runs as fenrir [options]. Use --help for the built-in reference. --version Print version and release channel + -f Keep console mode in the foreground + (always enabled with -x) + -x Start a shell inside this X11 terminal diff --git a/internal/app/app.go b/internal/app/app.go index 9791d6d1..0aa7c7d6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -70,6 +70,7 @@ type SoundPlayer interface { } type RunOptions struct { + Ready func() error Bindings []input.Binding Punctuation *punctuation.Processor Settings config.Settings diff --git a/internal/app/app_test.go b/internal/app/app_test.go index bec749af..99220ee1 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -772,3 +772,45 @@ func TestScreenWatcherFailureReleasesKeyboards(t *testing.T) { t.Fatal("screen watcher failure left keyboards open") } } + +func TestStartupReadinessFollowsInitialScreenAndInput(t *testing.T) { + for _, failure := range []string{"", "screen", "input", "notification"} { + t.Run(failure, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + problem := errors.New("startup fixture failure") + source := &fakeSource{snapshot: screen.Snapshot{Terminal: 1}} + keys := &fakeKeys{} + read, polled, ready := false, false, false + source.afterRead = func() { read = true } + if failure == "screen" { + source.readErr = problem + } + keys.poll = func(bool, func(input.Decision)) error { + polled = true + if failure == "input" { + return problem + } + return nil + } + opts := RunOptions{Settings: config.Defaults(), Ready: func() error { + if !read || !polled { + t.Fatal("readiness preceded initial screen or input") + } + ready = true + cancel() + if failure == "notification" { + return problem + } + return nil + }} + err := run(ctx, source, keys, &fakeVoice{}, nil, opts) + if failure != "" && !errors.Is(err, problem) || failure == "" && err != nil { + t.Fatal("unexpected startup result", err) + } + if ready != (failure == "" || failure == "notification") || !keys.closed { + t.Fatal("startup failure reported readiness or left input open", ready, keys.closed) + } + }) + } +} diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 562c7142..0088ba38 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -97,6 +97,11 @@ func (a *application) loop(ticks <-chan time.Time) error { if err := a.poll_input(true); err != nil { return err } + if a.options.Ready != nil { + if err := a.options.Ready(); err != nil { + return err + } + } for { if a.quitting { return nil diff --git a/internal/app/startup.go b/internal/app/startup.go index e2c35103..8ace6c5b 100644 --- a/internal/app/startup.go +++ b/internal/app/startup.go @@ -53,15 +53,21 @@ func (disabledVoice) Errors() <-chan error { return nil } func (disabledVoice) Close() {} func Start(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error { - return start(ctx, settings, false, configPath, bindings) + return Start_with_ready(ctx, settings, configPath, bindings, nil) +} + +// Start_with_ready reports console startup after the first screen and input poll. +// A failed notification tears down the reader and releases its keyboards. +func Start_with_ready(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding, ready func() error) error { + return start(ctx, settings, false, configPath, bindings, ready) } // Start_terminal runs the same reader and command dispatcher in an X11 PTY. func Start_terminal(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error { - return start(ctx, settings, true, configPath, bindings) + return start(ctx, settings, true, configPath, bindings, nil) } -func start(ctx context.Context, settings config.Settings, terminalMode bool, configPath string, bindings []input.Binding) (result error) { +func start(ctx context.Context, settings config.Settings, terminalMode bool, configPath string, bindings []input.Binding, ready func() error) (result error) { if err := settings.Validate(); err != nil { return err } @@ -168,7 +174,7 @@ func start(ctx context.Context, settings config.Settings, terminalMode bool, con } transfers := clipboard.New_worker(ctx, paste) defer transfers.Close() - result = Run(ctx, source, keyboards, voices, RunOptions{Spellcheck: spellChecker, Bindings: bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode}) + result = Run(ctx, source, keyboards, voices, RunOptions{Ready: ready, Spellcheck: spellChecker, Bindings: bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode}) if result == nil && player != nil { if err := player.Finish(context.Background()); err != nil { slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now())