Files
cthulhu/internal/app/preferences_session.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)
}
}
}
}