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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
Reference in new issue
Block a user