// 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) } } } }