280 lines
8.6 KiB
Go
280 lines
8.6 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/platform/session"
|
|
"git.stormux.org/storm/cthulhu/internal/preferences"
|
|
)
|
|
|
|
// Keep helper diagnostics bounded; GTK can repeat warnings while a display is
|
|
// unavailable. The child has no access to page content or the speech log.
|
|
type preferencesStderr struct{ data []byte }
|
|
|
|
func (stderr *preferencesStderr) Write(p []byte) (int, error) {
|
|
const limit = 4096
|
|
if room := limit - len(stderr.data); room > 0 {
|
|
stderr.data = append(stderr.data, p[:min(len(p), room)]...)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (stderr *preferencesStderr) String() string {
|
|
return string(bytes.TrimSpace(stderr.data))
|
|
}
|
|
|
|
func (worker *preferencesWorker) window(ctx context.Context) (result error) {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
childCtx, stop := context.WithCancel(ctx)
|
|
defer stop()
|
|
command := exec.CommandContext(childCtx, executable, "--preferences-ui")
|
|
command.Env = session.GTKEnvironment()
|
|
command.WaitDelay = time.Second
|
|
in, err := command.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := command.StdoutPipe()
|
|
if err != nil {
|
|
in.Close()
|
|
return err
|
|
}
|
|
// Keep GTK and child startup errors off the protocol pipe, then include
|
|
// them in a failed window attempt so a broken pipe has a useful cause.
|
|
var diagnostic preferencesStderr
|
|
command.Stderr = &diagnostic
|
|
if err := command.Start(); err != nil {
|
|
in.Close()
|
|
out.Close()
|
|
return err
|
|
}
|
|
if worker.live != nil {
|
|
worker.live.preferencesPID.Store(int64(command.Process.Pid))
|
|
defer worker.live.preferencesPID.Store(0)
|
|
}
|
|
if worker.preferencesProcess != nil {
|
|
worker.preferencesProcess.SetPreferencesProcess(command.Process.Pid)
|
|
defer worker.preferencesProcess.SetPreferencesProcess(0)
|
|
}
|
|
defer func() {
|
|
stop()
|
|
in.Close()
|
|
out.Close()
|
|
waitErr := command.Wait()
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if result == nil && waitErr != nil {
|
|
result = fmt.Errorf("preferences helper exited: %w", waitErr)
|
|
}
|
|
if result != nil {
|
|
if detail := diagnostic.String(); detail != "" {
|
|
result = fmt.Errorf("%w (helper exit: %v; stderr: %s)", result, waitErr, detail)
|
|
} else {
|
|
result = fmt.Errorf("%w (helper exit: %v)", result, waitErr)
|
|
}
|
|
}
|
|
}()
|
|
encoder := json.NewEncoder(in)
|
|
initial, err := worker.initial_response(childCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := preferences.EncodeInitial(encoder, initial); err != nil {
|
|
return err
|
|
}
|
|
requests := make(chan preferences.Request, 1)
|
|
readDone := make(chan error, 1)
|
|
readerStopped := make(chan struct{})
|
|
go read_preferences_requests(childCtx, stop, out, requests, readDone, readerStopped)
|
|
defer func() {
|
|
stop()
|
|
out.Close()
|
|
<-readerStopped
|
|
}()
|
|
return worker.serve_preferences(ctx, childCtx, encoder, requests, readDone)
|
|
}
|
|
|
|
func (worker *preferencesWorker) initial_response(childCtx context.Context) (preferences.Response, error) {
|
|
if worker.choosingApplication {
|
|
return preferences.Response{ChooseApplication: true}, nil
|
|
}
|
|
var err error
|
|
initial := worker.response("Apply and OK save settings. Cancel discards unapplied edits.", true)
|
|
initial.Application = worker.editApp
|
|
worker.sync_extensions(childCtx)
|
|
if worker.editApp == "" {
|
|
initial.ExtensionPages = worker.extension_pages()
|
|
}
|
|
if worker.editApp == "" {
|
|
snapshot := worker.tentacles.Snapshot()
|
|
initial.Tentacles = &snapshot
|
|
}
|
|
if !initial.CanSave {
|
|
initial.Message = "This file is read-only in the Go preview. Apply changes this session only; importing Python settings is not available yet."
|
|
}
|
|
catalogCtx, cancelCatalog := context.WithTimeout(childCtx, 4*time.Second)
|
|
initial.Catalog, err = worker.discover(catalogCtx, worker.socket)
|
|
cancelCatalog()
|
|
if err != nil {
|
|
worker.logger.Debug("discover echo voices", "error", err)
|
|
}
|
|
if worker.editing.SpeechServerFactory != "hardwarefactory" {
|
|
defaultsCtx, cancelDefaults := context.WithTimeout(childCtx, 500*time.Millisecond)
|
|
initial.SpeechDefaults, err = worker.readSpeechDefaults(defaultsCtx, worker.socket)
|
|
cancelDefaults()
|
|
if err != nil {
|
|
worker.logger.Debug("read speech defaults", "error", err)
|
|
}
|
|
}
|
|
if worker.editApp == "" {
|
|
if err := worker.load_extension_pages(childCtx); err != nil {
|
|
worker.logger.Warn("load tentacle preference pages", "error", err)
|
|
initial.Message += " Some tentacle pages could not load; their last valid controls were retained."
|
|
}
|
|
initial.ExtensionPages = worker.extension_pages()
|
|
}
|
|
return initial, nil
|
|
}
|
|
|
|
// The reader owns decoding only. Its caller closes the pipe and joins it before
|
|
// waiting for the child; cancellation also releases a full request mailbox.
|
|
func read_preferences_requests(childCtx context.Context, stop context.CancelFunc, out io.Reader, requests chan<- preferences.Request, readDone chan<- error, readerStopped chan<- struct{}) {
|
|
defer close(readerStopped)
|
|
defer stop()
|
|
scanner := bufio.NewScanner(out)
|
|
scanner.Buffer(make([]byte, 4096), 512*1024)
|
|
for scanner.Scan() {
|
|
var request preferences.Request
|
|
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
|
|
readDone <- err
|
|
return
|
|
}
|
|
select {
|
|
case requests <- request:
|
|
case <-childCtx.Done():
|
|
readDone <- childCtx.Err()
|
|
return
|
|
}
|
|
}
|
|
readDone <- scanner.Err()
|
|
}
|
|
|
|
// The worker goroutine serializes apply/preview replies and repeated-open
|
|
// requests. Preview restoration precedes pipe joins and child cleanup.
|
|
func (worker *preferencesWorker) serve_preferences(ctx, childCtx context.Context, encoder *json.Encoder, requests <-chan preferences.Request, readDone <-chan error) error {
|
|
previewActive := false
|
|
defer func() {
|
|
if !previewActive || ctx.Err() != nil {
|
|
return
|
|
}
|
|
restoreCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
defer cancel()
|
|
if err := worker.updateSpeech(restoreCtx, worker.effective_settings(worker.settings)); err != nil {
|
|
worker.logger.Error("restore speech settings after preferences exit", "error", err)
|
|
}
|
|
}()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-worker.extension_changes():
|
|
worker.sync_extensions(ctx)
|
|
if worker.editApp == "" {
|
|
if err := encoder.Encode(preferences.Response{ExtensionsOnly: true, ExtensionPages: worker.extension_pages()}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case call := <-worker.extensionRequests:
|
|
worker.handle_reader_call(ctx, call, true)
|
|
case command := <-worker.speechCommands:
|
|
if command == input.CycleProgressBarOutput {
|
|
worker.announceSpeechSetting("Close preferences before changing progress bar output with its shortcut.")
|
|
} else {
|
|
worker.announceSpeechSetting("Close preferences before using speech setting shortcuts.")
|
|
}
|
|
case <-worker.open:
|
|
// A present response raises the existing window without resetting edits.
|
|
if err := encoder.Encode(struct{ Present bool }{true}); err != nil {
|
|
return err
|
|
}
|
|
case err := <-readDone:
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
case request := <-requests:
|
|
if request.Tentacle != nil {
|
|
response := worker.manage_tentacle(childCtx, request)
|
|
if err := encoder.Encode(response); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if worker.choosingApplication {
|
|
if request.Application == "" || len(request.Application) > 4096 || !worker.beginEdit(childCtx, request.Application) {
|
|
return errors.New("invalid application preferences selection")
|
|
}
|
|
worker.choosingApplication = false
|
|
initial, err := worker.initial_response(childCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := preferences.EncodeInitial(encoder, initial); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
applyCtx, cancel := context.WithTimeout(childCtx, 6*time.Second)
|
|
var response preferences.Response
|
|
switch {
|
|
case request.Cancel:
|
|
response = worker.restorePreview(applyCtx)
|
|
case request.Preview:
|
|
response = worker.preview(applyCtx, request)
|
|
default:
|
|
response = worker.apply(applyCtx, request)
|
|
}
|
|
cancel()
|
|
if response.Accepted {
|
|
if request.Preview {
|
|
previewActive = true
|
|
} else {
|
|
previewActive = false
|
|
}
|
|
}
|
|
if childCtx.Err() != nil {
|
|
return nil
|
|
}
|
|
// Queue feedback before the helper restores focus or closes on OK.
|
|
if response.Accepted && !request.Preview && !request.Cancel && worker.announceApply != nil {
|
|
message := response.Message
|
|
if message == "Settings applied and saved." {
|
|
message = "Settings applied"
|
|
}
|
|
worker.announceApply(message)
|
|
}
|
|
if err := encoder.Encode(response); err != nil {
|
|
return fmt.Errorf("reply to preferences: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|