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
|
`doubletalk`, and `tripletalk`. Serial drivers need a device path. The Speech
|
||||||
settings tab includes a **Test synthesizer** button for hardware drivers.
|
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
|
Clipboard persistence is optional and off by default. It stores unencrypted
|
||||||
history separately for console and terminal sessions. X clipboard sync is
|
history separately for console and terminal sessions. X clipboard sync is
|
||||||
controlled by `clipboard.sync`.
|
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
|
The default driver is `speech-dispatcher`. Select its module, language, and
|
||||||
voice in Speech settings; empty values use server defaults.
|
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
|
Hardware driver IDs are `dectalk`, `dectalk-usb`, `litetalk`, `doubletalk`, and
|
||||||
`tripletalk`. Serial drivers require `presentation.speech.hardware_device`,
|
`tripletalk`. Serial drivers require `presentation.speech.hardware_device`,
|
||||||
for example `/dev/ttyUSB0`, and default to 9600 baud when
|
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
|
// deadline and the entire catalog has a four-second budget. Partial failures
|
||||||
// retain successful modules and mark unavailable voice lists explicitly.
|
// retain successful modules and mark unavailable voice lists explicitly.
|
||||||
func Discover(options Options) (Catalog, error) {
|
func Discover(options Options) (Catalog, error) {
|
||||||
if options.Socket == "" {
|
|
||||||
options.Socket = Default_socket()
|
|
||||||
}
|
|
||||||
result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
|
result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
|
||||||
w, err := connect(options.Socket)
|
w, err := connect_options(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
@@ -195,6 +192,7 @@ func Prepare(driver string, options Options) (Transport, error) {
|
|||||||
if err = d.Availability(); err != nil {
|
if err = d.Availability(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
options.autospawn = driver == "speech-dispatcher"
|
||||||
if options.Module != "" {
|
if options.Module != "" {
|
||||||
modules, err := d.Modules(options)
|
modules, err := d.Modules(options)
|
||||||
if err != nil {
|
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 {
|
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if options.Socket == "" {
|
w, err := connect_options(options)
|
||||||
options.Socket = Default_socket()
|
|
||||||
}
|
|
||||||
w, err := connect(options.Socket)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-31
@@ -20,10 +20,9 @@ package speech
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -96,8 +95,27 @@ func (w *wire) command(command string) ([]string, error) {
|
|||||||
return w.reply()
|
return w.reply()
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(path string) (*wire, error) {
|
// Queries share the address resolver. Only Prepare opts into daemon startup;
|
||||||
conn, err := net.DialTimeout("unix", path, commandTimeout)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -113,14 +131,16 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Socket string
|
// Only Prepare enables startup during its prerequisite selection queries.
|
||||||
Rate int
|
autospawn bool
|
||||||
Pitch *int
|
Socket string
|
||||||
Volume *int
|
Rate int
|
||||||
Module string
|
Pitch *int
|
||||||
Language string
|
Volume *int
|
||||||
Voice string
|
Module string
|
||||||
Hardware HardwareOptions
|
Language string
|
||||||
|
Voice string
|
||||||
|
Hardware HardwareOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
type Parameters struct {
|
type Parameters struct {
|
||||||
@@ -134,22 +154,6 @@ func (p Parameters) Validate() error {
|
|||||||
return nil
|
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) {
|
func Open(options Options) (*Client, error) {
|
||||||
if options.Rate < -100 || options.Rate > 100 {
|
if options.Rate < -100 || options.Rate > 100 {
|
||||||
return nil, fmt.Errorf("speech rate must be between -100 and 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 {
|
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data, err := connect(options.Socket)
|
endpoint, err := resolve_server_address(options.Socket)
|
||||||
if err != nil {
|
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}
|
client := &Client{data: data}
|
||||||
fail := func(err error) (*Client, error) {
|
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"))
|
return fail(fmt.Errorf("invalid Speech Dispatcher client ID"))
|
||||||
}
|
}
|
||||||
client.id = strconv.FormatUint(id, 10)
|
client.id = strconv.FormatUint(id, 10)
|
||||||
client.control, err = connect(options.Socket)
|
client.control, err = connect_endpoint(endpoint, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,19 @@ type fakeServer struct {
|
|||||||
|
|
||||||
func new_fake_server(t *testing.T, block bool) *fakeServer {
|
func new_fake_server(t *testing.T, block bool) *fakeServer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
server := &fakeServer{path: filepath.Join(t.TempDir(), "s"), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
|
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
|
||||||
if !block {
|
|
||||||
close(server.ack)
|
|
||||||
}
|
|
||||||
listener, err := net.Listen("unix", server.path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
server.wg.Add(1)
|
||||||
acceptedDone := make(chan struct{})
|
acceptedDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -293,13 +298,14 @@ func TestLiveParametersUseSpeakingClientAndRejectInvalidValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestDefaultSocketForSudo(t *testing.T) {
|
func TestDefaultSocketForSudo(t *testing.T) {
|
||||||
t.Setenv("SPEECHD_ADDRESS", "")
|
t.Setenv("SPEECHD_ADDRESS", "")
|
||||||
|
t.Setenv("SPEECHD_SOCKET", "")
|
||||||
t.Setenv("SUDO_UID", "1000")
|
t.Setenv("SUDO_UID", "1000")
|
||||||
t.Setenv("XDG_RUNTIME_DIR", "/run/user/0")
|
t.Setenv("XDG_RUNTIME_DIR", "/run/user/0")
|
||||||
if got := Default_socket(); got != "/run/user/1000/speech-dispatcher/speechd.sock" {
|
if got, err := resolve_server_address(""); err != nil || got.address != "/run/user/1000/speech-dispatcher/speechd.sock" {
|
||||||
t.Fatal(got)
|
t.Fatal(got, err)
|
||||||
}
|
}
|
||||||
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
|
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
|
||||||
if got := Default_socket(); got != "/tmp/example-speechd.sock" {
|
if got, err := resolve_server_address(""); err != nil || got.address != "/tmp/example-speechd.sock" {
|
||||||
t.Fatal(got)
|
t.Fatal(got, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,9 +43,6 @@ func (d Driver) Availability() error {
|
|||||||
var drivers = append([]Driver{{
|
var drivers = append([]Driver{{
|
||||||
ID: "speech-dispatcher", Label: "Speech Dispatcher",
|
ID: "speech-dispatcher", Label: "Speech Dispatcher",
|
||||||
Open: func(options Options) (Transport, error) {
|
Open: func(options Options) (Transport, error) {
|
||||||
if options.Socket == "" {
|
|
||||||
options.Socket = Default_socket()
|
|
||||||
}
|
|
||||||
return Open(options)
|
return Open(options)
|
||||||
},
|
},
|
||||||
Modules: discover_modules,
|
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
|
// 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) {
|
func discover_modules(options Options) ([]string, error) {
|
||||||
if options.Socket == "" {
|
wire, err := connect_options(options)
|
||||||
options.Socket = Default_socket()
|
|
||||||
}
|
|
||||||
wire, err := connect(options.Socket)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user