Standard fenrir backgrounding and -d flags restored.
This commit is contained in:
1 parent
4f5a875d0e
commit
eaf450587c
13 files changed
+408
-20
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
} {
|
||||
|
||||
+23
-2
@@ -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
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
+14
-2
@@ -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 |
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ type SoundPlayer interface {
|
||||
}
|
||||
|
||||
type RunOptions struct {
|
||||
Ready func() error
|
||||
Bindings []input.Binding
|
||||
Punctuation *punctuation.Processor
|
||||
Settings config.Settings
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+10
-4
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user