i screwed upMerge branch 'testing' of https://git.stormux.org/storm/fenrir into testing
This commit is contained in:
commit
55d550583c
11 files changed
+638
-222
No files matched your search
@@ -1,166 +0,0 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
|
||||
@@ -204,6 +204,11 @@ Speech drivers are `speech-dispatcher`, `dectalk`, `dectalk-usb`, `litetalk`,
|
||||
`doubletalk`, and `tripletalk`. Serial drivers need a device path. The Speech
|
||||
settings tab includes a **Test synthesizer** button for hardware drivers.
|
||||
|
||||
Speech Dispatcher connections support `SPEECHD_ADDRESS` (Unix socket or TCP),
|
||||
`SPEECHD_SOCKET`, runtime/cache-directory defaults, and bounded daemon autostart.
|
||||
See [speech synthesizers](docs/user.md#speech-synthesizers) for precedence and
|
||||
sudo behavior.
|
||||
|
||||
Clipboard persistence is optional and off by default. It stores unencrypted
|
||||
history separately for console and terminal sessions. X clipboard sync is
|
||||
controlled by `clipboard.sync`.
|
||||
|
||||
@@ -369,6 +369,25 @@ See the [keyboard guide](../config/keyboard/Readme.md) for an example.
|
||||
The default driver is `speech-dispatcher`. Select its module, language, and
|
||||
voice in Speech settings; empty values use server defaults.
|
||||
|
||||
Fenrir uses `SPEECHD_ADDRESS` when set: `unix_socket:/absolute/path` selects a
|
||||
Unix socket, and `inet_socket:HOST:PORT` selects TCP (default host `127.0.0.1`,
|
||||
port `6560`). Invalid addresses report an error. Otherwise, `SPEECHD_SOCKET`
|
||||
selects a Unix socket. Without either variable, Fenrir uses
|
||||
`$XDG_RUNTIME_DIR/speech-dispatcher/speechd.sock`, falling back to
|
||||
`$XDG_CACHE_HOME/speech-dispatcher/speechd.sock` or
|
||||
`~/.cache/speech-dispatcher/speechd.sock` when the runtime directory is unset.
|
||||
With sudo, the default remains `/run/user/$SUDO_UID/speech-dispatcher/speechd.sock`.
|
||||
Sudo may remove address variables; preserve them explicitly if needed.
|
||||
|
||||
When opening speech, Fenrir first tries the selected server. If its socket is
|
||||
missing or refuses connections, Fenrir requests `speech-dispatcher --spawn`
|
||||
and briefly retries. The daemon's configuration controls whether spawning is
|
||||
allowed. `SPEECHD_CMD` can select another executable. Autostart is limited to
|
||||
Unix sockets and local loopback TCP servers. When sudo changes the account,
|
||||
start Speech Dispatcher as the invoking user first; Fenrir will not launch a
|
||||
root-owned daemon for that user. Settings discovery never starts a daemon or
|
||||
interrupts the active speaking client.
|
||||
|
||||
Hardware driver IDs are `dectalk`, `dectalk-usb`, `litetalk`, `doubletalk`, and
|
||||
`tripletalk`. Serial drivers require `presentation.speech.hardware_device`,
|
||||
for example `/dev/ttyUSB0`, and default to 9600 baud when
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type serverAddress struct {
|
||||
network string
|
||||
address string
|
||||
}
|
||||
|
||||
// default_socket_path follows Python speechd, retaining Fenrir's sudo-user
|
||||
// runtime directory selection for console sessions.
|
||||
func default_socket_path() string {
|
||||
if uid := os.Getenv("SUDO_UID"); uid != "" {
|
||||
if _, err := strconv.ParseUint(uid, 10, 32); err == nil {
|
||||
return filepath.Join("/run/user", uid, "speech-dispatcher/speechd.sock")
|
||||
}
|
||||
}
|
||||
runtimeDirectory := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if runtimeDirectory == "" {
|
||||
runtimeDirectory = os.Getenv("XDG_CACHE_HOME")
|
||||
}
|
||||
if runtimeDirectory == "" {
|
||||
cacheDirectory, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
runtimeDirectory = cacheDirectory
|
||||
}
|
||||
}
|
||||
return filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock")
|
||||
}
|
||||
|
||||
// resolve_server_address gives an internal socket override priority over speechd's
|
||||
// standard address variables. SPEECHD_SOCKET is the older Unix-only setting.
|
||||
func resolve_server_address(socket string) (serverAddress, error) {
|
||||
if socket != "" {
|
||||
return serverAddress{network: "unix", address: socket}, nil
|
||||
}
|
||||
if address := os.Getenv("SPEECHD_ADDRESS"); address != "" {
|
||||
return parse_server_address(address)
|
||||
}
|
||||
if socket := os.Getenv("SPEECHD_SOCKET"); socket != "" {
|
||||
return serverAddress{network: "unix", address: socket}, nil
|
||||
}
|
||||
return serverAddress{network: "unix", address: default_socket_path()}, nil
|
||||
}
|
||||
|
||||
func parse_server_address(address string) (serverAddress, error) {
|
||||
method, value, _ := strings.Cut(address, ":")
|
||||
switch method {
|
||||
case "unix_socket":
|
||||
if value == "" {
|
||||
value = default_socket_path()
|
||||
}
|
||||
if !filepath.IsAbs(value) {
|
||||
return serverAddress{}, fmt.Errorf("Speech Dispatcher Unix socket must be an absolute path: %q", value)
|
||||
}
|
||||
return serverAddress{network: "unix", address: value}, nil
|
||||
case "inet_socket":
|
||||
host, port := "127.0.0.1", 6560
|
||||
if value != "" {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) > 2 {
|
||||
return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher address %q", address)
|
||||
}
|
||||
if parts[0] != "" {
|
||||
host = parts[0]
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
parsed, err := strconv.Atoi(parts[1])
|
||||
if err != nil || parsed < 1 || parsed > 65535 {
|
||||
return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher port in %q", address)
|
||||
}
|
||||
port = parsed
|
||||
}
|
||||
}
|
||||
return serverAddress{network: "tcp4", address: net.JoinHostPort(host, strconv.Itoa(port))}, nil
|
||||
default:
|
||||
return serverAddress{}, fmt.Errorf("unsupported Speech Dispatcher address %q", address)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveServerAddressPrecedence(t *testing.T) {
|
||||
t.Setenv("SUDO_UID", "")
|
||||
runtimeDirectory := t.TempDir()
|
||||
cacheDirectory := t.TempDir()
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeDirectory)
|
||||
t.Setenv("XDG_CACHE_HOME", cacheDirectory)
|
||||
t.Setenv("SPEECHD_SOCKET", "/tmp/legacy-speech.sock")
|
||||
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/address-speech.sock")
|
||||
|
||||
for _, test := range []struct {
|
||||
name, override, speechAddress, legacySocket, runtimeDir, want string
|
||||
}{
|
||||
{"explicit", "/tmp/explicit-speech.sock", "unix_socket:/tmp/address-speech.sock", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/explicit-speech.sock"},
|
||||
{"address", "", "unix_socket:/tmp/address-speech.sock", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/address-speech.sock"},
|
||||
{"legacy", "", "", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/legacy-speech.sock"},
|
||||
{"runtime", "", "", "", runtimeDirectory, filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock")},
|
||||
{"cache", "", "", "", "", filepath.Join(cacheDirectory, "speech-dispatcher", "speechd.sock")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("SPEECHD_ADDRESS", test.speechAddress)
|
||||
t.Setenv("SPEECHD_SOCKET", test.legacySocket)
|
||||
t.Setenv("XDG_RUNTIME_DIR", test.runtimeDir)
|
||||
got, err := resolve_server_address(test.override)
|
||||
if err != nil || got.network != "unix" || got.address != test.want {
|
||||
t.Fatalf("address = %+v, error = %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerAddress(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input, network, address string
|
||||
}{
|
||||
{"unix_socket:/tmp/speech.sock", "unix", "/tmp/speech.sock"},
|
||||
{"inet_socket", "tcp4", "127.0.0.1:6560"},
|
||||
{"inet_socket:localhost:6561", "tcp4", "localhost:6561"},
|
||||
{"inet_socket::6561", "tcp4", "127.0.0.1:6561"},
|
||||
} {
|
||||
got, err := parse_server_address(test.input)
|
||||
if err != nil || got.network != test.network || got.address != test.address {
|
||||
t.Fatalf("parse %q = %+v, %v", test.input, got, err)
|
||||
}
|
||||
}
|
||||
for _, address := range []string{"invalid:/tmp/speech.sock", "unix_socket:relative.sock", "inet_socket:localhost:0", "inet_socket:localhost:bad"} {
|
||||
if _, err := parse_server_address(address); err == nil {
|
||||
t.Fatalf("accepted invalid address %q", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeechAddressUsedByClientAndDiscovery(t *testing.T) {
|
||||
for _, network := range []string{"unix", "tcp4"} {
|
||||
t.Run(network, func(t *testing.T) {
|
||||
var server *fakeServer
|
||||
if network == "unix" {
|
||||
server = new_fake_server(t, false)
|
||||
} else {
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server = new_fake_server_listener(t, false, listener)
|
||||
}
|
||||
address := "unix_socket:" + server.path
|
||||
if network == "tcp4" {
|
||||
address = "inet_socket:" + server.path
|
||||
}
|
||||
t.Setenv("SPEECHD_ADDRESS", address)
|
||||
t.Setenv("SPEECHD_SOCKET", "/absent.sock")
|
||||
t.Setenv("SPEECHD_CMD", "/nonexistent/speech-dispatcher")
|
||||
client, err := Prepare("speech-dispatcher", Options{Module: "fixture", Voice: "Base Voice"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
if _, err := Discover(Options{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Say("connection fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait_event(t, server.events, "say:connection fixture")
|
||||
if err := client.Cancel(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait_event(t, server.events, "CANCEL 41")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidAddressDoesNotFallBack(t *testing.T) {
|
||||
server := new_fake_server(t, false)
|
||||
t.Setenv("SPEECHD_ADDRESS", "unsupported:address")
|
||||
t.Setenv("SPEECHD_SOCKET", server.path)
|
||||
if _, err := Open(Options{}); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) {
|
||||
t.Setenv("SUDO_UID", "")
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
t.Setenv("XDG_CACHE_HOME", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
want := filepath.Join(os.Getenv("HOME"), ".cache", "speech-dispatcher", "speechd.sock")
|
||||
if got := default_socket_path(); got != want {
|
||||
t.Fatalf("socket %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type serverDialer func(context.Context, string, string) (net.Conn, error)
|
||||
|
||||
// Like Python speechd, try the existing server first, then ask the daemon to
|
||||
// spawn according to its own configuration. Never start a local daemon for a
|
||||
// remote TCP endpoint, and never stop a daemon owned by the session.
|
||||
func connect_with_autospawn(ctx context.Context, endpoint serverAddress, dial serverDialer, spawn func(context.Context, serverAddress) error) (net.Conn, error) {
|
||||
conn, err := dial(ctx, endpoint.network, endpoint.address)
|
||||
if ctx.Err() != nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if err == nil {
|
||||
return conn, err
|
||||
}
|
||||
// Only an absent or stale endpoint warrants starting a daemon.
|
||||
if !errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return nil, err
|
||||
}
|
||||
if spawnErr := spawn(ctx, endpoint); spawnErr != nil {
|
||||
return nil, fmt.Errorf("%w; autostart Speech Dispatcher: %v", err, spawnErr)
|
||||
}
|
||||
retryContext, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||
defer cancel()
|
||||
for {
|
||||
conn, err = dial(retryContext, endpoint.network, endpoint.address)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
timer := time.NewTimer(50 * time.Millisecond)
|
||||
select {
|
||||
case <-retryContext.Done():
|
||||
timer.Stop()
|
||||
return nil, fmt.Errorf("after Speech Dispatcher autostart: %w", err)
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func spawn_arguments(ctx context.Context, endpoint serverAddress) ([]string, error) {
|
||||
args := []string{"--spawn"}
|
||||
if endpoint.network == "unix" {
|
||||
return append(args, "--communication-method", "unix_socket", "--socket-path", endpoint.address), nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(endpoint.address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !address.IP.IsLoopback() {
|
||||
return nil, fmt.Errorf("cannot autostart a remote Speech Dispatcher at %s", host)
|
||||
}
|
||||
}
|
||||
return append(args, "--communication-method", "inet_socket", "--port", port), nil
|
||||
}
|
||||
|
||||
func spawn_server(ctx context.Context, endpoint serverAddress) error {
|
||||
// Never create a root-owned server in the invoking user's runtime directory.
|
||||
if uid := os.Getenv("SUDO_UID"); uid != "" && uid != strconv.Itoa(os.Geteuid()) {
|
||||
return fmt.Errorf("start Speech Dispatcher as the invoking user before running Fenrir with sudo")
|
||||
}
|
||||
spawnContext, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||
defer cancel()
|
||||
args, err := spawn_arguments(spawnContext, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command := os.Getenv("SPEECHD_CMD")
|
||||
if command == "" {
|
||||
command = "speech-dispatcher"
|
||||
}
|
||||
process := exec.CommandContext(spawnContext, command, args...)
|
||||
process.WaitDelay = 100 * time.Millisecond
|
||||
output, err := process.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s --spawn: %w: %s", command, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T) {
|
||||
for _, connectError := range []error{syscall.ENOENT, syscall.ECONNREFUSED} {
|
||||
t.Run(connectError.Error(), func(t *testing.T) {
|
||||
endpoint := serverAddress{network: "unix", address: "/tmp/selected-speech.sock"}
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
attempts, starts := 0, 0
|
||||
got, err := connect_with_autospawn(context.Background(), endpoint, func(_ context.Context, network, address string) (net.Conn, error) {
|
||||
if network != endpoint.network || address != endpoint.address {
|
||||
t.Fatal("connection address changed during autostart")
|
||||
}
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, connectError
|
||||
}
|
||||
return client, nil
|
||||
}, func(_ context.Context, selected serverAddress) error {
|
||||
if selected != endpoint {
|
||||
t.Fatal("spawn address differs from selected server")
|
||||
}
|
||||
starts++
|
||||
return nil
|
||||
})
|
||||
if err != nil || got != client || attempts != 2 || starts != 1 {
|
||||
t.Fatalf("connection = %v, %v; attempts %d, starts %d", got, err, attempts, starts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) {
|
||||
dial := func(context.Context, string, string) (net.Conn, error) { return nil, syscall.ECONNREFUSED }
|
||||
starts := 0
|
||||
spawn := func(context.Context, serverAddress) error { starts++; return errors.New("autospawn disabled") }
|
||||
_, err := connect_with_autospawn(context.Background(), serverAddress{}, dial, spawn)
|
||||
if !errors.Is(err, syscall.ECONNREFUSED) || !strings.Contains(err.Error(), "autospawn disabled") || starts != 1 {
|
||||
t.Fatalf("error = %v, starts = %d", err, starts)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _ = connect_with_autospawn(ctx, serverAddress{}, dial, spawn)
|
||||
if starts != 1 {
|
||||
t.Fatal("canceled connection started a daemon")
|
||||
}
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
_, err = connect_with_autospawn(ctx, serverAddress{}, func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, syscall.ECONNREFUSED
|
||||
}, func(context.Context, serverAddress) error { cancel(); return nil })
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("retry error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeechSpawnArgumentsPreserveEndpointAndRejectRemote(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
endpoint serverAddress
|
||||
want []string
|
||||
}{
|
||||
{serverAddress{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}},
|
||||
{serverAddress{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}},
|
||||
} {
|
||||
args, err := spawn_arguments(context.Background(), test.endpoint)
|
||||
if err != nil || !reflect.DeepEqual(args, test.want) {
|
||||
t.Fatalf("arguments = %v, %v", args, err)
|
||||
}
|
||||
}
|
||||
if _, err := spawn_arguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil {
|
||||
t.Fatal("remote server allowed local autospawn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
|
||||
t.Setenv("SUDO_UID", "")
|
||||
path := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'autospawn disabled by daemon' >&2\nexit 1\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SPEECHD_CMD", path)
|
||||
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
|
||||
if err == nil || !strings.Contains(err.Error(), "autospawn disabled by daemon") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingSpeechServerDoesNotInvokeSpawn(t *testing.T) {
|
||||
t.Setenv("SPEECHD_CMD", "/nonexistent/speech-dispatcher")
|
||||
server := new_fake_server(t, false)
|
||||
client, err := Open(Options{Socket: server.path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
}
|
||||
|
||||
func TestPermissionErrorDoesNotSpawn(t *testing.T) {
|
||||
starts := 0
|
||||
_, err := connect_with_autospawn(context.Background(), serverAddress{},
|
||||
func(context.Context, string, string) (net.Conn, error) { return nil, syscall.EACCES },
|
||||
func(context.Context, serverAddress) error { starts++; return nil })
|
||||
if !errors.Is(err, syscall.EACCES) || starts != 0 {
|
||||
t.Fatalf("error %v, starts %d", err, starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryDoesNotSpawn(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "started")
|
||||
command := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
|
||||
if err := os.WriteFile(command, []byte("#!/bin/sh\ntouch "+marker+"\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SPEECHD_CMD", command)
|
||||
options := Options{Socket: filepath.Join(t.TempDir(), "absent.sock")}
|
||||
if _, err := Discover(options); err == nil {
|
||||
t.Fatal("expected connection failure")
|
||||
}
|
||||
if _, err := discover_modules(options); err == nil {
|
||||
t.Fatal("expected connection failure")
|
||||
}
|
||||
if _, err := discover_voices(options); err == nil {
|
||||
t.Fatal("expected connection failure")
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("discovery started a daemon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSudoDoesNotSpawnAsAnotherUser(t *testing.T) {
|
||||
t.Setenv("SUDO_UID", strconv.Itoa(os.Geteuid()+1))
|
||||
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invoking user") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run against a separate dummy-output daemon, never the user's speech server.
|
||||
func TestNativeSpeechDispatcherColdStart(t *testing.T) {
|
||||
if os.Getenv("FENRIR_NATIVE_SPEECH_STARTUP") != "1" {
|
||||
t.Skip("requires isolated Speech Dispatcher startup")
|
||||
}
|
||||
if _, err := exec.LookPath("speech-dispatcher"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, staleSocket := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("stale-socket-%v", staleSocket), func(t *testing.T) {
|
||||
directory, err := os.MkdirTemp("", "speech-start-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
configPath := filepath.Join(directory, "speechd.conf")
|
||||
config := "LogLevel 0\nLogDir \"" + directory + "\"\nAddModule \"dummy\" \"sd_dummy\" \"\"\nDefaultModule dummy\nTimeout 1\n"
|
||||
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pidPath := filepath.Join(directory, "speechd.pid")
|
||||
wrapper := filepath.Join(directory, "spawn")
|
||||
script := "#!/bin/sh\nexec speech-dispatcher --config-dir \"$FENRIR_TEST_SPEECH_CONFIG\" --pid-file \"$FENRIR_TEST_SPEECH_PID\" \"$@\"\n"
|
||||
if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("FENRIR_TEST_SPEECH_CONFIG", directory)
|
||||
t.Setenv("FENRIR_TEST_SPEECH_PID", pidPath)
|
||||
t.Setenv("SPEECHD_CMD", wrapper)
|
||||
t.Setenv("XDG_RUNTIME_DIR", directory)
|
||||
t.Setenv("SPEECHD_ADDRESS", "")
|
||||
t.Setenv("SPEECHD_SOCKET", "")
|
||||
t.Setenv("SUDO_UID", "")
|
||||
defer func() {
|
||||
if data, err := os.ReadFile(pidPath); err == nil {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
}
|
||||
}()
|
||||
socket := default_socket_path()
|
||||
if staleSocket {
|
||||
if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener.SetUnlinkOnClose(false)
|
||||
listener.Close()
|
||||
}
|
||||
|
||||
// Selection preparation must also start a cold daemon before querying
|
||||
// membership, even when the saved module is no longer installed.
|
||||
if staleSocket {
|
||||
candidate, prepareErr := Prepare("speech-dispatcher", Options{Module: "unavailable"})
|
||||
if candidate != nil {
|
||||
candidate.Close()
|
||||
t.Fatal("unexpected candidate")
|
||||
}
|
||||
if prepareErr == nil || !strings.Contains(prepareErr.Error(), `speech module "unavailable" is unavailable`) {
|
||||
t.Fatalf("selection error = %v", prepareErr)
|
||||
}
|
||||
}
|
||||
client, err := Open(Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
if client.id == "" || client.control == nil {
|
||||
t.Fatal("cold-start client did not initialize both channels")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,8 @@ func Validate_selection(module, language, voice string) error {
|
||||
// deadline and the entire catalog has a four-second budget. Partial failures
|
||||
// retain successful modules and mark unavailable voice lists explicitly.
|
||||
func Discover(options Options) (Catalog, error) {
|
||||
if options.Socket == "" {
|
||||
options.Socket = Default_socket()
|
||||
}
|
||||
result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
|
||||
w, err := connect(options.Socket)
|
||||
w, err := connect_options(options)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -195,6 +192,7 @@ func Prepare(driver string, options Options) (Transport, error) {
|
||||
if err = d.Availability(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options.autospawn = driver == "speech-dispatcher"
|
||||
if options.Module != "" {
|
||||
modules, err := d.Modules(options)
|
||||
if err != nil {
|
||||
@@ -230,10 +228,7 @@ func discover_voices(options Options) ([]Voice, error) {
|
||||
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.Socket == "" {
|
||||
options.Socket = Default_socket()
|
||||
}
|
||||
w, err := connect(options.Socket)
|
||||
w, err := connect_options(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+39
-31
@@ -20,10 +20,9 @@ package speech
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -96,8 +95,27 @@ func (w *wire) command(command string) ([]string, error) {
|
||||
return w.reply()
|
||||
}
|
||||
|
||||
func connect(path string) (*wire, error) {
|
||||
conn, err := net.DialTimeout("unix", path, commandTimeout)
|
||||
// Queries share the address resolver. Only Prepare opts into daemon startup;
|
||||
// ordinary settings discovery never starts a service.
|
||||
func connect_options(options Options) (*wire, error) {
|
||||
endpoint, err := resolve_server_address(options.Socket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connect_endpoint(endpoint, options.autospawn)
|
||||
}
|
||||
|
||||
func connect_endpoint(endpoint serverAddress, autospawn bool) (*wire, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*commandTimeout)
|
||||
defer cancel()
|
||||
dialer := &net.Dialer{Timeout: commandTimeout}
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if autospawn {
|
||||
conn, err = connect_with_autospawn(ctx, endpoint, dialer.DialContext, spawn_server)
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, endpoint.network, endpoint.address)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,14 +131,16 @@ type Client struct {
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Socket string
|
||||
Rate int
|
||||
Pitch *int
|
||||
Volume *int
|
||||
Module string
|
||||
Language string
|
||||
Voice string
|
||||
Hardware HardwareOptions
|
||||
// Only Prepare enables startup during its prerequisite selection queries.
|
||||
autospawn bool
|
||||
Socket string
|
||||
Rate int
|
||||
Pitch *int
|
||||
Volume *int
|
||||
Module string
|
||||
Language string
|
||||
Voice string
|
||||
Hardware HardwareOptions
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
@@ -134,22 +154,6 @@ func (p Parameters) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Default_socket() string {
|
||||
if value := os.Getenv("SPEECHD_ADDRESS"); strings.HasPrefix(value, "unix_socket:") {
|
||||
return strings.TrimPrefix(value, "unix_socket:")
|
||||
}
|
||||
if uid := os.Getenv("SUDO_UID"); uid != "" {
|
||||
if _, err := strconv.ParseUint(uid, 10, 32); err == nil {
|
||||
return filepath.Join("/run/user", uid, "speech-dispatcher/speechd.sock")
|
||||
}
|
||||
}
|
||||
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if runtimeDir == "" {
|
||||
runtimeDir = filepath.Join("/run/user", strconv.Itoa(os.Getuid()))
|
||||
}
|
||||
return filepath.Join(runtimeDir, "speech-dispatcher/speechd.sock")
|
||||
}
|
||||
|
||||
func Open(options Options) (*Client, error) {
|
||||
if options.Rate < -100 || options.Rate > 100 {
|
||||
return nil, fmt.Errorf("speech rate must be between -100 and 100")
|
||||
@@ -162,9 +166,13 @@ func Open(options Options) (*Client, error) {
|
||||
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := connect(options.Socket)
|
||||
endpoint, err := resolve_server_address(options.Socket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", options.Socket, err)
|
||||
return nil, err
|
||||
}
|
||||
data, err := connect_endpoint(endpoint, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", endpoint.address, err)
|
||||
}
|
||||
client := &Client{data: data}
|
||||
fail := func(err error) (*Client, error) {
|
||||
@@ -213,7 +221,7 @@ func Open(options Options) (*Client, error) {
|
||||
return fail(fmt.Errorf("invalid Speech Dispatcher client ID"))
|
||||
}
|
||||
client.id = strconv.FormatUint(id, 10)
|
||||
client.control, err = connect(options.Socket)
|
||||
client.control, err = connect_endpoint(endpoint, false)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
@@ -62,14 +62,19 @@ type fakeServer struct {
|
||||
|
||||
func new_fake_server(t *testing.T, block bool) *fakeServer {
|
||||
t.Helper()
|
||||
server := &fakeServer{path: filepath.Join(t.TempDir(), "s"), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
|
||||
if !block {
|
||||
close(server.ack)
|
||||
}
|
||||
listener, err := net.Listen("unix", server.path)
|
||||
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return new_fake_server_listener(t, block, listener)
|
||||
}
|
||||
|
||||
func new_fake_server_listener(t *testing.T, block bool, listener net.Listener) *fakeServer {
|
||||
t.Helper()
|
||||
server := &fakeServer{path: listener.Addr().String(), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
|
||||
if !block {
|
||||
close(server.ack)
|
||||
}
|
||||
server.wg.Add(1)
|
||||
acceptedDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -293,13 +298,14 @@ func TestLiveParametersUseSpeakingClientAndRejectInvalidValues(t *testing.T) {
|
||||
|
||||
func TestDefaultSocketForSudo(t *testing.T) {
|
||||
t.Setenv("SPEECHD_ADDRESS", "")
|
||||
t.Setenv("SPEECHD_SOCKET", "")
|
||||
t.Setenv("SUDO_UID", "1000")
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/run/user/0")
|
||||
if got := Default_socket(); got != "/run/user/1000/speech-dispatcher/speechd.sock" {
|
||||
t.Fatal(got)
|
||||
if got, err := resolve_server_address(""); err != nil || got.address != "/run/user/1000/speech-dispatcher/speechd.sock" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
|
||||
if got := Default_socket(); got != "/tmp/example-speechd.sock" {
|
||||
t.Fatal(got)
|
||||
if got, err := resolve_server_address(""); err != nil || got.address != "/tmp/example-speechd.sock" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,6 @@ func (d Driver) Availability() error {
|
||||
var drivers = append([]Driver{{
|
||||
ID: "speech-dispatcher", Label: "Speech Dispatcher",
|
||||
Open: func(options Options) (Transport, error) {
|
||||
if options.Socket == "" {
|
||||
options.Socket = Default_socket()
|
||||
}
|
||||
return Open(options)
|
||||
},
|
||||
Modules: discover_modules,
|
||||
@@ -100,12 +97,9 @@ func Open_driver(id string, options Options) (Transport, error) {
|
||||
}
|
||||
|
||||
// discover_modules uses a separate, bounded connection. It never changes the
|
||||
// active speaking client, cancels speech or starts a speech service.
|
||||
// active speaking client or cancels speech. Only Prepare permits daemon startup.
|
||||
func discover_modules(options Options) ([]string, error) {
|
||||
if options.Socket == "" {
|
||||
options.Socket = Default_socket()
|
||||
}
|
||||
wire, err := connect(options.Socket)
|
||||
wire, err := connect_options(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user