// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "encoding/json" "errors" "path/filepath" "reflect" "sort" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type readerExtension struct { session *tentacle.Session value tentaclewire.Extensions revision uint64 } type readerSettingsCall struct { ctx context.Context session *tentacle.Session request tentaclewire.ReaderRequest result chan readerSettingsResult } type readerSettingsResult struct { state *tentaclewire.ReaderState err error } type readerTransaction struct { extensions map[string]*readerExtension override *tentaclewire.SpeechConfiguration owner string } func copy_reader_value[T any](value T) T { data, _ := json.Marshal(value) var result T _ = json.Unmarshal(data, &result) return result } func (runtime *tentacleRuntime) signal_extensions() { if runtime == nil { return } select { case runtime.extensionChanges <- struct{}{}: default: } } func (worker *preferencesWorker) extension_changes() <-chan struct{} { if worker.tentacles == nil { return nil } return worker.tentacles.extensionChanges } func (runtime *tentacleRuntime) reader_sessions() map[string]*tentacle.Session { sessions := map[string]*tentacle.Session{} if runtime == nil { return sessions } runtime.mutex.Lock() defer runtime.mutex.Unlock() if runtime.disabled { return sessions } for _, command := range runtime.commands { if state, _ := command.session.Status(); command.reader && state == tentacle.Active { sessions[command.session.Identity()] = command.session } } return sessions } // Called only by the settings owner. Revocation wakes this mailbox even if no // preferences window is open. No native input callback waits for restoration. func (worker *preferencesWorker) sync_extensions(ctx context.Context) { if ctx.Err() != nil { return } sessions := worker.tentacles.reader_sessions() if worker.extensions == nil { worker.extensions = map[string]*readerExtension{} } for id := range worker.extensions { if sessions[id] == nil { delete(worker.extensions, id) } } for id, session := range sessions { if worker.extensions[id] != nil { continue } value := session.Extensions() worker.load_page_store() for index := range value.Pages { page := &value.Pages[index] if saved := worker.savedPages[session.TentacleID()][page.ID]; saved != nil && tentaclewire.ValidatePageValues(*page, saved) == nil { page.Values = copy_reader_value(saved) } } worker.extensionRevision++ worker.extensions[id] = &readerExtension{session: session, value: value, revision: worker.extensionRevision} if err := validate_reader_extensions(worker.extensions); err != nil { delete(worker.extensions, id) session.Disable() worker.announceSpeechSetting("A tentacle exceeded the reader settings limits and was disabled.") } } if worker.speechOverride != nil && sessions[worker.speechOverrideOwner] == nil { restoreCtx, cancel := context.WithTimeout(ctx, 6*time.Second) var err error if worker.settings.EnableSpeech { err = worker.updateSpeech(restoreCtx, worker.settings) } cancel() if err != nil { worker.logger.Error("restore speech after tentacle revocation", "error", err) worker.announceSpeechSetting("The tentacle stopped, but normal speech settings could not be restored. The working voice has been retained.") return } worker.speechOverride, worker.speechOverrideOwner = nil, "" if worker.live != nil { worker.live.Store(worker.settings) } } } func (worker *preferencesWorker) load_page_store() { if worker.pageStore != nil { return } worker.pageStore = &tentacle.PageStore{Path: filepath.Join(filepath.Dir(worker.settings.Path), "tentacle-preferences.json")} worker.savedPages, worker.pageLoadError = worker.pageStore.Load() if worker.savedPages == nil { worker.savedPages = tentacle.PageValues{} } if worker.pageLoadError != nil { worker.logger.Warn("load tentacle preferences", "error", worker.pageLoadError) } } func (worker *preferencesWorker) effective_settings(base config.Settings) config.Settings { if worker.speechOverride != nil { return worker.speechOverride.Apply(base) } return base } func (worker *preferencesWorker) extension_pages() []preferences.ExtensionPage { if worker.editApp != "" { return nil } var pages []preferences.ExtensionPage for _, id := range sorted_extension_ids(worker.extensions) { extension := worker.extensions[id] for _, page := range extension.value.Pages { pages = append(pages, preferences.ExtensionPage{Session: id, Revision: extension.revision, PreferencePage: copy_reader_value(page)}) } } return pages } func sorted_extension_ids(extensions map[string]*readerExtension) []string { ids := make([]string, 0, len(extensions)) for id := range extensions { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { left, right := extensions[ids[i]].session.TentacleID(), extensions[ids[j]].session.TentacleID() if left != right { return left < right } return ids[i] < ids[j] }) return ids } func (worker *preferencesWorker) reader_state(extension *readerExtension) tentaclewire.ReaderState { return copy_reader_value(tentaclewire.ReaderState{Speech: tentaclewire.SpeechFromSettings(worker.effective_settings(worker.settings)), DefaultSpeech: tentaclewire.SpeechFromSettings(worker.settings), Extensions: extension.value}) } func (worker *preferencesWorker) request_reader(ctx context.Context, session *tentacle.Session, request tentaclewire.ReaderRequest) (*tentaclewire.ReaderState, error) { call := readerSettingsCall{ctx: ctx, session: session, request: request, result: make(chan readerSettingsResult, 1)} select { case worker.extensionRequests <- call: case <-ctx.Done(): return nil, ctx.Err() default: return nil, tentacle.ErrBusy } select { case result := <-call.result: return result.state, result.err case <-ctx.Done(): return nil, ctx.Err() case <-worker.done: return nil, tentacle.ErrUnavailable } } func (worker *preferencesWorker) handle_reader_call(ctx context.Context, call readerSettingsCall, windowOpen bool) { callCtx, cancel := context.WithCancel(ctx) defer cancel() stop := context.AfterFunc(call.ctx, cancel) defer stop() if call.session != nil { stopSession := context.AfterFunc(call.session.OutputContext(), cancel) defer stopSession() } worker.sync_extensions(ctx) state, err := worker.apply_reader_request(callCtx, call.session, call.request, windowOpen) call.result <- readerSettingsResult{state, err} } func (worker *preferencesWorker) reader_allowed(ctx context.Context, session *tentacle.Session) error { if err := ctx.Err(); err != nil { return err } if session == nil { return tentacle.ErrUnavailable } if state, _ := session.Status(); state != tentacle.Active || worker.extensions[session.Identity()] == nil { return tentacle.ErrUnavailable } if worker.tentacles != nil && worker.tentacles.output != nil && worker.tentacles.output.gate != nil { if worker.tentacles.output.gate.Locked() || worker.tentacles.output.gate.Suspended() { return errors.New("reader interaction is unavailable") } } return nil } func (worker *preferencesWorker) reader_transaction() readerTransaction { extensions := map[string]*readerExtension{} for id, extension := range worker.extensions { extensions[id] = &readerExtension{session: extension.session, value: copy_reader_value(extension.value), revision: extension.revision} } return readerTransaction{extensions, copy_reader_value(worker.speechOverride), worker.speechOverrideOwner} } func (worker *preferencesWorker) stage_reader_request(ctx context.Context, transaction *readerTransaction, session *tentacle.Session, request tentaclewire.ReaderRequest) error { if err := worker.reader_allowed(ctx, session); err != nil { return err } if err := tentaclewire.ValidateReaderRequest(request); err != nil { return err } id := session.Identity() if request.Extensions != nil { allowed := map[string]bool{} for _, command := range session.Commands() { if command.Reader && !command.Settings { allowed[command.ID] = true } } for _, page := range request.Extensions.Pages { if !allowed[page.Command] { return tentacle.ErrProtocol } } for _, setting := range request.Extensions.QuickSettings { if !allowed[setting.Command] { return tentacle.ErrProtocol } } transaction.extensions[id].value = copy_reader_value(*request.Extensions) transaction.extensions[id].revision++ } if request.Speech != nil || request.RestoreSpeech { if transaction.override != nil && transaction.owner != id { return errors.New("another tentacle owns the active speech override") } if request.RestoreSpeech { transaction.override, transaction.owner = nil, "" } else { next := request.Speech.Apply(worker.settings) if err := config.Validate(next); err != nil { return err } transaction.override, transaction.owner = copy_reader_value(request.Speech), id } } return validate_reader_extensions(transaction.extensions) } func (worker *preferencesWorker) commit_reader_transaction(ctx context.Context, session *tentacle.Session, transaction readerTransaction) error { if err := worker.reader_allowed(ctx, session); err != nil { return err } next := worker.settings if transaction.override != nil { next = transaction.override.Apply(next) } if !reflect.DeepEqual(transaction.override, worker.speechOverride) && next.EnableSpeech { operationCtx, cancel := context.WithTimeout(ctx, 6*time.Second) stop := context.AfterFunc(session.OutputContext(), cancel) err := worker.updateSpeech(operationCtx, next) stop() cancel() if err != nil { return err } if err := worker.reader_allowed(ctx, session); err != nil { restoreCtx, restoreCancel := context.WithTimeout(context.Background(), 6*time.Second) defer restoreCancel() if restoreErr := worker.updateSpeech(restoreCtx, worker.effective_settings(worker.settings)); restoreErr != nil { worker.logger.Error("restore revoked speech transaction", "error", restoreErr) } return err } } worker.extensions = transaction.extensions worker.speechOverride, worker.speechOverrideOwner = transaction.override, transaction.owner if worker.live != nil { worker.live.Store(next) } return nil } func (worker *preferencesWorker) apply_reader_request(ctx context.Context, session *tentacle.Session, request tentaclewire.ReaderRequest, windowOpen bool) (*tentaclewire.ReaderState, error) { if err := worker.reader_allowed(ctx, session); err != nil { return nil, err } if windowOpen && (request.Speech != nil || request.RestoreSpeech || request.Extensions != nil) { return nil, errors.New("close preferences before changing reader settings from a command") } transaction := worker.reader_transaction() if err := worker.stage_reader_request(ctx, &transaction, session, request); err != nil { return nil, err } if err := worker.commit_reader_transaction(ctx, session, transaction); err != nil { return nil, err } state := worker.reader_state(worker.extensions[session.Identity()]) if request.Snapshot { catalogCtx, cancel := context.WithTimeout(ctx, 4*time.Second) catalog, err := worker.discover(catalogCtx, worker.socket) cancel() if err != nil { return &state, errors.New("speech voice catalog is unavailable") } for module := range catalog { if module != "" { state.Modules = append(state.Modules, module) } } sort.Strings(state.Modules) state.Voices = append(state.Voices, catalog[request.Module]...) // Keep a continuation below the RPC argument limit, explicitly marking // partial catalogs instead of silently dropping voices. for { data, _ := json.Marshal(tentaclewire.Submission{Reader: &state}) if len(data) <= tentacle.MaxReaderArgumentBytes { break } state.CatalogTruncated = true if len(state.Voices) > 0 { state.Voices = state.Voices[:len(state.Voices)/2] } else if len(state.Modules) > 0 { state.Modules = state.Modules[:len(state.Modules)/2] } else { return nil, tentacle.ErrLimit } } } return &state, nil } func (worker *preferencesWorker) extension_effect(ctx context.Context, extension *readerExtension, command string, event tentaclewire.ExtensionEvent) (tentaclewire.ReaderRequest, error) { arguments := any(event) for transition := 0; transition < 16; transition++ { if err := worker.reader_allowed(ctx, extension.session); err != nil { return tentaclewire.ReaderRequest{}, err } data, err := json.Marshal(arguments) if err != nil { return tentaclewire.ReaderRequest{}, err } replies, err := extension.session.Invoke(ctx, command, data) if err != nil { return tentaclewire.ReaderRequest{}, err } result := <-replies if result.Err != nil { return tentaclewire.ReaderRequest{}, result.Err } value, err := tentaclewire.DecodeInteraction(result.Value) if err != nil || value.Reader == nil || value.Form != nil || value.Poll != "" || value.Source != "" || value.Clipboard != nil || value.Speech != "" || value.Braille != "" { return tentaclewire.ReaderRequest{}, errors.New("extension callback must return only a reader settings proposal") } if !value.Reader.Snapshot { if value.Next != "" { return tentaclewire.ReaderRequest{}, tentacle.ErrProtocol } return *value.Reader, nil } if !worker.tentacles.reader_continuation(tentacleCommand{session: extension.session}, value.Next) { return tentaclewire.ReaderRequest{}, tentacle.ErrProtocol } state, snapshotErr := worker.apply_reader_request(ctx, extension.session, *value.Reader, true) submission := tentaclewire.Submission{Reader: state} if snapshotErr != nil { submission.Error = snapshotErr.Error() } arguments, command = submission, value.Next } return tentaclewire.ReaderRequest{}, errors.New("reader callback continuation limit reached") } // Optional page loading happens before GTK constructs controls. Handlers may // inspect the reader/catalog and replace declarations, but cannot apply speech. func (worker *preferencesWorker) load_extension_pages(ctx context.Context) error { loadCtx, cancel := context.WithTimeout(ctx, 6*time.Second) defer cancel() var failures error for _, view := range worker.extension_pages() { if !view.Load { continue } extension := worker.extensions[view.Session] if extension == nil { continue } event := tentaclewire.ExtensionEvent{Event: "preferences.open", ID: view.ID, Reader: worker.reader_state(extension)} request, err := worker.extension_effect(loadCtx, extension, view.Command, event) if err == nil && (request.Speech != nil || request.RestoreSpeech || request.Extensions == nil) { err = errors.New("opening a preference page may only update its declarations") } transaction := worker.reader_transaction() if err == nil { err = worker.stage_reader_request(loadCtx, &transaction, extension.session, request) } if err == nil { worker.extensions = transaction.extensions } else { failures = errors.Join(failures, err) } } worker.sync_extensions(ctx) return failures } func (worker *preferencesWorker) prepare_extension_pages(ctx context.Context, drafts []preferences.PageDraft) (readerTransaction, error) { worker.sync_extensions(ctx) transaction := worker.reader_transaction() if len(drafts) == 0 { return transaction, nil } if worker.editApp != "" || worker.choosingApplication { return transaction, errors.New("tentacle pages are global preferences") } seen := map[string]bool{} speechProposals := 0 for _, draft := range drafts { key := draft.Session + "/" + draft.ID extension := worker.extensions[draft.Session] if extension == nil || extension.revision != draft.Revision || seen[key] { return transaction, errors.New("tentacle page changed or was removed; reopen preferences") } seen[key] = true var page *tentaclewire.PreferencePage for _, candidate := range extension.value.Pages { if candidate.ID == draft.ID { copy := candidate page = © break } } if page == nil { return transaction, tentacle.ErrProtocol } if err := tentaclewire.ValidatePageValues(*page, draft.Values); err != nil { return transaction, err } for _, element := range page.Elements { if element.Disabled && !reflect.DeepEqual(page.Values[element.ID], draft.Values[element.ID]) { return transaction, tentacle.ErrProtocol } } if reflect.DeepEqual(page.Values, draft.Values) { continue } event := tentaclewire.ExtensionEvent{Event: "preferences.apply", ID: page.ID, Values: draft.Values, Reader: worker.reader_state(extension)} request, err := worker.extension_effect(ctx, extension, page.Command, event) if err != nil { return transaction, err } if request.Speech != nil || request.RestoreSpeech { speechProposals++ } if speechProposals > 1 { return transaction, errors.New("multiple pages proposed conflicting speech changes") } if err := worker.stage_reader_request(ctx, &transaction, extension.session, request); err != nil { return transaction, err } staged := transaction.extensions[draft.Session] found := false for index := range staged.value.Pages { if staged.value.Pages[index].ID == page.ID { // A callback may replace choices or normalize input. Otherwise // the successfully submitted draft becomes its committed value. if request.Extensions == nil { staged.value.Pages[index].Values = copy_reader_value(draft.Values) } found = true } } if !found { return transaction, errors.New("an applied page cannot remove itself") } staged.revision++ } return transaction, validate_reader_extensions(transaction.extensions) } func validate_reader_extensions(extensions map[string]*readerExtension) error { total := 0 for _, extension := range extensions { if err := tentaclewire.ValidateExtensions(extension.value); err != nil { return err } data, err := json.Marshal(extension.value) if err != nil { return err } total += len(data) } if total > 256*1024 { return tentacle.ErrLimit } return nil } func (worker *preferencesWorker) save_extension_pages() error { if worker.pageStore == nil { return nil } if worker.pageLoadError != nil { return worker.pageLoadError } values := copy_reader_value(worker.savedPages) if values == nil { values = tentacle.PageValues{} } for _, extension := range worker.extensions { pages := map[string]map[string]interaction.Value{} for _, page := range extension.value.Pages { pages[page.ID] = copy_reader_value(page.Values) } if len(pages) > 0 { values[extension.session.TentacleID()] = pages } } if reflect.DeepEqual(values, worker.savedPages) { return nil } if err := worker.pageStore.Save(values); err != nil { return err } worker.savedPages = values return nil } type readerQuickSetting struct { key string extension *readerExtension setting tentaclewire.QuickSetting } func (worker *preferencesWorker) quick_settings() []readerQuickSetting { var result []readerQuickSetting for _, id := range sorted_extension_ids(worker.extensions) { extension := worker.extensions[id] for _, setting := range extension.value.QuickSettings { result = append(result, readerQuickSetting{id + "/" + setting.ID, extension, setting}) } } return result } func (worker *preferencesWorker) handle_extension_quick(ctx context.Context, setting readerQuickSetting, adjust bool, delta int) { selected := 0 for index, option := range setting.setting.Options { if option.ID == setting.setting.Selected { selected = index break } } candidate := selected if adjust { for index := selected + delta; index >= 0 && index < len(setting.setting.Options); index += delta { if !setting.setting.Options[index].Disabled { candidate = index break } } } option := setting.setting.Options[candidate] if candidate != selected { event := tentaclewire.ExtensionEvent{Event: "quick-setting", ID: setting.setting.ID, Selected: option.ID, Reader: worker.reader_state(setting.extension)} request, err := worker.extension_effect(ctx, setting.extension, setting.setting.Command, event) transaction := worker.reader_transaction() if err == nil { err = worker.stage_reader_request(ctx, &transaction, setting.extension.session, request) } if err == nil { found := false extension := transaction.extensions[setting.extension.session.Identity()] for index := range extension.value.QuickSettings { current := &extension.value.QuickSettings[index] if current.ID != setting.setting.ID { continue } for _, choice := range current.Options { if choice.ID == option.ID && !choice.Disabled { current.Selected = option.ID found = true break } } } if !found { err = errors.New("selected quick setting was removed by its callback") } } if err == nil { err = worker.commit_reader_transaction(ctx, setting.extension.session, transaction) } if err != nil { worker.logger.Warn("apply tentacle quick setting", "error", err) worker.announceSpeechSetting(setting.setting.Label + " was not changed") return } } worker.announceSpeechSetting(setting.setting.Label + " " + option.Label) } func (runtime *tentacleRuntime) reader_continuation(command tentacleCommand, id string) bool { for _, registered := range command.session.Commands() { if registered.ID == id { return registered.Reader && !registered.Settings } } return false }