Standard fenrir backgrounding and -d flags restored.

This commit is contained in:
Storm Dragon committed 2026-10-28 00:58:49 -04:00
1 parent 4f5a875d0e
commit eaf450587c
13 files changed
+408 -20

No files matched your search

+7 -1
View File
@@ -58,6 +58,11 @@ From a Linux text console:
sudo ./fenrir 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 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 `/dev/uinput`. Run only one reader on the same console. Fenrir releases keyboard
input on graphical virtual terminals and on text consoles listed in 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`, Run `./fenrir --help` for all options. Useful options include `--config`,
`--keyboard-layout`, `--punctuation-profile`, `--speech-driver`, `--speech-rate`, `--keyboard-layout`, `--punctuation-profile`, `--speech-driver`, `--speech-rate`,
`--speech-module`, `--input-device`, and `--debug-file`. `--version` prints the `--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 ## Localization
+98
View File
@@ -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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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
}
+148
View File
@@ -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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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())
}
})
}
}
+24 -5
View File
@@ -39,14 +39,26 @@ import (
func main() { func main() {
i18n.Use(i18n.New(i18n.LocaleDir, i18n.Language())) 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)) os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
} }
func run(args []string, stdout, stderr io.Writer) int { 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 := flag.NewFlagSet("fenrir", flag.ContinueOnError)
flags.SetOutput(stderr) flags.SetOutput(stderr)
showVersion := flags.Bool("version", false, "print the version and release channel") showVersion := flags.Bool("version", false, "print the version and release channel")
terminalMode := flags.Bool("x", false, "run an unprivileged shell in this X11 terminal") 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)") 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)") 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)") 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() defer file.Close()
logOutput, logLevel = file, slog.LevelDebug 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() previousLogger := slog.Default()
defer slog.SetDefault(previousLogger) defer slog.SetDefault(previousLogger)
slog.SetDefault(slog.New(slog.NewTextHandler(logOutput, &slog.HandlerOptions{ 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()) slog.Info("reader starting", "pid", os.Getpid(), "terminal_mode", *terminalMode, "at", time.Now())
ctx, stop := termination_context() ctx, stop := termination_context()
defer stop() defer stop()
start := app.Start
if *terminalMode {
start = app.Start_terminal
}
if path == "" { if path == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -154,7 +168,12 @@ func run(args []string, stdout, stderr io.Writer) int {
} }
path = config.Paths(home, os.Geteuid())[0] 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 != "" { if *debugFile != "" {
slog.Error("reader stopped", "pid", os.Getpid(), "error", err, "at", time.Now()) slog.Error("reader stopped", "pid", os.Getpid(), "error", err, "at", time.Now())
} }
+3
View File
@@ -36,11 +36,14 @@ func TestCLIWithoutOpeningDevices(t *testing.T) {
}{ }{
{[]string{"--help"}, 0, "keypad 7/8/9"}, {[]string{"--help"}, 0, "keypad 7/8/9"},
{[]string{"--help"}, 0, "(default 80)"}, {[]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{"--version"}, 0, "fenrir " + version.Label()},
{[]string{"--unknown"}, 2, "flag provided but not defined"}, {[]string{"--unknown"}, 2, "flag provided but not defined"},
{[]string{"--speech-socket=/tmp/unused.sock"}, 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{"unexpected"}, 2, "unexpected positional arguments"},
{[]string{"--speech-rate=101"}, 2, "presentation.speech.rate must be"}, {[]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", "--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"}, {[]string{"-x", "--input-device=/dev/input/event1"}, 2, "-x does not use"},
} { } {
+23 -2
View File
@@ -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] \f[I]# Linux text console, with console and keyboard device access\f[R]
sudo ./fenrir 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] \f[I]# X11 terminal, as your regular user\f[R]
\&./fenrir \-x \&./fenrir \-x
@@ -28,8 +31,19 @@ sudo ./fenrir
\&./fenrir \-x \-\-keyboard\-layout laptop \&./fenrir \-x \-\-keyboard\-layout laptop
.EE .EE
.PP .PP
Fenrir runs in the foreground. Console mode starts in the background by default.
Press \f[B]Fenrir+Q\f[R] to quit. 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. Run only one reader on a given Linux console.
Separate X11 terminal instances can run at once. Separate X11 terminal instances can run at once.
.PP .PP
@@ -550,6 +564,13 @@ Purpose: Show usage and options
Purpose: Print version and release channel Purpose: Print version and release channel
.RE .RE
.IP \(bu 2 .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] \f[B]\-x\f[R]
.RS 2 .RS 2
.PP .PP
+16 -3
View File
@@ -17,6 +17,9 @@ Run these commands from the checkout root after building `fenrir`:
# Linux text console, with console and keyboard device access # Linux text console, with console and keyboard device access
sudo ./fenrir sudo ./fenrir
# Linux console in the foreground (also use -f with service managers)
sudo ./fenrir -f
# X11 terminal, as your regular user # X11 terminal, as your regular user
./fenrir -x ./fenrir -x
@@ -24,9 +27,17 @@ sudo ./fenrir
./fenrir -x --keyboard-layout laptop ./fenrir -x --keyboard-layout laptop
---- ----
Fenrir runs in the foreground. Press *Fenrir{plus}Q* to quit. Run only Console mode starts in the background by default. Add `-f` to keep it in
one reader on a given Linux console. Separate X11 terminal instances can the foreground, including under a service manager. The launcher returns
run at once. 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 X11 mode starts a shell in a new PTY. The launching terminal must supply
valid `DISPLAY` and `WINDOWID` environment variables. Keyboard valid `DISPLAY` and `WINDOWID` environment variables. Keyboard
@@ -384,6 +395,8 @@ reference.
|`--version` |Print version and release channel |`--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 |`-x` |Start a shell inside this X11 terminal
|`--config PATH` |Load a specific TOML settings file |`--config PATH` |Load a specific TOML settings file
+14 -2
View File
@@ -16,6 +16,9 @@ Run these commands from the checkout root after building `fenrir`:
# Linux text console, with console and keyboard device access # Linux text console, with console and keyboard device access
sudo ./fenrir sudo ./fenrir
# Linux console in the foreground (also use -f with service managers)
sudo ./fenrir -f
# X11 terminal, as your regular user # X11 terminal, as your regular user
./fenrir -x ./fenrir -x
@@ -23,8 +26,16 @@ sudo ./fenrir
./fenrir -x --keyboard-layout laptop ./fenrir -x --keyboard-layout laptop
``` ```
Fenrir runs in the foreground. Press **Fenrir+Q** to quit. Run only one reader Console mode starts in the background by default. Add `-f` to keep it in the
on a given Linux console. Separate X11 terminal instances can run at once. 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 X11 mode starts a shell in a new PTY. The launching terminal must supply valid
`DISPLAY` and `WINDOWID` environment variables. Keyboard interception is scoped `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 | | `--help` | Show usage and options |
| `--version` | Print version and release channel | | `--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 | | `-x` | Start a shell inside this X11 terminal |
| `--config PATH` | Load a specific TOML settings file | | `--config PATH` | Load a specific TOML settings file |
| `--keyboard-layout NAME_OR_PATH` | Select a named layout or TOML file | | `--keyboard-layout NAME_OR_PATH` | Select a named layout or TOML file |
+17 -3
View File
@@ -15,15 +15,26 @@ Run these commands from the checkout root after building fenrir:
# Linux text console, with console and keyboard device access # Linux text console, with console and keyboard device access
sudo ./fenrir sudo ./fenrir
# Linux console in the foreground (also use -f with service managers)
sudo ./fenrir -f
# X11 terminal, as your regular user # X11 terminal, as your regular user
./fenrir -x ./fenrir -x
# Either mode can select the laptop layout # Either mode can select the laptop layout
./fenrir -x --keyboard-layout laptop ./fenrir -x --keyboard-layout laptop
Fenrir runs in the foreground. Press Fenrir+Q to quit. Run only one Console mode starts in the background by default. Add -f to keep it in
reader on a given Linux console. Separate X11 terminal instances can run the foreground, including under a service manager. The launcher returns
at once. 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 X11 mode starts a shell in a new PTY. The launching terminal must supply
valid DISPLAY and WINDOWID environment variables. Keyboard interception 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 --version Print version and release channel
-f Keep console mode in the foreground
(always enabled with -x)
-x Start a shell inside this X11 -x Start a shell inside this X11
terminal terminal
+1
View File
@@ -70,6 +70,7 @@ type SoundPlayer interface {
} }
type RunOptions struct { type RunOptions struct {
Ready func() error
Bindings []input.Binding Bindings []input.Binding
Punctuation *punctuation.Processor Punctuation *punctuation.Processor
Settings config.Settings Settings config.Settings
+42
View File
@@ -772,3 +772,45 @@ func TestScreenWatcherFailureReleasesKeyboards(t *testing.T) {
t.Fatal("screen watcher failure left keyboards open") 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)
}
})
}
}
+5
View File
@@ -97,6 +97,11 @@ func (a *application) loop(ticks <-chan time.Time) error {
if err := a.poll_input(true); err != nil { if err := a.poll_input(true); err != nil {
return err return err
} }
if a.options.Ready != nil {
if err := a.options.Ready(); err != nil {
return err
}
}
for { for {
if a.quitting { if a.quitting {
return nil return nil
+10 -4
View File
@@ -53,15 +53,21 @@ func (disabledVoice) Errors() <-chan error { return nil }
func (disabledVoice) Close() {} func (disabledVoice) Close() {}
func Start(ctx context.Context, settings config.Settings, configPath string, bindings []input.Binding) error { 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. // 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 { 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 { if err := settings.Validate(); err != nil {
return err return err
} }
@@ -168,7 +174,7 @@ func start(ctx context.Context, settings config.Settings, terminalMode bool, con
} }
transfers := clipboard.New_worker(ctx, paste) transfers := clipboard.New_worker(ctx, paste)
defer transfers.Close() 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 result == nil && player != nil {
if err := player.Finish(context.Background()); err != nil { if err := player.Finish(context.Background()); err != nil {
slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now()) slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now())