Files
cthulhu/internal/app/tentacle_reader.go

644 lines
21 KiB
Go

// 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 = &copy
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
}