diff --git a/LICENCE b/LICENCE deleted file mode 100644 index 341c30bd..00000000 --- a/LICENCE +++ /dev/null @@ -1,166 +0,0 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - 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. - diff --git a/README.md b/README.md index 2c77068b..85e2e120 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,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`. diff --git a/docs/user.md b/docs/user.md index 4629e65b..97e805b3 100644 --- a/docs/user.md +++ b/docs/user.md @@ -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 diff --git a/internal/speech/address.go b/internal/speech/address.go new file mode 100644 index 00000000..ceeb75e4 --- /dev/null +++ b/internal/speech/address.go @@ -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) + } +} diff --git a/internal/speech/address_test.go b/internal/speech/address_test.go new file mode 100644 index 00000000..041c8617 --- /dev/null +++ b/internal/speech/address_test.go @@ -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) + } +} diff --git a/internal/speech/autospawn.go b/internal/speech/autospawn.go new file mode 100644 index 00000000..c6b94db5 --- /dev/null +++ b/internal/speech/autospawn.go @@ -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 +} diff --git a/internal/speech/autospawn_test.go b/internal/speech/autospawn_test.go new file mode 100644 index 00000000..15c9bc2f --- /dev/null +++ b/internal/speech/autospawn_test.go @@ -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") + } + }) + } +} diff --git a/internal/speech/catalog.go b/internal/speech/catalog.go index 7b251025..42e16fd8 100644 --- a/internal/speech/catalog.go +++ b/internal/speech/catalog.go @@ -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 } diff --git a/internal/speech/client.go b/internal/speech/client.go index 358a649b..47cba2d0 100644 --- a/internal/speech/client.go +++ b/internal/speech/client.go @@ -20,10 +20,9 @@ package speech import ( "bufio" + "context" "fmt" "net" - "os" - "path/filepath" "strconv" "strings" "sync" @@ -93,8 +92,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 } @@ -110,14 +128,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 { @@ -131,22 +151,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") @@ -159,9 +163,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) { @@ -210,7 +218,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) } diff --git a/internal/speech/client_test.go b/internal/speech/client_test.go index 74ae2e62..38339474 100644 --- a/internal/speech/client_test.go +++ b/internal/speech/client_test.go @@ -43,14 +43,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() { @@ -274,13 +279,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) } } diff --git a/internal/speech/drivers.go b/internal/speech/drivers.go index 4d0378b6..928cdac4 100644 --- a/internal/speech/drivers.go +++ b/internal/speech/drivers.go @@ -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 }