// 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" "os/user" "path/filepath" "strconv" "strings" "syscall" "time" ) type serverDialer func(context.Context, string, string) (net.Conn, error) // Automatic connections prefer the user's server, then the system service. // Preserve the user's endpoint error if neither server is available so that // autospawn still targets the user's socket, never the system socket. func dial_with_system_fallback(dial serverDialer) serverDialer { return func(ctx context.Context, network, address string) (net.Conn, error) { conn, err := dial(ctx, network, address) if ctx.Err() != nil || network != "unix" || (!errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED)) { return conn, err } systemConn, systemErr := dial(ctx, "unix", "/run/speech-dispatcher/speechd.sock") if systemErr == nil { return systemConn, nil } if ctx.Err() != nil { return nil, ctx.Err() } return nil, err } } // 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 { 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...) if uid := os.Getenv("SUDO_UID"); uid != "" && uid != strconv.Itoa(os.Geteuid()) { // Drop credentials only in the child. Never run a root-owned daemon in // another user's session or change the reader's device privileges. if os.Geteuid() != 0 { return fmt.Errorf("cannot start Speech Dispatcher as the invoking user without root privileges") } account, err := user.LookupId(uid) if err != nil { return fmt.Errorf("look up invoking user for Speech Dispatcher: %w", err) } groups, err := account.GroupIds() if err != nil { return fmt.Errorf("look up invoking user's groups: %w", err) } if err := configure_sudo_spawn(process, account, groups); err != nil { return err } } 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 } func configure_sudo_spawn(process *exec.Cmd, account *user.User, groups []string) error { uid, uidErr := strconv.ParseUint(account.Uid, 10, 32) gid, gidErr := strconv.ParseUint(account.Gid, 10, 32) if uidErr != nil || gidErr != nil || uid == 0 || !filepath.IsAbs(account.HomeDir) { return fmt.Errorf("invalid invoking user for Speech Dispatcher") } credentials := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} for _, group := range groups { id, err := strconv.ParseUint(group, 10, 32) if err != nil { return fmt.Errorf("invalid invoking user's group: %w", err) } credentials.Groups = append(credentials.Groups, uint32(id)) } process.SysProcAttr = &syscall.SysProcAttr{Credential: credentials} process.Dir = account.HomeDir // Do not pass root's HOME, XDG paths, D-Bus address, or unrelated secrets // into a user-owned daemon. Preserve explicit audio choices and locale. process.Env = []string{"HOME=" + account.HomeDir, "USER=" + account.Username, "LOGNAME=" + account.Username, "PATH=/usr/local/bin:/usr/bin:/bin"} for _, entry := range os.Environ() { key, _, _ := strings.Cut(entry, "=") if key == "LANG" || strings.HasPrefix(key, "LC_") || key == "PULSE_SERVER" || key == "PULSE_COOKIE" { process.Env = append(process.Env, entry) } } runtimeDir := filepath.Join("/run/user", account.Uid) if candidate := os.Getenv("XDG_RUNTIME_DIR"); owned_directory(candidate, uint32(uid)) { runtimeDir = candidate } if owned_directory(runtimeDir, uint32(uid)) { process.Env = append(process.Env, "XDG_RUNTIME_DIR="+runtimeDir) } return nil } func owned_directory(path string, uid uint32) bool { info, err := os.Stat(path) if err != nil || !info.IsDir() { return false } stat, ok := info.Sys().(*syscall.Stat_t) return ok && stat.Uid == uid }