From 1d75168807d1aab2afa2461fd3e61484bb5f88ea Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 22 Oct 2026 21:13:08 -0400 Subject: [PATCH] Extended tentacle capabilities a bit. --- README.md | 1 + cmd/tentacle-host/main.go | 3 +- .../Arch-Linux/cthulhu-git/PKGBUILD | 2 +- internal/app/app.go | 3 + internal/app/echo_speaker_test.go | 32 + internal/app/preferences.go | 18 +- internal/app/preferences_apply.go | 65 +- internal/app/preferences_preview.go | 2 +- internal/app/preferences_session.go | 22 +- internal/app/speech_settings.go | 43 +- internal/app/tentacle_interaction.go | 24 +- internal/app/tentacle_management.go | 1 + internal/app/tentacle_reader.go | 643 +++++++++++++++++ internal/app/tentacle_reader_test.go | 665 ++++++++++++++++++ internal/app/tentacles.go | 10 +- internal/interaction/session.go | 17 +- internal/preferences/extensions.go | 117 +++ internal/preferences/native.c | 5 +- internal/preferences/native.h | 11 + internal/preferences/native_extensions.c | 138 ++++ internal/preferences/native_internal.h | 1 + internal/preferences/protocol.go | 28 +- internal/preferences/session.go | 1 + internal/preferences/tentacles.go | 3 + internal/preferences/ui.go | 28 +- internal/tentacle/install.go | 8 +- internal/tentacle/install_test.go | 47 ++ internal/tentacle/manager.go | 13 +- internal/tentacle/page_store.go | 105 +++ internal/tentacle/page_store_test.go | 73 ++ internal/tentacle/process.go | 12 +- internal/tentacle/protocol.go | 43 +- internal/tentacle/reader_test.go | 111 +++ internal/tentacle/registry.go | 3 +- internal/tentacle/session.go | 64 +- internal/tentacle/testdata/sdk_reader_peer.py | 20 + internal/tentaclewire/interaction.go | 8 +- internal/tentaclewire/presentation.go | 4 +- internal/tentaclewire/reader.go | 247 +++++++ internal/tentaclewire/reader_test.go | 134 ++++ sdk/go/tentacle/tentacle.go | 48 +- sdk/python/cthulhu_tentacle/__init__.py | 32 +- tests/native_preferences.py | 7 + tests/native_tentacle_extensions.py | 100 +++ 44 files changed, 2874 insertions(+), 88 deletions(-) create mode 100644 internal/app/tentacle_reader.go create mode 100644 internal/app/tentacle_reader_test.go create mode 100644 internal/preferences/extensions.go create mode 100644 internal/preferences/native_extensions.c create mode 100644 internal/tentacle/page_store.go create mode 100644 internal/tentacle/page_store_test.go create mode 100644 internal/tentacle/reader_test.go create mode 100644 internal/tentacle/testdata/sdk_reader_peer.py create mode 100644 internal/tentaclewire/reader.go create mode 100644 internal/tentaclewire/reader_test.go create mode 100644 tests/native_tentacle_extensions.py diff --git a/README.md b/README.md index c2c0bd3..d7ab107 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Cthulhu is a Linux screen reader written in Go. - [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance. - [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification. - [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation. +- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). diff --git a/cmd/tentacle-host/main.go b/cmd/tentacle-host/main.go index edc0a3c..4a50a6c 100644 --- a/cmd/tentacle-host/main.go +++ b/cmd/tentacle-host/main.go @@ -28,6 +28,7 @@ func run() (runError error) { cgroupParent := options.String("cgroup-parent", "", "delegated cgroup v2 parent for resource containment") presentation := options.Bool("presentation", false, "grant command speech/braille result capability (prints JSON only)") interactive := options.Bool("interaction", false, "grant internal form/clipboard/focused-item capability (prints initial JSON only)") + readerSettings := options.Bool("reader-settings", false, "grant declarative reader settings capability (prints initial JSON only; does not modify a reader)") command := options.String("command", "echo", "registered command to invoke") if err := options.Parse(os.Args[1:]); err != nil { return err @@ -52,7 +53,7 @@ func run() (runError error) { }() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - session, err := manager.Start(ctx, tentacle.Spec{ID: "local-example", Program: options.Args(), Presentation: *presentation, Interaction: *interactive}) + session, err := manager.Start(ctx, tentacle.Spec{ID: "local-example", Program: options.Args(), Presentation: *presentation, Interaction: *interactive, ReaderSettings: *readerSettings}) if err != nil { return err } diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index aa5c2ad..1f492c0 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -4,7 +4,7 @@ pkgbase=cthulhu-git pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.10.22.r65.ge62f8c8 +pkgver=2026.10.22.r70.gd7b1efe pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/internal/app/app.go b/internal/app/app.go index e9aafe8..f292b10 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -283,6 +283,9 @@ func Run(ctx context.Context, options Options) error { } preferences := newPreferencesWorker(logger, settings, settingsUpdater) preferences.tentacles = tentacles + if tentacles != nil { + tentacles.readerSettings = preferences + } preferences.live = live live.onApplication = preferences.SetApplication preferences.bindings, _ = commands.(interface { diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go index 67f7583..1dbf3ad 100644 --- a/internal/app/echo_speaker_test.go +++ b/internal/app/echo_speaker_test.go @@ -21,6 +21,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/remote" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) func TestEchoSpeakerSwitchesBothVoicesTransactionally(t *testing.T) { @@ -160,6 +161,37 @@ func test_echo_speaker(t *testing.T, wrapped bool) { } check(false, "", "") check(true, "echo-module", "62") + if wrapped { + // Exercise the new request path through the actual configured backend + // and private SSIP connections, including a failure in the echo client + // after preparation of the new main client has already succeeded. + readerWorker, session, _ := reader_extension_fixture(t, "normal") + base := settings + base.Path = readerWorker.settings.Path + readerWorker.settings, readerWorker.globalSettings = base, base + readerWorker.updater = speaker + readerWorker.live.Store(base) + configuration := tentaclewire.SpeechFromSettings(base) + configuration.Module = "tentacle-module" + profileRate := 65.0 + configuration.DefaultVoice.Rate = &profileRate + if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &configuration}, false); err != nil { + t.Fatal(err) + } + check(false, "tentacle-module", "32") + check(true, "echo-module", "62") + configuration.Module, configuration.EchoServer = "candidate-module", "rejected" + if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &configuration}, false); err == nil { + t.Fatal("failed echo client accepted through reader API") + } + check(false, "tentacle-module", "32") + check(true, "echo-module", "62") + if _, err := readerWorker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err != nil { + t.Fatal(err) + } + check(false, "", "") + check(true, "echo-module", "62") + } // Key names use completion notifications on the selected voice's real // SSIP connection, so following navigation cannot cancel an active echo. keySpeech := newSpeechWorker(slog.New(slog.DiscardHandler), speaker) diff --git a/internal/app/preferences.go b/internal/app/preferences.go index b3ed939..6c70f45 100644 --- a/internal/app/preferences.go +++ b/internal/app/preferences.go @@ -14,6 +14,8 @@ import ( "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/speech" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type speechSettingsUpdater interface { @@ -31,6 +33,16 @@ type brailleSettingsUpdater interface { type preferencesWorker struct { tentacles *tentacleRuntime logger *slog.Logger + stagedReader *readerTransaction + extensionRequests chan readerSettingsCall + extensions map[string]*readerExtension + pageStore *tentacle.PageStore + savedPages tentacle.PageValues + pageLoadError error + speechOverride *tentaclewire.SpeechConfiguration + speechOverrideOwner string + extensionRevision uint64 + quickSettingKey string settings config.Settings globalSettings config.Settings appSettings map[string]config.Settings @@ -70,7 +82,7 @@ type preferencesWorker struct { func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker { return &preferencesWorker{logger: logger, settings: settings, globalSettings: settings, appSettings: make(map[string]config.Settings), updater: updater, - speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})} + extensionRequests: make(chan readerSettingsCall, 16), extensions: make(map[string]*readerExtension), speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})} } func (worker *preferencesWorker) Start(ctx context.Context) error { @@ -81,6 +93,10 @@ func (worker *preferencesWorker) Start(ctx context.Context) error { select { case <-ctx.Done(): return + case <-worker.extension_changes(): + worker.sync_extensions(ctx) + case call := <-worker.extensionRequests: + worker.handle_reader_call(ctx, call, false) case command := <-worker.speechCommands: worker.handleSpeechCommand(ctx, command) case change := <-worker.remoteChanges: diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go index bf472a8..f6e05c0 100644 --- a/internal/app/preferences_apply.go +++ b/internal/app/preferences_apply.go @@ -13,6 +13,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) func (worker *preferencesWorker) response(message string, accepted bool) preferences.Response { @@ -24,7 +25,7 @@ func (worker *preferencesWorker) response(message string, accepted bool) prefere if err == nil { _, err = store.Load() } - return preferences.Response{Values: preferences.FromSettings(settings), CanSave: err == nil, + return preferences.Response{Values: preferences.FromSettings(settings), ExtensionPages: worker.extension_pages(), CanSave: err == nil, Message: message, Accepted: accepted} } @@ -37,7 +38,20 @@ func (worker *preferencesWorker) apply(ctx context.Context, request preferences. if err != nil { return worker.response("Settings were not applied: "+err.Error(), false) } - return worker.applyNext(ctx, next, request.Save, request.Close) + transaction, err := worker.prepare_extension_pages(ctx, request.ExtensionPages) + if err != nil { + return worker.response("Tentacle settings were not applied: "+err.Error(), false) + } + worker.stagedReader = &transaction + defer func() { worker.stagedReader = nil }() + response := worker.applyNext(ctx, next, request.Save, request.Close) + if response.Accepted && request.Save && len(request.ExtensionPages) > 0 { + if err := worker.save_extension_pages(); err != nil { + response.Message = "Applied for this session, but tentacle preferences could not be saved: " + err.Error() + response.Close = false + } + } + return response } func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Settings, save, close bool) preferences.Response { @@ -115,19 +129,35 @@ func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.S } }() } - speechChanged := next.RaisePitchForCapitals != worker.settings.RaisePitchForCapitals || - next.SpeechServerFactory != worker.settings.SpeechServerFactory || - !reflect.DeepEqual(next.UppercaseVoice, worker.settings.UppercaseVoice) || - !reflect.DeepEqual(next.HyperlinkVoice, worker.settings.HyperlinkVoice) || - !reflect.DeepEqual(next.SystemVoice, worker.settings.SystemVoice) || - next.HardwareSpeechDevice != worker.settings.HardwareSpeechDevice || - next.HardwareSpeechBaudRate != worker.settings.HardwareSpeechBaudRate || !reflect.DeepEqual(speechSettingsFromConfig(next), speechSettingsFromConfig(worker.settings)) || - next.UseCustomEchoVoice != worker.settings.UseCustomEchoVoice || - (next.UseCustomEchoVoice && !reflect.DeepEqual(echoSpeechSettings(next), echoSpeechSettings(worker.settings))) + effectiveNext := worker.effective_settings(next) + effectiveCurrent := worker.effective_settings(worker.settings) + if worker.stagedReader != nil { + for _, extension := range worker.stagedReader.extensions { + if err := worker.reader_allowed(ctx, extension.session); err != nil { + return err + } + } + effectiveNext = next + if worker.stagedReader.override != nil { + effectiveNext = worker.stagedReader.override.Apply(next) + } + } + speechChanged := !reflect.DeepEqual(tentaclewire.SpeechFromSettings(effectiveNext), tentaclewire.SpeechFromSettings(effectiveCurrent)) + speechApplied := false + defer func() { + if speechApplied && !bindingsCommitted { + restoreCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + if err := worker.updateSpeech(restoreCtx, effectiveCurrent); err != nil { + worker.logger.Error("restore speech after failed preferences apply", "error", err) + } + } + }() if next.EnableSpeech && (speechChanged || !worker.settings.EnableSpeech) { - if message := worker.apply_speech_settings(ctx, next); message != "" { + if message := worker.apply_speech_settings(ctx, effectiveNext); message != "" { return errors.New(message) } + speechApplied = true } if next.EnableBraille != worker.settings.EnableBraille { if worker.braille == nil { @@ -143,6 +173,15 @@ func (worker *preferencesWorker) applyRuntime(ctx context.Context, next config.S updater.SetContraction(next.EnableContractedBraille, next.BrailleContractionTable) } } + if worker.stagedReader != nil { + for _, extension := range worker.stagedReader.extensions { + if err := worker.reader_allowed(ctx, extension.session); err != nil { + return err + } + } + worker.extensions = worker.stagedReader.extensions + worker.speechOverride, worker.speechOverrideOwner = worker.stagedReader.override, worker.stagedReader.owner + } bindingsCommitted = true worker.commit_settings(next) return nil @@ -216,7 +255,7 @@ func (worker *preferencesWorker) commit_settings(next config.Settings) { worker.textAttributes.SetCaptureTextAttributes(captureTextAttributes(next)) } if worker.live != nil { - worker.live.Store(next) + worker.live.Store(worker.effective_settings(next)) } if worker.speechWorker != nil && next.EnableSpeech { worker.speechWorker.SetEnabled(true) diff --git a/internal/app/preferences_preview.go b/internal/app/preferences_preview.go index 6d06586..07a65aa 100644 --- a/internal/app/preferences_preview.go +++ b/internal/app/preferences_preview.go @@ -130,7 +130,7 @@ func echo_preview_speech(label, value string) speechRequest { func (worker *preferencesWorker) restorePreview(ctx context.Context) preferences.Response { response := worker.response("Speech settings restored.", false) - if err := worker.updateSpeech(ctx, worker.settings); err != nil { + if err := worker.updateSpeech(ctx, worker.effective_settings(worker.settings)); err != nil { response.Message = "Speech settings could not be restored: " + err.Error() return response } diff --git a/internal/app/preferences_session.go b/internal/app/preferences_session.go index 36982ad..a032d82 100644 --- a/internal/app/preferences_session.go +++ b/internal/app/preferences_session.go @@ -115,6 +115,10 @@ func (worker *preferencesWorker) initial_response(childCtx context.Context) (pre 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 @@ -128,6 +132,13 @@ func (worker *preferencesWorker) initial_response(childCtx context.Context) (pre if err != nil { worker.logger.Debug("discover echo voices", "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 } @@ -164,7 +175,7 @@ func (worker *preferencesWorker) serve_preferences(ctx, childCtx context.Context } restoreCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second) defer cancel() - if err := worker.updateSpeech(restoreCtx, worker.settings); err != nil { + if err := worker.updateSpeech(restoreCtx, worker.effective_settings(worker.settings)); err != nil { worker.logger.Error("restore speech settings after preferences exit", "error", err) } }() @@ -172,6 +183,15 @@ func (worker *preferencesWorker) serve_preferences(ctx, childCtx context.Context 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.") diff --git a/internal/app/speech_settings.go b/internal/app/speech_settings.go index 268d28d..8169f5a 100644 --- a/internal/app/speech_settings.go +++ b/internal/app/speech_settings.go @@ -15,6 +15,7 @@ import ( "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/speech" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) // Use the settings worker's bounded mailbox, keeping discovery, disk writes, @@ -62,18 +63,47 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman } return } + worker.sync_extensions(ctx) + quickSettings := worker.quick_settings() + settingIndex := worker.speechSetting + if worker.quickSettingKey != "" { + settingIndex = 4 + found := false + for index, setting := range quickSettings { + if setting.key == worker.quickSettingKey { + settingIndex = 5 + index + found = true + break + } + } + if !found && (command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting) { + worker.quickSettingKey, worker.speechSetting = "", 4 + worker.announceSpeechSetting("The selected speech setting is no longer available") + return + } + } switch command { case input.SelectPreviousSpeechSetting: - worker.speechSetting = max(0, worker.speechSetting-1) + settingIndex = max(0, settingIndex-1) case input.SelectNextSpeechSetting: - worker.speechSetting = min(4, worker.speechSetting+1) + settingIndex = min(4+len(quickSettings), settingIndex+1) } - next := worker.settings + worker.quickSettingKey = "" + if settingIndex >= 5 { + worker.quickSettingKey = quickSettings[settingIndex-5].key + } else { + worker.speechSetting = settingIndex + } + next := worker.effective_settings(worker.settings) adjust := command == input.IncreaseSpeechSetting || command == input.DecreaseSpeechSetting delta := 1 if command == input.DecreaseSpeechSetting { delta = -1 } + if settingIndex >= 5 { + worker.handle_extension_quick(ctx, quickSettings[settingIndex-5], adjust, delta) + return + } message := "" switch worker.speechSetting { case 0, 1, 2: @@ -193,7 +223,12 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman worker.announceSpeechSetting("Speech settings were not applied") return } - worker.settings = next + if worker.speechOverride != nil { + value := tentaclewire.SpeechFromSettings(next) + worker.speechOverride = &value + } else { + worker.settings = next + } if worker.live != nil { worker.live.Store(next) } diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index 178be0d..2ba2f5e 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -61,6 +61,28 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo value.Presentation = clipboardWriteFailure(value.Presentation) } } + if value.Reader != nil { + if !command.reader || runtime.readerSettings == nil || value.Next != "" && !runtime.reader_continuation(command, value.Next) { + command.session.Disable() + return + } + state, readerErr := runtime.readerSettings.request_reader(ctx, command.session, *value.Reader) + if value.Next == "" { + if readerErr != nil { + runtime.report("Tentacle settings were not applied: " + readerErr.Error()) + } + return + } + submission := tentaclewire.Submission{Reader: state} + if readerErr != nil { + submission.Error = readerErr.Error() + } + raw, err = runtime.callInteraction(ctx, command, value.Next, submission) + if err != nil { + return + } + continue + } if value.Form == nil && value.Source == "" { runtime.output.Present(runtime.output.Capture(command.session.OutputContext()), value.Presentation, "tentacle/"+command.id) return @@ -173,7 +195,7 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te return internalUIResult{Cancelled: true}, nil } status, err := tentaclewire.DecodeInteraction(raw) - if err != nil || status.Form != nil || status.Source != "" || status.Next != "" { + if err != nil || status.Form != nil || status.Source != "" || status.Next != "" || status.Reader != nil { command.session.Disable() return internalUIResult{Cancelled: true}, nil } diff --git a/internal/app/tentacle_management.go b/internal/app/tentacle_management.go index 774506f..7bce3b4 100644 --- a/internal/app/tentacle_management.go +++ b/internal/app/tentacle_management.go @@ -520,6 +520,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error { case <-ctx.Done(): } runtime.output.RetireRevoked() + runtime.signal_extensions() notify() <-session.Done() notify() diff --git a/internal/app/tentacle_reader.go b/internal/app/tentacle_reader.go new file mode 100644 index 0000000..5cf9dbd --- /dev/null +++ b/internal/app/tentacle_reader.go @@ -0,0 +1,643 @@ +// 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 +} diff --git a/internal/app/tentacle_reader_test.go b/internal/app/tentacle_reader_test.go new file mode 100644 index 0000000..c6934e4 --- /dev/null +++ b/internal/app/tentacle_reader_test.go @@ -0,0 +1,665 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/preferences" + "git.stormux.org/storm/cthulhu/internal/speech" + "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" +) + +// A private protocol fixture, not an installable or bundled tentacle. +func reader_probe_extensions() sdk.Extensions { + return sdk.Extensions{Pages: []sdk.PreferencePage{{ID: "probe", Title: "Extension probe", Command: "apply", Elements: []sdk.Element{ + {ID: "rate", Label: "Probe rate", Kind: sdk.Entry, Required: true, MaxLength: 3}, + {ID: "enabled", Label: "Probe enabled", Kind: sdk.CheckBox}, + {ID: "choice", Label: "Probe choice", Kind: sdk.List, Items: []sdk.ListItem{{ID: "first", Label: "First choice"}, {ID: "second", Label: "Second choice"}}}, + }, Values: map[string]sdk.Value{"rate": {Text: "70"}, "enabled": {}, "choice": {Selected: "first"}}}}, + QuickSettings: []sdk.QuickSetting{{ID: "probe", Label: "Probe mode", Command: "apply", Selected: "default", Options: []sdk.ListItem{{ID: "default", Label: "Default"}, {ID: "fast", Label: "Fast"}, {ID: "unavailable", Label: "Unavailable"}}}}} +} +func TestTentacleReaderExtensionPeer(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "reader-extension-peer" { + return + } + mode := os.Args[len(os.Args)-1] + commands := []sdk.Command{{ID: "apply", Title: "Apply probe", Presentation: true, Interaction: true, Reader: true, Handle: func(ctx context.Context, arguments json.RawMessage) (any, error) { + var event sdk.ExtensionEvent + if err := json.Unmarshal(arguments, &event); err != nil { + return nil, err + } + if mode == "reject" { + return nil, errors.New("rejected") + } + if mode == "crash" { + os.Exit(3) + } + if event.Event == "exit" { + os.Exit(3) + } + if mode == "slow" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Minute): + return nil, nil + } + } + if event.Event == "preferences.open" { + if mode == "load-denied" { + configuration := event.Reader.DefaultSpeech + return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil + } + if mode == "load-snapshot" { + return sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true, Module: "alpha"}, Next: "receive"}, nil + } + extensions := event.Reader.Extensions + extensions.Pages[0].Elements[1].Label = "Loaded probe enabled" + return sdk.Interaction{Reader: &sdk.ReaderRequest{Extensions: &extensions}}, nil + } + if event.Event == "preferences.apply" { + value, err := strconv.ParseFloat(event.Values["rate"].Text, 64) + if err != nil { + return nil, err + } + configuration := event.Reader.Speech + configuration.DefaultVoice.Rate = &value + extensions := event.Reader.Extensions + extensions.Pages[0].Values = event.Values + return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration, Extensions: &extensions}}, nil + } + if event.Event == "quick-setting" { + if event.Selected == "default" { + return sdk.Interaction{Reader: &sdk.ReaderRequest{RestoreSpeech: true}}, nil + } + configuration := event.Reader.Speech + rate := 80.0 + configuration.DefaultVoice.Rate = &rate + if event.Selected == "unavailable" { + configuration.Module = "unavailable" + } + return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil + } + if event.Event == "snapshot" { + return sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true}, Next: "receive"}, nil + } + return nil, errors.New("unknown event") + }}, {ID: "receive", Title: "Receive probe", Presentation: true, Interaction: true, Reader: true, Handle: func(ctx context.Context, arguments json.RawMessage) (any, error) { + var submission sdk.Submission + if err := json.Unmarshal(arguments, &submission); err != nil { + return nil, err + } + if submission.Reader == nil || submission.Error != "" { + return nil, errors.New("no reader state") + } + if mode == "load-snapshot" { + if len(submission.Reader.Voices) != 1 || submission.Reader.Voices[0].Name != "Example voice" { + return nil, errors.New("missing voice catalog") + } + extensions := submission.Reader.Extensions + extensions.Pages[0].Elements[1].Label = "Loaded catalog probe" + return sdk.Interaction{Reader: &sdk.ReaderRequest{Extensions: &extensions}}, nil + } + configuration := submission.Reader.DefaultSpeech + rate := 77.0 + configuration.DefaultVoice.Rate = &rate + return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil + }}} + extensions := reader_probe_extensions() + if strings.HasPrefix(mode, "load") { + extensions.Pages[0].Load = true + } + if err := sdk.ServeWithExtensions(os.Stdin, os.Stdout, commands, extensions); err != nil { + os.Exit(1) + } + os.Exit(0) +} + +type readerSpeechRecorder struct { + values []config.Settings + failure string + hook func() +} + +func (recorder *readerSpeechRecorder) UpdateConfiguration(ctx context.Context, next config.Settings) error { + if recorder.hook != nil { + recorder.hook() + } + if err := ctx.Err(); err != nil { + return err + } + if next.SpeechOutputModule == recorder.failure && recorder.failure != "" { + return errors.New("module unavailable") + } + recorder.values = append(recorder.values, copy_reader_value(next)) + return nil +} +func reader_extension_fixture(t *testing.T, mode string) (*preferencesWorker, *tentacle.Session, *readerSpeechRecorder) { + t.Helper() + manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if err := manager.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + session, err := manager.Start(t.Context(), tentacle.Spec{ID: "reader-probe", Program: []string{executable, "-test.run=^TestTentacleReaderExtensionPeer$", "--", "reader-extension-peer", mode}, Presentation: true, Interaction: true, ReaderSettings: true}) + if err != nil { + t.Fatal(err) + } + select { + case <-session.Ready(): + case <-time.After(5 * time.Second): + t.Fatal("handshake timed out") + } + if err := session.Activate(); err != nil { + state, reason := session.Status() + t.Fatal(err, state, reason) + } + runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), &tentacleBindingRecorder{}, "", "") + runtime.output = tentacleOutputFixture(t) + runtime.publishCommands([]*tentacle.Session{session}) + settings := config.DefaultSettings() + settings.Path = filepath.Join(t.TempDir(), "settings.toml") + rate := 50.0 + settings.DefaultVoice.Rate = &rate + recorder := &readerSpeechRecorder{failure: "unavailable"} + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, recorder) + worker.tentacles = runtime + runtime.readerSettings = worker + worker.live = &liveSettings{} + worker.live.Store(settings) + worker.discover = func(context.Context, string) (speech.Catalog, error) { + return speech.Catalog{"alpha": {{Name: "Example voice", Language: "en"}}}, nil + } + worker.sync_extensions(t.Context()) + return worker, session, recorder +} +func reader_page_request(worker *preferencesWorker, rate string) preferences.Request { + page := worker.extension_pages()[0] + values := copy_reader_value(page.Values) + values["rate"] = interaction.Value{Text: rate} + return preferences.Request{Values: preferences.FromSettings(worker.settings), ExtensionPages: []preferences.PageDraft{{Session: page.Session, ID: page.ID, Revision: page.Revision, Values: values}}} +} +func TestReaderExtensionPageApplyCancelSaveAndReopen(t *testing.T) { + worker, session, recorder := reader_extension_fixture(t, "normal") + before := copy_reader_value(worker.settings) + // Creating or editing a draft has no effect until Apply reaches the owner. + request := reader_page_request(worker, "73") + if len(recorder.values) != 0 || worker.speechOverride != nil { + t.Fatal("draft changed runtime") + } + request.Save = true + response := worker.apply(t.Context(), request) + if !response.Accepted { + t.Fatal(response.Message) + } + if len(recorder.values) != 1 || *recorder.values[0].DefaultVoice.Rate != 73 || *worker.live.Load().DefaultVoice.Rate != 73 { + t.Fatal("override was not applied") + } + if !reflect.DeepEqual(worker.settings, before) { + t.Fatal("override overwrote base settings") + } + if worker.extension_pages()[0].Values["rate"].Text != "73" { + t.Fatal("draft not committed") + } + store, _ := config.NewStore(worker.settings.Path) + saved, err := store.Load() + if err != nil || *saved.DefaultVoice.Rate != 50 { + t.Fatal("override was persisted as normal speech", err) + } + persisted, err := (&tentacle.PageStore{Path: worker.pageStore.Path}).Load() + if err != nil || persisted[session.TentacleID()]["probe"]["rate"].Text != "73" { + t.Fatal(persisted, err) + } + // Re-registering after a new session loads only saved page values, not speech. + worker.extensions = map[string]*readerExtension{} + worker.speechOverride, worker.speechOverrideOwner = nil, "" + worker.sync_extensions(t.Context()) + if worker.extension_pages()[0].Values["rate"].Text != "73" || worker.speechOverride != nil { + t.Fatal("page restoration activated speech") + } + response = worker.apply(t.Context(), request) + if response.Accepted { + t.Fatal("accepted a stale revision") + } +} +func TestReaderExtensionQuickNavigationRollbackDefaultAndBaseEdits(t *testing.T) { + worker, _, recorder := reader_extension_fixture(t, "normal") + var messages []string + worker.announce = func(message string) { messages = append(messages, message) } + worker.speechSetting = 4 + worker.handleSpeechCommand(t.Context(), input.SelectNextSpeechSetting) + if messages[len(messages)-1] != "Probe mode Default" { + t.Fatal(messages) + } + worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) + if *worker.live.Load().DefaultVoice.Rate != 80 || *worker.settings.DefaultVoice.Rate != 50 { + t.Fatal("quick override corrupted base") + } + worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) + if messages[len(messages)-1] != "Probe mode was not changed" || worker.quick_settings()[0].setting.Selected != "fast" || *worker.live.Load().DefaultVoice.Rate != 80 { + t.Fatal("failed switch committed") + } + // Built-in changes edit the active temporary override, not the normal voice. + worker.quickSettingKey = "" + worker.speechSetting = 0 + worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) + if *worker.live.Load().DefaultVoice.Rate != 81 || *worker.settings.DefaultVoice.Rate != 50 { + t.Fatal("built-in adjustment changed base") + } + // A core preference edit still targets and saves the ordinary settings. + next := worker.settings + pitch := 6.0 + next.DefaultVoice.AveragePitch = &pitch + response := worker.applyNext(t.Context(), next, false, false) + if !response.Accepted || *worker.live.Load().DefaultVoice.Rate != 81 { + t.Fatal(response.Message) + } + worker.quickSettingKey = worker.quick_settings()[0].key + worker.handleSpeechCommand(t.Context(), input.DecreaseSpeechSetting) + if worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 || *worker.live.Load().DefaultVoice.AveragePitch != 6 { + t.Fatal("default did not restore edited base") + } + if len(recorder.values) < 3 { + t.Fatal("backend not exercised") + } +} +func TestReaderExtensionRejectionCrashAndFailedApplyLeaveDraftUncommitted(t *testing.T) { + for _, mode := range []string{"reject", "crash", "normal"} { + t.Run(mode, func(t *testing.T) { + worker, _, recorder := reader_extension_fixture(t, mode) + request := reader_page_request(worker, "74") + if mode == "normal" { + worker.braille = &recordingBrailleSettings{err: errors.New("braille failed")} + request.Values.EnableBraille = !worker.settings.EnableBraille + } + response := worker.apply(t.Context(), request) + if response.Accepted || worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 { + t.Fatal("failed apply committed", response.Message) + } + if mode == "normal" { + if len(recorder.values) != 2 || *recorder.values[0].DefaultVoice.Rate != 74 || *recorder.values[1].DefaultVoice.Rate != 50 { + t.Fatal("speech not rolled back after later failure", recorder.values) + } + if worker.extension_pages()[0].Values["rate"].Text != "70" { + t.Fatal("page values committed on failure") + } + } + }) + } +} +func TestReaderExtensionRevocationRestoresAndRemovesContributions(t *testing.T) { + worker, session, _ := reader_extension_fixture(t, "normal") + worker.quickSettingKey = worker.quick_settings()[0].key + value := tentaclewire.SpeechFromSettings(worker.settings) + rate := 82.0 + value.DefaultVoice.Rate = &rate + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { + t.Fatal(err) + } + session.Disable() + worker.sync_extensions(t.Context()) + if worker.speechOverride != nil || len(worker.extension_pages()) != 0 || len(worker.quick_settings()) != 0 || *worker.live.Load().DefaultVoice.Rate != 50 { + t.Fatal("revoked contributions survived") + } + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err == nil { + t.Fatal("late effect applied") + } + message := "" + worker.announce = func(text string) { message = text } + worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) + if message != "The selected speech setting is no longer available" || worker.settings.DefaultVoice.Family != nil { + t.Fatal("a removed quick setting silently adjusted a different setting", message) + } +} +func TestReaderExtensionRevocationDuringBackendPreparation(t *testing.T) { + worker, session, recorder := reader_extension_fixture(t, "normal") + value := tentaclewire.SpeechFromSettings(worker.settings) + rate := 82.0 + value.DefaultVoice.Rate = &rate + recorder.hook = func() { session.Disable() } + _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false) + if err == nil || worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 { + t.Fatal("revoked update committed", err) + } +} +func TestReaderExtensionSnapshotBoundsAndPreferenceWindowGate(t *testing.T) { + worker, session, _ := reader_extension_fixture(t, "normal") + worker.discover = func(context.Context, string) (speech.Catalog, error) { + voices := make([]speech.Voice, 10000) + for index := range voices { + voices[index] = speech.Voice{Name: strings.Repeat("Voice", 40), Language: "en"} + } + return speech.Catalog{"alpha": voices, "beta": nil}, nil + } + state, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Snapshot: true, Module: "alpha"}, true) + if err != nil || !state.CatalogTruncated || len(state.Voices) == 0 || len(state.Modules) != 2 { + t.Fatal(state, err) + } + data, _ := json.Marshal(tentaclewire.Submission{Reader: state}) + if len(data) > tentacle.MaxReaderArgumentBytes { + t.Fatal("oversized continuation") + } + value := state.Speech + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, true); err == nil { + t.Fatal("changed speech behind preferences draft") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := worker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &value}, false); err == nil { + t.Fatal("cancelled request applied") + } + page := worker.extension_pages()[0] + page.Values["rate"] = interaction.Value{Text: "1"} + if worker.extension_pages()[0].Values["rate"].Text != "70" { + t.Fatal("snapshot aliases page values") + } +} +func TestReaderExtensionSlowCallbackTimesOutAndRetiresSession(t *testing.T) { + worker, session, _ := reader_extension_fixture(t, "slow") + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) + defer cancel() + response := worker.apply(ctx, reader_page_request(worker, "75")) + if response.Accepted || worker.speechOverride != nil { + t.Fatal("timeout applied") + } + waitTentacle(t, func() bool { state, _ := session.Status(); return state != tentacle.Active }) + worker.sync_extensions(t.Context()) + if len(worker.extension_pages()) != 0 { + t.Fatal("timed-out page survived") + } +} +func TestReaderExtensionWorkerMailboxAndProtocolContinuation(t *testing.T) { + worker, session, _ := reader_extension_fixture(t, "normal") + // The real runtime interaction asks the worker for a snapshot, receives it + // through the protocol continuation, and proposes a speech change. + if err := worker.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if err := worker.Stop(ctx); err != nil { + t.Error(err) + } + }) + command := tentacleCommand{session: session, id: "apply", reader: true, interactive: true, presentation: true} + raw, _ := json.Marshal(sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true}, Next: "receive"}) + worker.tentacles.interact(t.Context(), command, raw) + // Query through the same owner mailbox; do not read mutable worker state. + state, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{Snapshot: true}) + if err != nil || state.Speech.DefaultVoice.Rate == nil || *state.Speech.DefaultVoice.Rate != 77 { + t.Fatal(state, err) + } + session.Disable() + worker.tentacles.signal_extensions() + // Sync is owner-serialized by the next request, proving queued late work is rejected. + if _, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{RestoreSpeech: true}); err == nil { + t.Fatal("revoked mailbox work accepted") + } +} +func TestReaderExtensionPersistFailureReportsSessionOnly(t *testing.T) { + worker, _, _ := reader_extension_fixture(t, "normal") + if err := os.WriteFile(worker.pageStore.Path, []byte(`{}`), 0600); err != nil { + t.Fatal(err) + } + request := reader_page_request(worker, "76") + request.Save = true + request.Close = true + response := worker.apply(t.Context(), request) + if !response.Accepted || response.Close || !strings.Contains(response.Message, "could not be saved") { + t.Fatal(response) + } + data, _ := os.ReadFile(worker.pageStore.Path) + if !bytes.Equal(data, []byte(`{}`)) { + t.Fatal("overwrote external changes") + } +} + +func TestReaderExtensionApplicationOverlayAndDisabledSpeech(t *testing.T) { + worker, session, recorder := reader_extension_fixture(t, "normal") + value := tentaclewire.SpeechFromSettings(worker.settings) + rate := 83.0 + value.DefaultVoice.Rate = &rate + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { + t.Fatal(err) + } + appSettings := worker.settings + appRate := 61.0 + appSettings.DefaultVoice.Rate = &appRate + appSettings.SpeechVerbosityLevel = 0 + worker.appSettings["Example application"] = appSettings + worker.switchApplication(t.Context(), "Example application") + if *worker.live.Load().DefaultVoice.Rate != 83 || worker.live.Load().SpeechVerbosityLevel != 0 { + t.Fatal("application switch lost override or app preferences") + } + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err != nil { + t.Fatal(err) + } + if *worker.live.Load().DefaultVoice.Rate != 61 { + t.Fatal("did not restore current application's voice") + } + next := worker.settings + next.EnableSpeech = false + if err := worker.applyRuntime(t.Context(), next); err != nil { + t.Fatal(err) + } + count := len(recorder.values) + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { + t.Fatal(err) + } + if len(recorder.values) != count || worker.live.Load().EnableSpeech { + t.Fatal("override enabled or connected disabled speech") + } + next.EnableSpeech = true + if err := worker.applyRuntime(t.Context(), next); err != nil { + t.Fatal(err) + } + if len(recorder.values) != count+1 || *recorder.values[len(recorder.values)-1].DefaultVoice.Rate != 83 { + t.Fatal("reenabling did not apply pending override") + } + next.EnableSpeech = false + if err := worker.applyRuntime(t.Context(), next); err != nil { + t.Fatal(err) + } + count = len(recorder.values) + session.Disable() + worker.sync_extensions(t.Context()) + if worker.speechOverride != nil || len(recorder.values) != count { + t.Fatal("disabled speech required a backend restoration") + } +} +func TestReaderExtensionOwnershipLockAndAtomicContributionUpdate(t *testing.T) { + worker, first, _ := reader_extension_fixture(t, "normal") + _, second, _ := reader_extension_fixture(t, "other") + worker.tentacles.publishCommands([]*tentacle.Session{first, second}) + worker.sync_extensions(t.Context()) + value := tentaclewire.SpeechFromSettings(worker.settings) + rate := 85.0 + value.DefaultVoice.Rate = &rate + if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { + t.Fatal(err) + } + for _, request := range []tentaclewire.ReaderRequest{{Speech: &value}, {RestoreSpeech: true}} { + if _, err := worker.apply_reader_request(t.Context(), second, request, false); err == nil { + t.Fatal("another session changed the owner's override") + } + } + extensions := copy_reader_value(worker.extensions[first.Identity()].value) + extensions.QuickSettings[0].Label = "Renamed probe" + value.Module = "unavailable" + if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Speech: &value, Extensions: &extensions}, false); err == nil { + t.Fatal("unavailable module accepted") + } + if worker.extensions[first.Identity()].value.QuickSettings[0].Label != "Probe mode" { + t.Fatal("partial contribution update committed") + } + worker.tentacles.output.gate.secure.Store(true) + if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err == nil { + t.Fatal("locked reader accepted request") + } + worker.tentacles.output.gate.secure.Store(false) + worker.tentacles.output.gate.suspended.Store(true) + if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Snapshot: true}, false); err == nil { + t.Fatal("suspended reader exposed state") + } + worker.tentacles.output.gate.suspended.Store(false) +} +func TestReaderExtensionRestorationFailureRetainsWorkingVoiceAndReports(t *testing.T) { + worker, session, recorder := reader_extension_fixture(t, "normal") + value := tentaclewire.SpeechFromSettings(worker.settings) + value.Module = "alpha" + if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { + t.Fatal(err) + } + worker.settings.SpeechOutputModule = "unavailable" + message := "" + worker.announce = func(text string) { message = text } + session.Disable() + worker.sync_extensions(t.Context()) + if worker.speechOverride == nil || worker.live.Load().SpeechOutputModule != "alpha" || !strings.Contains(message, "could not be restored") { + t.Fatal("failed restoration lost working speech", message) + } + if len(worker.extension_pages()) != 0 || len(worker.quick_settings()) != 0 { + t.Fatal("revoked UI survived failed restoration") + } + recorder.failure = "" + worker.sync_extensions(t.Context()) + if worker.speechOverride != nil || worker.live.Load().SpeechOutputModule != "unavailable" { + t.Fatal("later restoration did not recover") + } +} + +func TestReaderExtensionManagedLifecycleRestoresWithoutAnotherCommand(t *testing.T) { + for _, ending := range []string{"disable", "remove", "exit", "emergency"} { + t.Run(ending, func(t *testing.T) { + directory := t.TempDir() + runtime, _ := managed_tentacle_fixture(t, filepath.Join(directory, "tentacles.toml"), false) + runtime.output = tentacleOutputFixture(t) + settings := config.DefaultSettings() + settings.Path = filepath.Join(directory, "settings.toml") + baseRate := 50.0 + settings.DefaultVoice.Rate = &baseRate + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, &readerSpeechRecorder{}) + worker.tentacles = runtime + runtime.readerSettings = worker + worker.live = &liveSettings{} + worker.live.Store(settings) + worker.discover = func(context.Context, string) (speech.Catalog, error) { return speech.Catalog{}, nil } + if err := runtime.Start(t.Context()); err != nil { + t.Fatal(err) + } + if err := worker.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + if err := worker.Stop(ctx); err != nil { + t.Error(err) + } + }) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + entry := tentacle.Entry{ID: "managed-reader-probe", Enabled: true, Presentation: true, Interaction: true, ReaderSettings: true, Program: []string{executable, "-test.run=^TestTentacleReaderExtensionPeer$", "--", "reader-extension-peer", "normal"}} + manage_tentacle(t, runtime, "add", entry.ID, &entry) + sessions := runtime.reader_sessions() + if len(sessions) != 1 { + t.Fatal("reader session not published", runtime.Snapshot()) + } + var session *tentacle.Session + for _, value := range sessions { + session = value + } + configuration := tentaclewire.SpeechFromSettings(settings) + rate := 87.0 + configuration.DefaultVoice.Rate = &rate + if _, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{Speech: &configuration}); err != nil { + t.Fatal(err) + } + if *worker.live.Load().DefaultVoice.Rate != 87 { + t.Fatal("override not active") + } + switch ending { + case "disable", "remove": + manage_tentacle(t, runtime, ending, entry.ID, nil) + case "exit": + replies, err := session.Invoke(t.Context(), "apply", json.RawMessage(`{"event":"exit"}`)) + if err != nil { + t.Fatal(err) + } + if result := <-replies; result.Err == nil { + t.Fatal("child did not exit") + } + case "emergency": + runtime.Disable() + } + // Observe the independent atomic snapshot only. No request or command + // is sent to the preferences worker to trigger reconciliation. + waitTentacle(t, func() bool { + return worker.live.Load().DefaultVoice.Rate != nil && *worker.live.Load().DefaultVoice.Rate == 50 + }) + }) + } +} + +func TestReaderExtensionPageLoadCanInspectCatalogWithoutChangingSpeech(t *testing.T) { + for _, mode := range []string{"load", "load-snapshot", "load-denied"} { + t.Run(mode, func(t *testing.T) { + worker, _, recorder := reader_extension_fixture(t, mode) + response, err := worker.initial_response(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(recorder.values) != 0 || worker.speechOverride != nil { + t.Fatal("opening preferences changed speech") + } + if len(response.ExtensionPages) != 1 || response.ExtensionPages[0].Values["rate"].Text != "70" { + t.Fatal("load discarded page values") + } + label := response.ExtensionPages[0].Elements[1].Label + switch mode { + case "load": + if label != "Loaded probe enabled" { + t.Fatal(label) + } + case "load-snapshot": + if label != "Loaded catalog probe" { + t.Fatal("catalog continuation did not refresh page", label) + } + case "load-denied": + if label != "Probe enabled" || !strings.Contains(response.Message, "could not load") { + t.Fatal("speech-changing load was not rejected", label, response.Message) + } + } + }) + } +} diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go index 9a5155b..c501625 100644 --- a/internal/app/tentacles.go +++ b/internal/app/tentacles.go @@ -26,6 +26,7 @@ type tentacleCommand struct { id, title, description string presentation bool interactive bool + reader bool settings bool } @@ -33,6 +34,8 @@ type tentacleCommand struct { // native publication; mutex protects dispatch snapshots and immediate disable. // Only explicitly granted presentation commands reach the core output owner. type tentacleRuntime struct { + readerSettings *preferencesWorker + extensionChanges chan struct{} forms tentacleForms focusedItem func(context.Context) (tentaclewire.Item, error) interactionSlots chan struct{} @@ -66,7 +69,7 @@ type tentacleRuntime struct { func newTentacleRuntime(logger *slog.Logger, commands commandListener, path, parent string) *tentacleRuntime { owner, _ := commands.(tentacleInput) return &tentacleRuntime{logger: logger, input: owner, path: path, parent: parent, - commands: make(map[input.Command]tentacleCommand), failures: make(chan error, 1), announcements: make(chan string, 1), interactionSlots: make(chan struct{}, 1), resultSlots: make(chan struct{}, 64)} + extensionChanges: make(chan struct{}, 1), commands: make(map[input.Command]tentacleCommand), failures: make(chan error, 1), announcements: make(chan string, 1), interactionSlots: make(chan struct{}, 1), resultSlots: make(chan struct{}, 64)} } func (runtime *tentacleRuntime) Start(ctx context.Context) error { @@ -99,6 +102,7 @@ func (runtime *tentacleRuntime) Disable() { runtime.disabled = true runtime.output.Stop() clear(runtime.commands) + runtime.signal_extensions() for _, session := range runtime.sessions { session.Disable() } @@ -187,6 +191,7 @@ func (runtime *tentacleRuntime) run(ctx context.Context) error { case <-ctx.Done(): } runtime.output.RetireRevoked() + runtime.signal_extensions() select { case wake <- struct{}{}: default: @@ -267,6 +272,7 @@ func (runtime *tentacleRuntime) bind(ctx context.Context, sessions []*tentacle.S func (runtime *tentacleRuntime) publishCommands(sessions []*tentacle.Session) { runtime.mutex.Lock() defer runtime.mutex.Unlock() + defer runtime.signal_extensions() clear(runtime.commands) if runtime.disabled { return @@ -276,7 +282,7 @@ func (runtime *tentacleRuntime) publishCommands(sessions []*tentacle.Session) { runtime.commands[input.Command("tentacle/"+session.Identity()+"/"+command.ID)] = tentacleCommand{ session: session, entryID: session.TentacleID(), id: command.ID, title: command.Title, description: command.Description, presentation: command.Presentation, - interactive: command.Interaction, settings: command.Settings, + interactive: command.Interaction, settings: command.Settings, reader: command.Reader, } } } diff --git a/internal/interaction/session.go b/internal/interaction/session.go index 0775730..00649d6 100644 --- a/internal/interaction/session.go +++ b/internal/interaction/session.go @@ -100,22 +100,29 @@ type Session struct { const maxTextLength = 8192 func NewSession(form Form) (*Session, error) { - return new_session(form, 256) + return new_session(form, 256, 64) +} + +// ValidatePreferences shares control validation with internal forms, while +// allowing a native, scrolling page to contain several full voice editors. +func ValidatePreferences(form Form) error { + _, err := new_session(form, 256, 256) + return err } // NewNavigationSession is for core document lists. External forms retain // their smaller protocol limit. The selected row follows the reading caret. func NewNavigationSession(title string, items []Item, selected int) (*Session, error) { - session, err := new_session(Form{Title: title, Elements: []Element{{ID: "items", Label: title, Kind: List, Items: items}}}, 65536) + session, err := new_session(Form{Title: title, Elements: []Element{{ID: "items", Label: title, Kind: List, Items: items}}}, 65536, 64) if err == nil && selected >= 0 && selected < len(items) && !items[selected].Disabled { session.elements[0].selected = selected } return session, err } -func new_session(form Form, maxItems int) (*Session, error) { - if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > 64 { - return nil, errors.New("a titled form with 1 to 64 controls is required") +func new_session(form Form, maxItems, maxElements int) (*Session, error) { + if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > maxElements { + return nil, fmt.Errorf("a titled form with 1 to %d controls is required", maxElements) } session := &Session{title: form.Title, focus: -1} ids := map[string]bool{} diff --git a/internal/preferences/extensions.go b/internal/preferences/extensions.go new file mode 100644 index 0000000..80c50b6 --- /dev/null +++ b/internal/preferences/extensions.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "fmt" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + "unsafe" +) + +func extension_page_key(page ExtensionPage) string { return page.Session + "/" + page.ID } + +// GTK owns native focus. Refreshes keep unchanged widgets and their unapplied +// drafts, and move focus to the notebook before removing a revoked page. +func (session *uiSession) set_extension_pages(pages []ExtensionPage) error { + selected := C.GoString(C.prefs_extension_selected()) + ids := map[string]bool{} + for _, page := range pages { + key := extension_page_key(page) + if page.Session == "" || ids[key] { + return fmt.Errorf("invalid extension page identity") + } + ids[key] = true + if err := tentaclewire.ValidateExtensions(tentaclewire.Extensions{Pages: []tentaclewire.PreferencePage{page.PreferencePage}}); err != nil { + return err + } + } + keep := map[string]bool{} + for _, previous := range session.extensionPages { + key := extension_page_key(previous) + for _, page := range pages { + if extension_page_key(page) == key && page.Revision == previous.Revision { + keep[key] = true + break + } + } + if !keep[key] { + cKey := C.CString(key) + C.prefs_extension_remove(cKey) + C.free(unsafe.Pointer(cKey)) + } + } + for _, page := range pages { + key := extension_page_key(page) + if keep[key] { + continue + } + cKey, title := C.CString(key), C.CString(page.Title) + C.prefs_extension_add(cKey, title) + C.free(unsafe.Pointer(title)) + for row, element := range page.Elements { + id, label := C.CString(element.ID), C.CString(element.Label) + C.prefs_extension_control(cKey, id, label, C.int(element.Kind), C.int(row), C.int(element.MaxLength), boolean(element.Disabled)) + C.free(unsafe.Pointer(label)) + for _, option := range element.Items { + optionID, optionLabel := C.CString(option.ID), C.CString(option.Label) + C.prefs_extension_option(cKey, id, optionID, optionLabel, boolean(option.Disabled)) + C.free(unsafe.Pointer(optionID)) + C.free(unsafe.Pointer(optionLabel)) + } + value := page.Values[element.ID] + text := value.Text + if element.Kind == interaction.List { + text = value.Selected + } + cText := C.CString(text) + C.prefs_extension_set(cKey, id, C.int(element.Kind), cText, boolean(value.Checked)) + C.free(unsafe.Pointer(cText)) + C.free(unsafe.Pointer(id)) + } + C.free(unsafe.Pointer(cKey)) + } + session.extensionPages = pages + if selected != "" { + key := C.CString(selected) + C.prefs_extension_select(key) + C.free(unsafe.Pointer(key)) + } + return nil +} +func (session *uiSession) extension_drafts() ([]PageDraft, error) { + var drafts []PageDraft + for _, page := range session.extensionPages { + values := map[string]interaction.Value{} + key := C.CString(extension_page_key(page)) + for _, element := range page.Elements { + id := C.CString(element.ID) + value := interaction.Value{} + switch element.Kind { + case interaction.List: + value.Selected = C.GoString(C.prefs_extension_text(key, id, C.int(element.Kind))) + case interaction.Entry: + value.Text = C.GoString(C.prefs_extension_text(key, id, C.int(element.Kind))) + case interaction.CheckBox: + value.Checked = C.prefs_extension_checked(key, id) != 0 + } + C.free(unsafe.Pointer(id)) + values[element.ID] = value + } + C.free(unsafe.Pointer(key)) + declaration := page.PreferencePage + declaration.Values = values + if err := tentaclewire.ValidateExtensions(tentaclewire.Extensions{Pages: []tentaclewire.PreferencePage{declaration}}); err != nil { + return nil, fmt.Errorf("Check the values on %s: %w", page.Title, err) + } + drafts = append(drafts, PageDraft{Session: page.Session, ID: page.ID, Revision: page.Revision, Values: values}) + } + return drafts, nil +} diff --git a/internal/preferences/native.c b/internal/preferences/native.c index 505a063..973fcfa 100644 --- a/internal/preferences/native.c +++ b/internal/preferences/native.c @@ -49,6 +49,9 @@ static void combo_changed(GtkComboBox *combo, gpointer data) { (const char *)data, value ? value : ""); g_free(value); } +void prefs_describe_combo(GtkWidget *control, const char *label) { + combo_changed(GTK_COMBO_BOX(control), (gpointer)label); +} static void free_signal_data(gpointer data, GClosure *closure) { (void)closure; g_free(data); @@ -309,7 +312,7 @@ void prefs_busy(int busy, int can_save) { } } void prefs_present(void) { gtk_window_present(GTK_WINDOW(prefs_widget("window"))); } -void prefs_close(void) { busy_focus = NULL; gtk_widget_destroy(prefs_widget("window")); g_object_unref(builder); builder = NULL; } +void prefs_close(void) { prefs_extensions_close(); busy_focus = NULL; gtk_widget_destroy(prefs_widget("window")); g_object_unref(builder); builder = NULL; } void prefs_status(const char *message, int focus) { gtk_entry_set_text(GTK_ENTRY(prefs_widget("status")), message); atk_object_set_name(gtk_widget_get_accessible(prefs_widget("status")), message); diff --git a/internal/preferences/native.h b/internal/preferences/native.h index c5bec2d..ac0d550 100644 --- a/internal/preferences/native.h +++ b/internal/preferences/native.h @@ -71,3 +71,14 @@ void prefs_tentacle_repositories_clear(void); void prefs_tentacle_repository_append(const char *label); int prefs_tentacle_repository_selected(void); void prefs_tentacle_repository_select(int page); + +void prefs_extension_add(const char *key, const char *title); +void prefs_extension_remove(const char *key); +void prefs_extensions_close(void); +const char *prefs_extension_selected(void); +void prefs_extension_select(const char *key); +void prefs_extension_control(const char *page, const char *id, const char *label, int kind, int row, int max_length, int disabled); +void prefs_extension_option(const char *page, const char *id, const char *option, const char *label, int disabled); +void prefs_extension_set(const char *page, const char *id, int kind, const char *text, int checked); +const char *prefs_extension_text(const char *page, const char *id, int kind); +int prefs_extension_checked(const char *page, const char *id); diff --git a/internal/preferences/native_extensions.c b/internal/preferences/native_extensions.c new file mode 100644 index 0000000..6503432 --- /dev/null +++ b/internal/preferences/native_extensions.c @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +#include "native_internal.h" + +static GHashTable *extensionPages; +static GHashTable *extensionControls; + +static void extension_combo_changed(GtkComboBox *combo, gpointer data) { + (void)data; + prefs_describe_combo(GTK_WIDGET(combo), g_object_get_data(G_OBJECT(combo), "extension-label")); +} + +static char *control_key(const char *page, const char *id) { + return g_strconcat(page, "/", id, NULL); +} +static GtkWidget *extension_control(const char *page, const char *id) { + char *key = control_key(page, id); + GtkWidget *widget = extensionControls ? g_hash_table_lookup(extensionControls, key) : NULL; + g_free(key); + return widget; +} +void prefs_extension_add(const char *key, const char *title) { + if (!extensionPages) { + extensionPages = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + extensionControls = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + } + GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 8); + gtk_grid_set_column_spacing(GTK_GRID(grid), 12); + gtk_container_set_border_width(GTK_CONTAINER(grid), 12); + gtk_container_add(GTK_CONTAINER(scroll), grid); + g_object_set_data(G_OBJECT(scroll), "extension-grid", grid); + gtk_notebook_append_page(GTK_NOTEBOOK(prefs_widget("notebook")), scroll, gtk_label_new(title)); + g_hash_table_insert(extensionPages, g_strdup(key), scroll); + gtk_widget_show_all(scroll); +} +void prefs_extension_remove(const char *key) { + if (!extensionPages) return; + GtkWidget *page = g_hash_table_lookup(extensionPages, key); + if (!page) return; + GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("notebook")); + int pageIndex = gtk_notebook_page_num(notebook, page); + GtkWidget *focus = gtk_window_get_focus(GTK_WINDOW(prefs_widget("window"))); + if (gtk_notebook_get_current_page(notebook) == pageIndex) { + // Switch while the old page is still realized. GTK must not attempt to + // restore a focus child from a page whose controls are being destroyed. + gtk_notebook_set_current_page(notebook, 0); + gtk_widget_grab_focus(GTK_WIDGET(notebook)); + } else if (focus && gtk_widget_is_ancestor(focus, page)) { + gtk_widget_grab_focus(GTK_WIDGET(notebook)); + } + GHashTableIter iterator; + gpointer controlKey, widget; + char *prefix = g_strconcat(key, "/", NULL); + g_hash_table_iter_init(&iterator, extensionControls); + while (g_hash_table_iter_next(&iterator, &controlKey, &widget)) { + if (g_str_has_prefix(controlKey, prefix)) g_hash_table_iter_remove(&iterator); + } + g_free(prefix); + gtk_notebook_remove_page(notebook, pageIndex); + g_hash_table_remove(extensionPages, key); +} +void prefs_extensions_close(void) { + g_clear_pointer(&extensionControls, g_hash_table_destroy); + g_clear_pointer(&extensionPages, g_hash_table_destroy); +} +const char *prefs_extension_selected(void) { + if (!extensionPages) return ""; + GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("notebook")); + GtkWidget *current = gtk_notebook_get_nth_page(notebook, gtk_notebook_get_current_page(notebook)); + GHashTableIter iterator; + gpointer key, page; + g_hash_table_iter_init(&iterator, extensionPages); + while (g_hash_table_iter_next(&iterator, &key, &page)) { + if (page == current) return key; + } + return ""; +} +void prefs_extension_select(const char *key) { + if (!extensionPages) return; + GtkWidget *page = g_hash_table_lookup(extensionPages, key); + if (page) { + GtkNotebook *notebook = GTK_NOTEBOOK(prefs_widget("notebook")); + gtk_notebook_set_current_page(notebook, gtk_notebook_page_num(notebook, page)); + } +} +void prefs_extension_control(const char *pageKey, const char *id, const char *label, int kind, int row, int maxLength, int disabled) { + GtkWidget *page = g_hash_table_lookup(extensionPages, pageKey); + GtkWidget *grid = g_object_get_data(G_OBJECT(page), "extension-grid"); + GtkWidget *control; + if (kind == 1) { + control = gtk_combo_box_text_new(); + // Focus the internal combo button so Tab and Shift+Tab leave it normally. + gtk_widget_set_can_focus(control, FALSE); + g_object_set_data_full(G_OBJECT(control), "extension-label", g_strdup(label), g_free); + g_signal_connect(control, "changed", G_CALLBACK(extension_combo_changed), NULL); + } else if (kind == 2) { + control = gtk_entry_new(); + gtk_entry_set_max_length(GTK_ENTRY(control), maxLength ? maxLength : 8192); + } else { + control = gtk_check_button_new_with_label(label); + } + atk_object_set_name(gtk_widget_get_accessible(control), label); + gtk_widget_set_hexpand(control, TRUE); + gtk_widget_set_sensitive(control, !disabled); + if (kind != 3) { + GtkWidget *name = gtk_label_new(label); + gtk_label_set_xalign(GTK_LABEL(name), 0); + gtk_label_set_mnemonic_widget(GTK_LABEL(name), control); + gtk_grid_attach(GTK_GRID(grid), name, 0, row, 1, 1); + gtk_grid_attach(GTK_GRID(grid), control, 1, row, 1, 1); + } else { + gtk_grid_attach(GTK_GRID(grid), control, 0, row, 2, 1); + } + g_hash_table_insert(extensionControls, control_key(pageKey, id), control); + gtk_widget_show_all(grid); +} +void prefs_extension_option(const char *page, const char *id, const char *option, const char *label, int disabled) { + // Disabled choices are omitted from native selection, preserving their IDs + // in the host schema so an old/malicious draft is still rejected there. + if (!disabled) gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(extension_control(page, id)), option, label); +} +void prefs_extension_set(const char *page, const char *id, int kind, const char *text, int checked) { + GtkWidget *widget = extension_control(page, id); + if (kind == 1) gtk_combo_box_set_active_id(GTK_COMBO_BOX(widget), text); + else if (kind == 2) gtk_entry_set_text(GTK_ENTRY(widget), text); + else gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(widget), checked); +} +const char *prefs_extension_text(const char *page, const char *id, int kind) { + GtkWidget *widget = extension_control(page, id); + const char *value = kind == 1 ? gtk_combo_box_get_active_id(GTK_COMBO_BOX(widget)) : gtk_entry_get_text(GTK_ENTRY(widget)); + return value ? value : ""; +} +int prefs_extension_checked(const char *page, const char *id) { + return gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(extension_control(page, id))); +} diff --git a/internal/preferences/native_internal.h b/internal/preferences/native_internal.h index 9d3babd..242d791 100644 --- a/internal/preferences/native_internal.h +++ b/internal/preferences/native_internal.h @@ -8,6 +8,7 @@ // Private C boundary. The window owner retains the builder and action slot; // widget models and signal callbacks stay with their respective tables. GtkWidget *prefs_widget(const char *id); +void prefs_describe_combo(GtkWidget *control, const char *label); void prefs_set_action(int value); void prefs_init_key_bindings(void); void prefs_init_modifiers(void); diff --git a/internal/preferences/protocol.go b/internal/preferences/protocol.go index 2cd7cff..b4d5275 100644 --- a/internal/preferences/protocol.go +++ b/internal/preferences/protocol.go @@ -9,8 +9,10 @@ package preferences import ( "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/speech" "git.stormux.org/storm/cthulhu/internal/tentacle" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type Values struct { @@ -84,12 +86,26 @@ type Values struct { SpeakDescription bool } +type ExtensionPage struct { + Session string + Revision uint64 + tentaclewire.PreferencePage +} + +type PageDraft struct { + Session string + ID string + Revision uint64 + Values map[string]interaction.Value +} + type Request struct { - Tentacle *tentacle.ManagementRequest - Application string - Values Values - Save bool - Close bool + ExtensionPages []PageDraft `json:",omitempty"` + Tentacle *tentacle.ManagementRequest + Application string + Values Values + Save bool + Close bool // Preview applies the default voice controls to the running speech // backend without committing session or persistent settings. Preview bool @@ -103,6 +119,8 @@ type Request struct { } type Response struct { + ExtensionPages []ExtensionPage `json:",omitempty"` + ExtensionsOnly bool `json:",omitempty"` Tentacles *tentacle.Snapshot `json:",omitempty"` ChooseApplication bool Catalog speech.Catalog diff --git a/internal/preferences/session.go b/internal/preferences/session.go index 2048de4..2236f50 100644 --- a/internal/preferences/session.go +++ b/internal/preferences/session.go @@ -14,6 +14,7 @@ import ( // uiSession lives for one helper window. Only the locked GTK thread in Run // accesses these fields; pipe readers deliver immutable responses by channel. type uiSession struct { + extensionPages []ExtensionPage progressBar config.ProgressBarSettings catalog speech.Catalog contractionTables []braille.TableOption diff --git a/internal/preferences/tentacles.go b/internal/preferences/tentacles.go index 8f0743b..2671a0d 100644 --- a/internal/preferences/tentacles.go +++ b/internal/preferences/tentacles.go @@ -238,6 +238,9 @@ func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error if slices.Contains(manifest.Capabilities, "interaction") { permissions = "This tentacle can register reader commands, send speech and braille messages, open internal forms, read the focused item and clipboard when invoked, and write clipboard text." } + if slices.Contains(manifest.Capabilities, "reader-settings") { + permissions += " It can also add preferences pages and quick settings, inspect speech voices, and temporarily change the reader voice configuration." + } operation, label := "install", "Install" if action == 11 { operation, label = "install-enable", "Install and enable" diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go index 4c0a19b..fa4cf0f 100644 --- a/internal/preferences/ui.go +++ b/internal/preferences/ui.go @@ -66,6 +66,9 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { } session := &uiSession{catalog: initial.Catalog, soundThemes: []string{"default", "none"}} session.setValues(initial.Values) + if err := session.set_extension_pages(initial.ExtensionPages); err != nil { + return err + } tentacles := &tentaclePage{} tentacles.populate(initial.Tentacles) var tentaclePending *tentacle.ManagementRequest @@ -88,6 +91,12 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { case err := <-errorsIn: return err case response := <-updates: + if response.ExtensionsOnly { + if err := session.set_extension_pages(response.ExtensionPages); err != nil { + return err + } + continue + } if response.Present { C.prefs_present() continue @@ -122,6 +131,9 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { busy, canSave = false, response.CanSave if response.Accepted { session.setValues(response.Values) + if err := session.set_extension_pages(response.ExtensionPages); err != nil { + return err + } C.prefs_voice_changed() C.prefs_echo_preview(nil) } @@ -176,7 +188,21 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { status(err.Error(), true) continue } - if err := encoder.Encode(Request{Values: values, Save: canSave, Close: action == 2}); err != nil { + drafts, err := session.extension_drafts() + if err != nil { + status(err.Error(), true) + continue + } + request := Request{Values: values, ExtensionPages: drafts, Save: canSave, Close: action == 2} + data, err := json.Marshal(request) + if err != nil { + return err + } + if len(data)+1 > initialMessageLimit { + status("Preferences changes exceed the message limit. Shorten the edited values.", true) + continue + } + if err := encoder.Encode(json.RawMessage(data)); err != nil { return err } busy = true diff --git a/internal/tentacle/install.go b/internal/tentacle/install.go index 31a077d..3c15c30 100644 --- a/internal/tentacle/install.go +++ b/internal/tentacle/install.go @@ -202,6 +202,7 @@ func prepare_package(ctx context.Context, root string, repository Repository, lo } presentation := slices.Contains(packageManifest.Capabilities, "presentation") interactive := slices.Contains(packageManifest.Capabilities, "interaction") + readerSettings := slices.Contains(packageManifest.Capabilities, "reader-settings") expectedAPIs := []string{"commands"} if presentation { expectedAPIs = append(expectedAPIs, "presentation") @@ -209,7 +210,10 @@ func prepare_package(ctx context.Context, root string, repository Repository, lo if interactive { expectedAPIs = append(expectedAPIs, "interaction") } - if interactive && !presentation || len(packageManifest.Capabilities) != len(expectedAPIs)-1 || !slices.Equal(packageManifest.APIs, expectedAPIs) { + if readerSettings { + expectedAPIs = append(expectedAPIs, "reader-settings") + } + if readerSettings && !interactive || interactive && !presentation || len(packageManifest.Capabilities) != len(expectedAPIs)-1 || !slices.Equal(packageManifest.APIs, expectedAPIs) { return nil, errors.New("Package requires unsupported APIs or permissions.") } if len(packageManifest.EntryPoint) == 0 || len(packageManifest.EntryPoint) > 32 || !package_path(packageManifest.EntryPoint[0]) { @@ -243,7 +247,7 @@ func prepare_package(ctx context.Context, root string, repository Repository, lo program := slices.Clone(packageManifest.EntryPoint) program[0] = filepath.Join(final, filepath.FromSlash(program[0])) prepared.Entry = Entry{ID: PackageID(repository, localID), LocalID: localID, Name: manifest.Name, Version: manifest.Version, - Program: program, Directory: final, Presentation: presentation, Interaction: interactive, Repository: repository.URL, RepositoryID: repository.ID, + Program: program, Directory: final, Presentation: presentation, Interaction: interactive, ReaderSettings: readerSettings, Repository: repository.URL, RepositoryID: repository.ID, RepositoryRevision: repository.Revision, PackageDirectory: final, PackageSHA256: release.SHA256} if err := ValidateEntries([]Entry{prepared.Entry}); err != nil { return nil, err diff --git a/internal/tentacle/install_test.go b/internal/tentacle/install_test.go index 6436593..93df7dc 100644 --- a/internal/tentacle/install_test.go +++ b/internal/tentacle/install_test.go @@ -393,3 +393,50 @@ func TestInstallInteractivePackageGrant(t *testing.T) { t.Fatal(prepared.Entry) } } + +func TestInstallReaderSettingsPackageGrantAndDependencies(t *testing.T) { + for _, interactive := range []bool{true, false} { + t.Run(fmt.Sprintf("interaction=%t", interactive), func(t *testing.T) { + capabilities := "'presentation','reader-settings'" + apis := "'commands','presentation','reader-settings'" + granted := []string{"presentation", "reader-settings"} + if interactive { + capabilities = "'presentation','interaction','reader-settings'" + apis = "'commands','presentation','interaction','reader-settings'" + granted = []string{"presentation", "interaction", "reader-settings"} + } + members := native_members(t) + manifest := strings.ReplaceAll(package_manifest(), "apis=['commands','presentation']", "apis=["+apis+"]") + manifest = strings.ReplaceAll(manifest, "capabilities=['presentation']", "capabilities=["+capabilities+"]") + members[0].data = []byte(manifest) + repository, client := package_fixture(t, package_archive(t, members)) + repository.Tentacles[0].Capabilities = granted + prepared, err := prepare_package(t.Context(), t.TempDir(), repository, "echo", client) + if !interactive { + if err == nil { + prepared.Discard() + t.Fatal("reader settings installed without interaction permission") + } + return + } + if err != nil { + t.Fatal(err) + } + defer prepared.Discard() + if !prepared.Entry.ReaderSettings || !prepared.Entry.Spec().ReaderSettings { + t.Fatal("reader grant not carried into runtime spec") + } + registry := &Registry{Path: filepath.Join(t.TempDir(), "tentacles.toml")} + if _, err := registry.Load(); err != nil { + t.Fatal(err) + } + if err := registry.Save([]Entry{prepared.Entry}); err != nil { + t.Fatal(err) + } + loaded, err := (&Registry{Path: registry.Path}).Load() + if err != nil || len(loaded) != 1 || !loaded[0].ReaderSettings { + t.Fatal("reader grant lost across restart", loaded, err) + } + }) + } +} diff --git a/internal/tentacle/manager.go b/internal/tentacle/manager.go index fe8cbb9..487938c 100644 --- a/internal/tentacle/manager.go +++ b/internal/tentacle/manager.go @@ -35,11 +35,12 @@ func DefaultLimits() Limits { // Spec is a validated launch description, independent of package metadata. // Program[0] must be absolute; arguments are never interpreted by a shell. type Spec struct { - ID string - Program []string - Directory string - Presentation bool // Explicit local grant for command speech/braille results. - Interaction bool // Internal forms and explicit focused-item/clipboard exchanges. + ID string + Program []string + Directory string + Presentation bool // Explicit local grant for command speech/braille results. + ReaderSettings bool // Declarative preferences, quick settings and speech overrides. + Interaction bool // Internal forms and explicit focused-item/clipboard exchanges. } type Stats struct { @@ -195,7 +196,7 @@ func (manager *Manager) Shutdown(ctx context.Context) error { } func validate_spec(spec Spec) error { - if spec.Interaction && !spec.Presentation || !valid_id(spec.ID) || len(spec.Program) == 0 || len(spec.Program) > 32 || + if spec.ReaderSettings && !spec.Interaction || spec.Interaction && !spec.Presentation || !valid_id(spec.ID) || len(spec.Program) == 0 || len(spec.Program) > 32 || !filepath.IsAbs(spec.Program[0]) || (spec.Directory != "" && !filepath.IsAbs(spec.Directory)) { return errors.New("invalid local tentacle launch") } diff --git a/internal/tentacle/page_store.go b/internal/tentacle/page_store.go new file mode 100644 index 0000000..10261c2 --- /dev/null +++ b/internal/tentacle/page_store.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentacle + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "syscall" + + "git.stormux.org/storm/cthulhu/internal/interaction" +) + +// PageValues contains only host-validated declarative preferences. Active speech +// overrides and quick-setting selections are deliberately never restored here. +type PageValues map[string]map[string]map[string]interaction.Value + +type PageStore struct { + Path string + original []byte + loaded bool +} + +func (store *PageStore) Load() (PageValues, error) { + store.loaded = false + data, err := read_registry(store.Path) + if err != nil { + return nil, err + } + values := PageValues{} + if data != nil { + if validate_json(data) != nil { + return nil, ErrProtocol + } + if err := json.Unmarshal(data, &values); err != nil || values == nil { + return nil, ErrProtocol + } + } + store.original, store.loaded = data, true + return values, nil +} + +func (store *PageStore) Save(values PageValues) error { + if !store.loaded { + return errors.New("tentacle preferences were not loaded successfully") + } + data, err := json.Marshal(values) + if err != nil { + return err + } + if len(data) > MaxFrameBytes { + return ErrLimit + } + directory := filepath.Dir(store.Path) + if err := os.MkdirAll(directory, 0700); err != nil { + return err + } + lock, err := os.OpenFile(store.Path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600) + if err != nil { + return err + } + defer lock.Close() + if info, err := lock.Stat(); err != nil || !info.Mode().IsRegular() { + return errors.New("tentacle preferences lock must be a regular file") + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return errors.New("tentacle preferences are being edited") + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + current, err := read_registry(store.Path) + if err != nil { + return err + } + if !bytes.Equal(current, store.original) || (current == nil) != (store.original == nil) { + return errors.New("tentacle preferences changed outside the reader") + } + file, err := os.CreateTemp(directory, ".tentacle-preferences-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + dir, err := os.Open(directory) + if err != nil { + return err + } + defer dir.Close() + if err := os.Rename(file.Name(), store.Path); err != nil { + return err + } + store.original = data + return dir.Sync() +} diff --git a/internal/tentacle/page_store_test.go b/internal/tentacle/page_store_test.go new file mode 100644 index 0000000..2298d05 --- /dev/null +++ b/internal/tentacle/page_store_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package tentacle + +import ( + "git.stormux.org/storm/cthulhu/internal/interaction" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestPageStoreAtomicPersistenceAndExternalEdits(t *testing.T) { + path := filepath.Join(t.TempDir(), "preferences.json") + store := &PageStore{Path: path} + values, err := store.Load() + if err != nil || len(values) != 0 { + t.Fatal(values, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("load created a file") + } + values["probe"] = map[string]map[string]interaction.Value{"page": {"name": {Text: "Example"}}} + if err := store.Save(values); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0600 { + t.Fatal(info.Mode()) + } + other := &PageStore{Path: path} + loaded, err := other.Load() + if err != nil || !reflect.DeepEqual(loaded, values) { + t.Fatal(loaded, err) + } + loaded["probe"]["page"]["name"] = interaction.Value{Text: "Changed"} + if err := other.Save(loaded); err != nil { + t.Fatal(err) + } + if err := store.Save(values); err == nil { + t.Fatal("overwrote another editor") + } + after, err := (&PageStore{Path: path}).Load() + if err != nil || after["probe"]["page"]["name"].Text != "Changed" { + t.Fatal(after, err) + } +} +func TestPageStoreRejectsSymlinksMalformedAndUnloadedWrites(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "preferences.json") + store := &PageStore{Path: path} + if store.Save(PageValues{}) == nil { + t.Fatal("saved without loading") + } + for _, data := range []string{`null`, `{"probe":{},"probe":{}}`, `{"probe":`, `[]`} { + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + if _, err := store.Load(); err == nil { + t.Fatal("accepted", data) + } + if store.Save(PageValues{}) == nil { + t.Fatal("saved after a failed load") + } + } + link := filepath.Join(directory, "link") + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err := (&PageStore{Path: link}).Load(); err == nil { + t.Fatal("followed a symlink") + } +} diff --git a/internal/tentacle/process.go b/internal/tentacle/process.go index 15c86f0..0327647 100644 --- a/internal/tentacle/process.go +++ b/internal/tentacle/process.go @@ -215,6 +215,9 @@ func (session *Session) run() { if session.spec.Interaction { apis = append(apis, "interaction") } + if session.spec.ReaderSettings { + apis = append(apis, "reader-settings") + } initialized, err = session.request_locked(session.ctx, "initialize", Initialization{ Protocol: Version{Major: 1, Minor: 0}, Session: session.identity, APIs: apis, Capabilities: apis, @@ -239,14 +242,21 @@ func (session *Session) run() { return } for _, command := range registration.Commands { - if command.Presentation && !session.spec.Presentation || command.Interaction && (!command.Presentation || !session.spec.Interaction) { + if command.Reader && !session.spec.ReaderSettings || command.Presentation && !session.spec.Presentation || command.Interaction && (!command.Presentation || !session.spec.Interaction) { session.fail(ErrProtocol) return } } + if registration.Extensions != nil && !session.spec.ReaderSettings { + session.fail(ErrProtocol) + return + } session.mutex.Lock() if session.state == Starting && session.ctx.Err() == nil { session.commands = registration.Commands + if registration.Extensions != nil { + session.extensions = *registration.Extensions + } session.state = Ready } session.mutex.Unlock() diff --git a/internal/tentacle/protocol.go b/internal/tentacle/protocol.go index ea41929..6b0527e 100644 --- a/internal/tentacle/protocol.go +++ b/internal/tentacle/protocol.go @@ -16,10 +16,11 @@ import ( ) const ( - MaxFrameBytes = tentaclewire.MaxFrameBytes // Excludes the mandatory newline. - MaxArgumentBytes = 16 * 1024 - MaxCommands = 32 - MaxDiagnosticBytes = 64 * 1024 // Lifetime budget; diagnostics are discarded. + MaxFrameBytes = tentaclewire.MaxFrameBytes // Excludes the mandatory newline. + MaxReaderArgumentBytes = 48 * 1024 + MaxArgumentBytes = 16 * 1024 + MaxCommands = 32 + MaxDiagnosticBytes = 64 * 1024 // Lifetime budget; diagnostics are discarded. ) var ( @@ -43,6 +44,7 @@ type Command struct { Bindings []Binding `json:"bindings,omitempty"` Presentation bool `json:"presentation,omitempty"` Interaction bool `json:"interaction,omitempty"` + Reader bool `json:"reader,omitempty"` Settings bool `json:"settings,omitempty"` // Opens settings; never a continuation handler. } @@ -54,9 +56,10 @@ type Initialization struct { } type Registration struct { - Protocol Version `json:"protocol"` - Session string `json:"session"` - Commands []Command `json:"commands"` + Protocol Version `json:"protocol"` + Session string `json:"session"` + Commands []Command `json:"commands"` + Extensions *tentaclewire.Extensions `json:"extensions,omitempty"` } // RPCError retains the bounded remote message for a future accessible details @@ -149,7 +152,7 @@ func validate_registration(registration Registration, session string) error { seen := make(map[string]bool) settings := false for _, command := range registration.Commands { - if command.Settings && (!command.Interaction || settings) || command.Interaction && !command.Presentation || !valid_id(command.ID) || seen[command.ID] || strings.TrimSpace(command.Title) == "" || + if command.Reader && !command.Interaction || command.Settings && (!command.Interaction || settings) || command.Interaction && !command.Presentation || !valid_id(command.ID) || seen[command.ID] || strings.TrimSpace(command.Title) == "" || len(command.Title) > 256 || len(command.Description) > 1024 { return ErrProtocol } @@ -164,9 +167,33 @@ func validate_registration(registration Registration, session string) error { } } } + if registration.Extensions != nil { + if err := tentaclewire.ValidateExtensions(*registration.Extensions); err != nil { + return err + } + for _, page := range registration.Extensions.Pages { + if !reader_command(registration.Commands, page.Command) { + return ErrProtocol + } + } + for _, setting := range registration.Extensions.QuickSettings { + if !reader_command(registration.Commands, setting.Command) { + return ErrProtocol + } + } + } return nil } +func reader_command(commands []Command, id string) bool { + for _, command := range commands { + if command.ID == id { + return command.Reader && !command.Settings + } + } + return false +} + func clone_commands(commands []Command) []Command { result := append([]Command(nil), commands...) for index := range result { diff --git a/internal/tentacle/reader_test.go b/internal/tentacle/reader_test.go new file mode 100644 index 0000000..6becc80 --- /dev/null +++ b/internal/tentacle/reader_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package tentacle + +import ( + "context" + "encoding/json" + "errors" + "git.stormux.org/storm/cthulhu/internal/tentaclewire" + sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestReaderSDKPeer(t *testing.T) { + if os.Getenv("CTHULHU_PRIVATE_READER_PEER") != "1" { + return + } + extension := sdk.Extensions{Pages: []sdk.PreferencePage{{ID: "probe", Title: "Reader probe", Command: "echo", Elements: []sdk.Element{{ID: "text", Label: "Example text", Kind: sdk.Entry}}, Values: map[string]sdk.Value{"text": {Text: "Initial"}}}}, QuickSettings: []sdk.QuickSetting{{ID: "probe", Label: "Probe choice", Command: "echo", Selected: "default", Options: []sdk.ListItem{{ID: "default", Label: "Default"}}}}} + echo := func(ctx context.Context, args json.RawMessage) (any, error) { return args, nil } + if err := sdk.ServeWithExtensions(os.Stdin, os.Stdout, []sdk.Command{{ID: "echo", Title: "Reader probe", Presentation: true, Interaction: true, Reader: true, Handle: echo}, {ID: "ordinary", Title: "Ordinary probe", Handle: echo}}, extension); err != nil { + os.Exit(1) + } + os.Exit(0) +} +func TestReaderSDKGrantsCopiesAndArgumentBounds(t *testing.T) { + t.Setenv("CTHULHU_PRIVATE_READER_PEER", "1") + for _, language := range []string{"go", "python"} { + for _, grant := range []bool{false, true} { + t.Run(language+"/"+map[bool]string{false: "denied", true: "granted"}[grant], func(t *testing.T) { + var program []string + if language == "go" { + executable, _ := os.Executable() + program = []string{executable, "-test.run=^TestReaderSDKPeer$"} + } else { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip(err) + } + peer, err := filepath.Abs("testdata/sdk_reader_peer.py") + if err != nil { + t.Fatal(err) + } + program = []string{python, "-B", peer} + } + manager := test_manager(t, DefaultLimits(), nil) + session, err := manager.Start(t.Context(), Spec{ID: "probe", Program: program, Presentation: true, Interaction: true, ReaderSettings: grant}) + if err != nil { + t.Fatal(err) + } + wait_signal(t, session.Ready()) + state, reason := session.Status() + if !grant { + if state == Ready { + t.Fatal("reader API accepted without grant") + } + return + } + if state != Ready { + t.Fatal(state, reason) + } + extensions := session.Extensions() + if len(extensions.Pages) != 1 || len(extensions.QuickSettings) != 1 { + t.Fatal(extensions) + } + extensions.Pages[0].Values["text"] = sdk.Value{Text: "Changed"} + extensions.Pages[0].Elements[0].Label = "Changed" + if session.Extensions().Pages[0].Values["text"].Text != "Initial" || session.Extensions().Pages[0].Elements[0].Label != "Example text" { + t.Fatal("registration shared mutable state") + } + if err := session.Activate(); err != nil { + t.Fatal(err) + } + argument := json.RawMessage(`{"text":"` + strings.Repeat("x", 24*1024) + `"}`) + replies, err := session.Invoke(t.Context(), "echo", argument) + if err != nil { + t.Fatal(err) + } + result := wait_result(t, replies) + if result.Err != nil || string(result.Value) != string(argument) { + t.Fatal("reader continuation did not round trip", result.Err) + } + if _, err := session.Invoke(t.Context(), "ordinary", argument); !errors.Is(err, ErrLimit) { + t.Fatal("ordinary command exceeded its original argument limit", err) + } + if _, err := session.Invoke(t.Context(), "echo", json.RawMessage(`"`+strings.Repeat("x", MaxReaderArgumentBytes)+`"`)); !errors.Is(err, ErrLimit) { + t.Fatal("reader limit not enforced", err) + } + }) + } + } +} +func TestReaderRegistrationRequiresDeclaredReaderCallbacks(t *testing.T) { + extension := tentaclewire.Extensions{QuickSettings: []tentaclewire.QuickSetting{{ID: "probe", Label: "Probe", Command: "apply", Selected: "default", Options: []sdk.ListItem{{ID: "default", Label: "Default"}}}}} + registration := Registration{Protocol: Version{Major: 1}, Session: "session", Commands: []Command{{ID: "apply", Title: "Apply", Presentation: true, Interaction: true, Reader: true}}, Extensions: &extension} + if err := validate_registration(registration, "session"); err != nil { + t.Fatal(err) + } + registration.Commands[0].Reader = false + if validate_registration(registration, "session") == nil { + t.Fatal("accepted an unprivileged extension callback") + } + registration.Commands[0].Reader = true + registration.Commands[0].Settings = true + if validate_registration(registration, "session") == nil { + t.Fatal("accepted a settings-form handler as a page callback") + } +} diff --git a/internal/tentacle/registry.go b/internal/tentacle/registry.go index eecd0a5..1550cb6 100644 --- a/internal/tentacle/registry.go +++ b/internal/tentacle/registry.go @@ -23,6 +23,7 @@ type Entry struct { Directory string `toml:"directory,omitempty"` Enabled bool `toml:"enabled"` Presentation bool `toml:"presentation"` + ReaderSettings bool `toml:"reader-settings,omitempty"` Interaction bool `toml:"interaction,omitempty"` Repository string `toml:"repository,omitempty"` RepositoryID string `toml:"repository-id,omitempty"` @@ -35,7 +36,7 @@ type Entry struct { } func (entry Entry) Spec() Spec { - return Spec{ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction} + return Spec{ID: entry.ID, Program: append([]string(nil), entry.Program...), Directory: entry.Directory, Presentation: entry.Presentation, Interaction: entry.Interaction, ReaderSettings: entry.ReaderSettings} } type registryDocument struct { diff --git a/internal/tentacle/session.go b/internal/tentacle/session.go index e9b726e..87ab374 100644 --- a/internal/tentacle/session.go +++ b/internal/tentacle/session.go @@ -10,6 +10,8 @@ import ( "strconv" "sync" "time" + + "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type State string @@ -49,24 +51,25 @@ type Session struct { outputContext context.Context revokeOutput context.CancelFunc - mutex sync.Mutex - manager *Manager - spec Spec - identity string - ctx context.Context - cancel context.CancelFunc - state State - failure error - commands []Command - bindings []BindingStatus - sequence uint64 - pending map[string]*pendingCall - outbound chan outgoing - ready chan struct{} - done chan struct{} - stop chan struct{} - revoked chan struct{} - callbacks sync.WaitGroup + mutex sync.Mutex + manager *Manager + spec Spec + identity string + ctx context.Context + cancel context.CancelFunc + state State + failure error + extensions tentaclewire.Extensions + commands []Command + bindings []BindingStatus + sequence uint64 + pending map[string]*pendingCall + outbound chan outgoing + ready chan struct{} + done chan struct{} + stop chan struct{} + revoked chan struct{} + callbacks sync.WaitGroup } func (session *Session) Ready() <-chan struct{} { return session.ready } @@ -87,6 +90,16 @@ func (session *Session) Status() (State, error) { return session.state, session.failure } +// Extensions returns a private copy of the staged declarative registrations. +func (session *Session) Extensions() tentaclewire.Extensions { + session.mutex.Lock() + defer session.mutex.Unlock() + data, _ := json.Marshal(session.extensions) + var value tentaclewire.Extensions + _ = json.Unmarshal(data, &value) + return value +} + // Commands returns a copy of staged or active registrations. A caller may // inspect Ready registrations but must not dispatch them before Activate. func (session *Session) Commands() []Command { @@ -123,7 +136,7 @@ func (session *Session) Invoke(ctx context.Context, command string, arguments js if err := ctx.Err(); err != nil { return nil, err } - if len(arguments) > MaxArgumentBytes { + if len(arguments) > MaxReaderArgumentBytes { return nil, ErrLimit } if len(arguments) == 0 { @@ -138,8 +151,17 @@ func (session *Session) Invoke(ctx context.Context, command string, arguments js return nil, ErrUnavailable } known := false + limit := MaxArgumentBytes for _, registered := range session.commands { - known = known || registered.ID == command + if registered.ID == command { + known = true + if registered.Reader { + limit = MaxReaderArgumentBytes + } + } + } + if len(arguments) > limit { + return nil, ErrLimit } if !known { return nil, ErrUnavailable @@ -256,6 +278,7 @@ func (session *Session) fail_locked(err error) { close(session.revoked) session.revokeOutput() session.commands = nil + session.extensions = tentaclewire.Extensions{} session.bindings = nil for id := range session.pending { session.finish_locked(id, nil, err) @@ -281,6 +304,7 @@ func (session *Session) Disable() { close(session.revoked) session.revokeOutput() session.commands = nil + session.extensions = tentaclewire.Extensions{} session.bindings = nil for id := range session.pending { session.finish_locked(id, nil, ErrDisabled) diff --git a/internal/tentacle/testdata/sdk_reader_peer.py b/internal/tentacle/testdata/sdk_reader_peer.py new file mode 100644 index 0000000..efc31a3 --- /dev/null +++ b/internal/tentacle/testdata/sdk_reader_peer.py @@ -0,0 +1,20 @@ +# Private reader-settings conformance peer; no backend or desktop access. +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "sdk/python")) +from cthulhu_tentacle import Command, serve + + +def echo(cancelled, arguments): + return arguments + + +extensions = {"pages": [{"id": "probe", "title": "Reader probe", "command": "echo", + "elements": [{"id": "text", "label": "Example text", "kind": 2}], + "values": {"text": {"text": "Initial"}}}], + "quick_settings": [{"id": "probe", "label": "Probe choice", "command": "echo", + "selected": "default", "options": [{"id": "default", "label": "Default"}]}]} +serve([Command("echo", "Reader probe", "Private fixture", echo, + presentation=True, interaction=True, reader=True), + Command("ordinary", "Ordinary probe", "Private fixture", echo)], extensions=extensions) diff --git a/internal/tentaclewire/interaction.go b/internal/tentaclewire/interaction.go index 99f1d0e..89b9549 100644 --- a/internal/tentaclewire/interaction.go +++ b/internal/tentaclewire/interaction.go @@ -24,6 +24,7 @@ type Interaction struct { Source string `json:"source,omitempty"` // "focus" or "clipboard", supplied to Next. Clipboard *string `json:"clipboard,omitempty"` Done bool `json:"done,omitempty"` + Reader *ReaderRequest `json:"reader,omitempty"` } type Submission struct { @@ -32,6 +33,7 @@ type Submission struct { Cancelled bool `json:"cancelled,omitempty"` Item *Item `json:"item,omitempty"` Error string `json:"error,omitempty"` + Reader *ReaderState `json:"reader,omitempty"` } // Item is a single copied value, without accessible identities or native handles. @@ -69,7 +71,11 @@ func DecodeInteraction(data []byte) (Interaction, error) { if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" { return value, ErrProtocol } - if value.Form != nil { + if value.Reader != nil { + if ValidateReaderRequest(*value.Reader) != nil || value.Form != nil || value.Source != "" || value.Poll != "" || value.Clipboard != nil || value.Done || value.Reader.Snapshot && value.Next == "" { + return value, ErrProtocol + } + } else if value.Form != nil { for _, element := range value.Form.Elements { if !validFormText(element.Label) || !validFormText(element.ID) { return value, ErrProtocol diff --git a/internal/tentaclewire/presentation.go b/internal/tentaclewire/presentation.go index 438f865..e8a388f 100644 --- a/internal/tentaclewire/presentation.go +++ b/internal/tentaclewire/presentation.go @@ -58,10 +58,10 @@ func DecodePresentation(data []byte) (Presentation, error) { // The experimental optional API is additive to the command-only handshake. func ValidAPIs(apis, capabilities []string) bool { - if len(apis) != len(capabilities) || len(apis) < 1 || len(apis) > 3 || apis[0] != "commands" { + if len(apis) != len(capabilities) || len(apis) < 1 || len(apis) > 4 || apis[0] != "commands" { return false } - if len(apis) >= 2 && apis[1] != "presentation" || len(apis) == 3 && apis[2] != "interaction" { + if len(apis) >= 2 && apis[1] != "presentation" || len(apis) >= 3 && apis[2] != "interaction" || len(apis) == 4 && apis[3] != "reader-settings" { return false } for index := range apis { diff --git a/internal/tentaclewire/reader.go b/internal/tentaclewire/reader.go new file mode 100644 index 0000000..d559585 --- /dev/null +++ b/internal/tentaclewire/reader.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package tentaclewire + +import ( + "encoding/json" + "errors" + "strings" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/interaction" + "git.stormux.org/storm/cthulhu/internal/speech" +) + +// SpeechConfiguration is a complete voice configuration, never arbitrary reader +// state. Enablement, interruption policy, input and unrelated preferences remain +// under the core's control. Nil voice parameters retain server/inherited defaults. +type SpeechConfiguration struct { + Server string `json:"server"` + Module string `json:"module"` + Device string `json:"device,omitempty"` + Baud int `json:"baud,omitempty"` + DefaultVoice config.Voice `json:"default_voice"` + UppercaseVoice config.Voice `json:"uppercase_voice"` + HyperlinkVoice config.Voice `json:"hyperlink_voice"` + SystemVoice config.Voice `json:"system_voice"` + RaisePitchForCapitals bool `json:"raise_pitch_for_capitals"` + BeepForCapitals bool `json:"beep_for_capitals"` + SayCapForCapitals bool `json:"say_cap_for_capitals"` + Punctuation int `json:"punctuation"` + CustomEcho bool `json:"custom_echo"` + CustomEchoServer bool `json:"custom_echo_server"` + EchoServer string `json:"echo_server,omitempty"` + EchoVoice config.Voice `json:"echo_voice"` + EchoForKey bool `json:"echo_for_key"` + EchoForCharacter bool `json:"echo_for_character"` + EchoForWord bool `json:"echo_for_word"` + EchoForSentence bool `json:"echo_for_sentence"` +} + +func SpeechFromSettings(settings config.Settings) SpeechConfiguration { + return SpeechConfiguration{ + Server: settings.SpeechServerFactory, Module: settings.SpeechOutputModule, + Device: settings.HardwareSpeechDevice, Baud: settings.HardwareSpeechBaudRate, + DefaultVoice: settings.DefaultVoice, UppercaseVoice: settings.UppercaseVoice, + HyperlinkVoice: settings.HyperlinkVoice, SystemVoice: settings.SystemVoice, + RaisePitchForCapitals: settings.RaisePitchForCapitals, + BeepForCapitals: settings.BeepForCapitals, SayCapForCapitals: settings.SayCapForCapitals, + Punctuation: settings.VerbalizePunctuationStyle, + CustomEcho: settings.UseCustomEchoVoice, CustomEchoServer: settings.UseCustomEchoSpeechServer, + EchoServer: settings.EchoSpeechServer, EchoVoice: settings.EchoVoice, + EchoForKey: settings.UseCustomEchoForKey, EchoForCharacter: settings.UseCustomEchoForCharacter, + EchoForWord: settings.UseCustomEchoForWord, EchoForSentence: settings.UseCustomEchoForSentence, + } +} + +func (value SpeechConfiguration) Apply(settings config.Settings) config.Settings { + settings.SpeechServerFactory, settings.SpeechOutputModule = value.Server, value.Module + settings.HardwareSpeechDevice, settings.HardwareSpeechBaudRate = value.Device, value.Baud + settings.DefaultVoice, settings.UppercaseVoice = value.DefaultVoice, value.UppercaseVoice + settings.HyperlinkVoice, settings.SystemVoice = value.HyperlinkVoice, value.SystemVoice + settings.RaisePitchForCapitals, settings.BeepForCapitals, settings.SayCapForCapitals = value.RaisePitchForCapitals, value.BeepForCapitals, value.SayCapForCapitals + settings.VerbalizePunctuationStyle = value.Punctuation + settings.UseCustomEchoVoice, settings.UseCustomEchoSpeechServer = value.CustomEcho, value.CustomEchoServer + settings.EchoSpeechServer, settings.EchoVoice = value.EchoServer, value.EchoVoice + settings.UseCustomEchoForKey, settings.UseCustomEchoForCharacter = value.EchoForKey, value.EchoForCharacter + settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = value.EchoForWord, value.EchoForSentence + return settings +} + +type PreferencePage struct { + ID string `json:"id"` + Title string `json:"title"` + Command string `json:"command"` + Load bool `json:"load,omitempty"` + Elements []interaction.Element `json:"elements"` + Values map[string]interaction.Value `json:"values"` +} + +type QuickSetting struct { + ID string `json:"id"` + Label string `json:"label"` + Command string `json:"command"` + Options []interaction.Item `json:"options"` + Selected string `json:"selected"` +} + +// Extensions are copied at registration/update. IDs are local to one session. +type Extensions struct { + Pages []PreferencePage `json:"pages,omitempty"` + QuickSettings []QuickSetting `json:"quick_settings,omitempty"` +} + +// ReaderRequest is serviced on the settings worker. Snapshot returns a copied +// state to Next; Module optionally requests that module's catalog. Mutations +// are atomic within this request and never persist the active speech override. +type ReaderRequest struct { + Snapshot bool `json:"snapshot,omitempty"` + Module string `json:"module,omitempty"` + Speech *SpeechConfiguration `json:"speech_configuration,omitempty"` + RestoreSpeech bool `json:"restore_speech,omitempty"` + Extensions *Extensions `json:"extensions,omitempty"` +} + +type ReaderState struct { + Speech SpeechConfiguration `json:"speech_configuration"` + DefaultSpeech SpeechConfiguration `json:"default_speech_configuration"` + Modules []string `json:"modules,omitempty"` + Voices []speech.Voice `json:"voices,omitempty"` + CatalogTruncated bool `json:"catalog_truncated,omitempty"` + Extensions Extensions `json:"extensions"` +} + +// ExtensionEvent handlers propose effects only. The host commits page values +// and quick selections after validating all proposals and applying core settings. +type ExtensionEvent struct { + Event string `json:"event"` + ID string `json:"id"` + Values map[string]interaction.Value `json:"values,omitempty"` + Selected string `json:"selected,omitempty"` + Reader ReaderState `json:"reader"` +} + +func valid_label(value string) bool { + return strings.TrimSpace(value) != "" && len(value) <= 256 && validFormText(value) +} + +func ValidExtensionID(value string) bool { + if len(value) == 0 || len(value) > 64 { + return false + } + for _, character := range value { + if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '.') { + return false + } + } + return true +} + +func ValidateExtensions(value Extensions) error { + if len(value.Pages) > 4 || len(value.QuickSettings) > 8 { + return ErrLimit + } + ids := map[string]bool{} + for _, page := range value.Pages { + if !ValidExtensionID(page.ID) || ids[page.ID] || !valid_label(page.Title) || !ValidExtensionID(page.Command) { + return ErrProtocol + } + ids[page.ID] = true + if err := interaction.ValidatePreferences(interaction.Form{Title: page.Title, Elements: page.Elements}); err != nil { + return ErrProtocol + } + for _, element := range page.Elements { + if !ValidExtensionID(element.ID) || !valid_label(element.Label) || element.Kind == interaction.Button { + return ErrProtocol + } + for _, item := range element.Items { + if !ValidExtensionID(item.ID) || !valid_label(item.Label) { + return ErrProtocol + } + } + } + if err := ValidatePageValues(page, page.Values); err != nil { + return err + } + } + clear(ids) + for _, setting := range value.QuickSettings { + if !ValidExtensionID(setting.ID) || ids[setting.ID] || !valid_label(setting.Label) || !ValidExtensionID(setting.Command) || len(setting.Options) == 0 || len(setting.Options) > 64 { + return ErrProtocol + } + ids[setting.ID] = true + options := map[string]bool{} + selected := false + for _, option := range setting.Options { + if !ValidExtensionID(option.ID) || options[option.ID] || !valid_label(option.Label) { + return ErrProtocol + } + options[option.ID] = true + selected = selected || option.ID == setting.Selected && !option.Disabled + } + if !selected { + return ErrProtocol + } + } + data, err := json.Marshal(value) + if err != nil || len(data) > 32*1024 { + return ErrLimit + } + return nil +} + +func ValidatePageValues(page PreferencePage, values map[string]interaction.Value) error { + if len(values) != len(page.Elements) { + return errors.New("page values do not match its controls") + } + for _, element := range page.Elements { + value, found := values[element.ID] + if !found { + return ErrProtocol + } + switch element.Kind { + case interaction.Entry: + limit := element.MaxLength + if limit == 0 { + limit = 8192 + } + if !validFormText(value.Text) || len([]rune(value.Text)) > limit || value.Selected != "" || value.Checked || element.Required && strings.TrimSpace(value.Text) == "" { + return ErrProtocol + } + case interaction.CheckBox: + if value.Text != "" || value.Selected != "" { + return ErrProtocol + } + case interaction.List: + valid := false + for _, item := range element.Items { + valid = valid || item.ID == value.Selected && !item.Disabled + } + if !valid || value.Text != "" || value.Checked { + return ErrProtocol + } + default: + return ErrProtocol + } + } + return nil +} + +func ValidateReaderRequest(value ReaderRequest) error { + if value.Snapshot && (value.Speech != nil || value.RestoreSpeech || value.Extensions != nil) || value.Speech != nil && value.RestoreSpeech || value.Module != "" && !value.Snapshot || len(value.Module) > 256 || !validFormText(value.Module) { + return ErrProtocol + } + if !value.Snapshot && value.Speech == nil && !value.RestoreSpeech && value.Extensions == nil { + return ErrProtocol + } + if value.Speech != nil { + if err := config.Validate(value.Speech.Apply(config.DefaultSettings())); err != nil { + return err + } + } + if value.Extensions != nil { + return ValidateExtensions(*value.Extensions) + } + return nil +} diff --git a/internal/tentaclewire/reader_test.go b/internal/tentaclewire/reader_test.go new file mode 100644 index 0000000..41f668d --- /dev/null +++ b/internal/tentaclewire/reader_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package tentaclewire + +import ( + "encoding/json" + "fmt" + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/interaction" + "math" + "reflect" + "testing" +) + +func reader_test_extensions() Extensions { + return Extensions{ + Pages: []PreferencePage{{ID: "options", Title: "Reader probe", Command: "apply", Elements: []interaction.Element{ + {ID: "text", Label: "Text", Kind: interaction.Entry, Required: true, MaxLength: 32}, + {ID: "enabled", Label: "Enabled", Kind: interaction.CheckBox}, + {ID: "choice", Label: "Choice", Kind: interaction.List, Items: []interaction.Item{{ID: "first", Label: "First"}, {ID: "second", Label: "Second", Disabled: true}}}, + }, Values: map[string]interaction.Value{"text": {Text: "Initial"}, "enabled": {}, "choice": {Selected: "first"}}}}, + QuickSettings: []QuickSetting{{ID: "choice", Label: "Probe choice", Command: "apply", Options: []interaction.Item{{ID: "default", Label: "Default"}, {ID: "custom", Label: "Custom"}}, Selected: "default"}}, + } +} + +func TestReaderPageCanDescribeSixCompleteVoiceConfigurations(t *testing.T) { + page := PreferencePage{ID: "voices", Title: "Voice configurations", Command: "apply", Values: map[string]interaction.Value{}} + for profile := 1; profile <= 6; profile++ { + for field := 1; field <= 24; field++ { + id := fmt.Sprintf("profile%d.field%d", profile, field) + page.Elements = append(page.Elements, interaction.Element{ID: id, Label: fmt.Sprintf("Profile %d setting %d", profile, field), Kind: interaction.Entry, MaxLength: 64}) + page.Values[id] = interaction.Value{Text: "Example"} + } + } + if err := ValidateExtensions(Extensions{Pages: []PreferencePage{page}}); err != nil { + t.Fatal(err) + } + if _, err := interaction.NewSession(interaction.Form{Title: page.Title, Elements: page.Elements}); err == nil { + t.Fatal("native page limit leaked into internal forms") + } + state := ReaderState{Extensions: Extensions{Pages: []PreferencePage{page}}, Speech: SpeechFromSettings(config.DefaultSettings()), DefaultSpeech: SpeechFromSettings(config.DefaultSettings())} + data, err := json.Marshal(ExtensionEvent{Event: "preferences.apply", ID: page.ID, Values: page.Values, Reader: state}) + if err != nil || len(data) > 48*1024 { + t.Fatal("six configurations exceed callback budget", len(data), err) + } +} +func TestReaderExtensionsRejectInvalidSchemasAndValues(t *testing.T) { + mutations := map[string]func(*Extensions){ + "duplicate page": func(v *Extensions) { v.Pages = append(v.Pages, v.Pages[0]) }, + "duplicate setting": func(v *Extensions) { v.QuickSettings = append(v.QuickSettings, v.QuickSettings[0]) }, + "duplicate control": func(v *Extensions) { v.Pages[0].Elements = append(v.Pages[0].Elements, v.Pages[0].Elements[0]) }, + "button": func(v *Extensions) { v.Pages[0].Elements[0].Kind = interaction.Button }, + "bad label": func(v *Extensions) { v.Pages[0].Title = "Probe\npage" }, + "bad identifier": func(v *Extensions) { v.Pages[0].ID = "../page" }, + "missing value": func(v *Extensions) { delete(v.Pages[0].Values, "text") }, + "extra value": func(v *Extensions) { v.Pages[0].Values["other"] = interaction.Value{} }, + "wrong value type": func(v *Extensions) { v.Pages[0].Values["enabled"] = interaction.Value{Text: "yes"} }, + "required": func(v *Extensions) { v.Pages[0].Values["text"] = interaction.Value{} }, + "oversized": func(v *Extensions) { + v.Pages[0].Values["text"] = interaction.Value{Text: "This string is longer than thirty-two characters"} + }, + "control character": func(v *Extensions) { v.Pages[0].Values["text"] = interaction.Value{Text: "bad\x1bvalue"} }, + "disabled choice": func(v *Extensions) { v.Pages[0].Values["choice"] = interaction.Value{Selected: "second"} }, + "unknown quick choice": func(v *Extensions) { v.QuickSettings[0].Selected = "missing" }, + "disabled quick choice": func(v *Extensions) { v.QuickSettings[0].Options[0].Disabled = true }, + } + if err := ValidateExtensions(reader_test_extensions()); err != nil { + t.Fatal(err) + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + value := reader_test_extensions() + mutate(&value) + if ValidateExtensions(value) == nil { + t.Fatal("accepted invalid extension") + } + }) + } +} +func TestReaderSpeechConfigurationRoundTripAndIsolation(t *testing.T) { + settings := config.DefaultSettings() + rate, pitch, gain := 72.0, 6.2, 8.1 + settings.DefaultVoice = config.Voice{Rate: &rate, AveragePitch: &pitch, Gain: &gain, Family: &config.VoiceFamily{Name: "Example voice", Lang: "en", Dialect: "US", Variant: "sample"}} + settings.UppercaseVoice = config.Voice{AveragePitch: &pitch} + settings.HyperlinkVoice = config.Voice{Rate: &rate} + settings.SystemVoice = config.Voice{Gain: &gain} + settings.UseCustomEchoVoice = true + settings.EchoVoice = config.Voice{Rate: &rate} + base := config.DefaultSettings() + base.EnableSpeech = false + base.EnableBraille = true + result := SpeechFromSettings(settings).Apply(base) + if !reflect.DeepEqual(SpeechFromSettings(result), SpeechFromSettings(settings)) { + t.Fatal("voice settings lost") + } + if result.EnableSpeech || !result.EnableBraille || result.KeyboardLayout != base.KeyboardLayout { + t.Fatal("unrelated settings changed") + } + value := SpeechFromSettings(settings) + value.DefaultVoice.Rate = new(float64) + *value.DefaultVoice.Rate = math.NaN() + if ValidateReaderRequest(ReaderRequest{Speech: &value}) == nil { + t.Fatal("accepted NaN") + } +} +func TestReaderInteractionValidation(t *testing.T) { + for _, raw := range []string{ + `{"reader":{"snapshot":true},"next":"receive"}`, + `{"reader":{"restore_speech":true}}`, + `{"reader":{"extensions":{}}}`, + } { + if _, err := DecodeInteraction([]byte(raw)); err != nil { + t.Fatal(raw, err) + } + } + for _, raw := range []string{ + `{"reader":{"snapshot":true}}`, + `{"reader":{"snapshot":true,"restore_speech":true},"next":"receive"}`, + `{"reader":{"restore_speech":true},"clipboard":"value"}`, + `{"reader":{"restore_speech":true},"done":true}`, + `{"reader":{"module":"example"}}`, + `{"reader":{"restore_speech":true,"unknown":true}}`, + `{"reader":null}`, + } { + if _, err := DecodeInteraction([]byte(raw)); err == nil { + t.Fatal("accepted", raw) + } + } + value := reader_test_extensions() + data, _ := json.Marshal(Interaction{Reader: &ReaderRequest{Extensions: &value}}) + if _, err := DecodeInteraction(data); err != nil { + t.Fatal(err) + } +} diff --git a/sdk/go/tentacle/tentacle.go b/sdk/go/tentacle/tentacle.go index 67f4fed..6c1c3d9 100644 --- a/sdk/go/tentacle/tentacle.go +++ b/sdk/go/tentacle/tentacle.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "errors" + "git.stormux.org/storm/cthulhu/internal/config" "io" "strconv" "sync" @@ -28,6 +29,16 @@ type Binding struct { // Declare Command.Presentation and obtain the local host grant before using it. // Uninterruptible protects accepted speech through ordinary input/focus changes; // session revocation and core lifecycle/output policy still take precedence. +type Extensions = tentaclewire.Extensions +type PreferencePage = tentaclewire.PreferencePage +type QuickSetting = tentaclewire.QuickSetting +type ReaderRequest = tentaclewire.ReaderRequest +type ReaderState = tentaclewire.ReaderState +type ExtensionEvent = tentaclewire.ExtensionEvent +type SpeechConfiguration = tentaclewire.SpeechConfiguration +type Voice = config.Voice +type VoiceFamily = config.VoiceFamily + type Presentation = tentaclewire.Presentation // Interaction requires the explicit interaction grant as well as presentation. @@ -60,6 +71,7 @@ type Command struct { Interaction bool `json:"interaction,omitempty"` // Settings exposes this interactive command through global Preferences. // Register at most one; its handler opens a form without requiring arguments. + Reader bool `json:"reader,omitempty"` Settings bool `json:"settings,omitempty"` Handle Handler `json:"-"` } @@ -78,21 +90,35 @@ type request struct { // handlers and waits for them; the host kills a child that ignores cancellation. // Never print to stdout. Command declarations must remain unchanged during Serve. func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error { + return serve(input, output, commands, nil) +} + +// ServeWithExtensions declares session-owned preferences pages and quick settings. +func ServeWithExtensions(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions Extensions) error { + if err := tentaclewire.ValidateExtensions(extensions); err != nil { + return err + } + return serve(input, output, commands, &extensions) +} + +func serve(input io.ReadCloser, output io.WriteCloser, commands []Command, extensions *Extensions) error { ctx, cancel := context.WithCancel(context.Background()) var workers sync.WaitGroup var writeMutex sync.Mutex defer func() { cancel(); input.Close(); output.Close(); workers.Wait() }() handlers := make(map[string]Handler) + readerCommands := make(map[string]bool) if len(commands) == 0 || len(commands) > 32 { return ErrProtocol } settings := false for _, command := range commands { - if command.Settings && (!command.Interaction || !command.Presentation || settings) || command.ID == "" || command.Handle == nil || handlers[command.ID] != nil { + if command.Reader && !command.Interaction || command.Settings && (!command.Interaction || !command.Presentation || settings) || command.ID == "" || command.Handle == nil || handlers[command.ID] != nil { return ErrProtocol } settings = settings || command.Settings handlers[command.ID] = command.Handle + readerCommands[command.ID] = command.Reader } writeReply := func(id string, value any, failed bool, release func()) error { reply := map[string]any{"jsonrpc": "2.0", "id": id, "result": value} @@ -169,12 +195,19 @@ func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error return ErrProtocol } for _, command := range commands { - if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) != 3) { + if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) < 3) || command.Reader && len(params.APIs) != 4 { return ErrProtocol } } + if extensions != nil && len(params.APIs) != 4 { + return ErrProtocol + } session = params.Session - if err := writeReply(message.ID, map[string]any{"protocol": params.Protocol, "session": session, "commands": commands}, false, nil); err != nil { + registration := map[string]any{"protocol": params.Protocol, "session": session, "commands": commands} + if extensions != nil { + registration["extensions"] = extensions + } + if err := writeReply(message.ID, registration, false, nil); err != nil { return err } case "initialized": @@ -190,7 +223,7 @@ func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error Command string `json:"command"` Arguments json.RawMessage `json:"arguments"` } - if !active || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || len(params.Arguments) == 0 || len(params.Arguments) > 16*1024 || handlers[params.Command] == nil { + if !active || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || len(params.Arguments) == 0 || len(params.Arguments) > command_argument_limit(readerCommands[params.Command]) || handlers[params.Command] == nil { return ErrProtocol } select { @@ -235,3 +268,10 @@ func call_handler(ctx context.Context, handler Handler, arguments json.RawMessag }() return handler(ctx, arguments) } + +func command_argument_limit(reader bool) int { + if reader { + return 48 * 1024 + } + return 16 * 1024 +} diff --git a/sdk/python/cthulhu_tentacle/__init__.py b/sdk/python/cthulhu_tentacle/__init__.py index a2a8c24..e5f29e9 100644 --- a/sdk/python/cthulhu_tentacle/__init__.py +++ b/sdk/python/cthulhu_tentacle/__init__.py @@ -22,12 +22,18 @@ class Command: handle: object bindings: list = field(default_factory=list) presentation: bool = False + interaction: bool = False + settings: bool = False + reader: bool = False def declaration(self): declaration = {"id": self.id, "title": self.title, "description": self.description, "bindings": self.bindings} if self.presentation: declaration["presentation"] = True + for name in ("interaction", "settings", "reader"): + if getattr(self, name): + declaration[name] = True return declaration @@ -74,7 +80,7 @@ def _fields(value, required, optional=()): raise ProtocolError("Invalid fields") -def serve(commands, inputStream=None, outputStream=None): +def serve(commands, inputStream=None, outputStream=None, extensions=None): """Run in a dedicated child, then exit. Handlers receive (cancelled, arguments). cancelled is a threading.Event shared by the session. Up to eight handlers @@ -138,6 +144,11 @@ def serve(commands, inputStream=None, outputStream=None): if (not 1 <= len(commands) <= 32 or len(handlers) != len(commands) or any(not key or not callable(value) for key, value in handlers.items())): raise ProtocolError("Invalid commands") + if (sum(command.settings for command in commands) > 1 + or any(command.interaction and not command.presentation + or command.settings and not command.interaction + or command.reader and not command.interaction for command in commands)): + raise ProtocolError("Invalid command capabilities") session, active, sequence = None, False, 0 while not cancelled.is_set(): frame = inputStream.readline(MAX_FRAME + 2) @@ -172,14 +183,22 @@ def serve(commands, inputStream=None, outputStream=None): or any(type(value) is not int for value in version.values()) or not isinstance(params["session"], str) or not params["session"] or len(params["session"].encode("utf-8")) > 128 - or params["apis"] not in (["commands"], ["commands", "presentation"]) + or params["apis"] not in (["commands"], ["commands", "presentation"], + ["commands", "presentation", "interaction"], + ["commands", "presentation", "interaction", "reader-settings"]) or params["capabilities"] != params["apis"] or (any(command.presentation for command in commands) - and params["apis"] != ["commands", "presentation"])): + and len(params["apis"]) < 2) + or (any(command.interaction for command in commands) and len(params["apis"]) < 3) + or ((extensions is not None or any(command.reader for command in commands)) + and len(params["apis"]) != 4)): raise ProtocolError("Invalid initialization") session = params["session"] - write_reply(requestId, {"protocol": version, "session": session, - "commands": [command.declaration() for command in commands]}) + registration = {"protocol": version, "session": session, + "commands": [command.declaration() for command in commands]} + if extensions is not None: + registration["extensions"] = extensions + write_reply(requestId, registration) elif method == "initialized": _fields(params, ("session",)) if session is None or active or requestId or params["session"] != session: @@ -189,7 +208,8 @@ def serve(commands, inputStream=None, outputStream=None): _fields(params, ("command", "arguments")) if (not active or not requestId or not isinstance(params["command"], str) or params["command"] not in handlers - or len(_encode(params["arguments"])) > 16 * 1024): + or len(_encode(params["arguments"])) > (48 if next( + command.reader for command in commands if command.id == params["command"]) else 16) * 1024): raise ProtocolError("Invalid invocation") workers = [worker for worker in workers if worker.is_alive()] if not slots.acquire(blocking=False): diff --git a/tests/native_preferences.py b/tests/native_preferences.py index 5c490bf..70ee5dd 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -151,6 +151,9 @@ def inside(): if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1": initial["Tentacles"] = {"Entries": [{"Entry": {"ID": "probe", "Name": "Settings probe", "Enabled": True}, "State": "active", "Shortcuts": ["Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)"]}], "Suppressed": False} + if os.environ.get("CTHULHU_PREFS_EXTENSIONS_ONLY") == "1": + from native_tentacle_extensions import probe_pages + initial["ExtensionPages"] = probe_pages() if application: initial["Application"] = application def send(value): @@ -438,6 +441,10 @@ def inside(): assert process.wait(timeout=5) == 0 print("PASS: echo synthesizer initialization, keyboard navigation, synthesizer/person previews, routing choices, Apply/reload, disable/re-enable") return + if os.environ.get("CTHULHU_PREFS_EXTENSIONS_ONLY") == "1": + from native_tentacle_extensions import check_page + check_page(pyatspi, process, window, nodes, walk, focus, key, request, send, initial) + return if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1": from native_tentacle_settings import check_page check_page(pyatspi, window, nodes, named, walk, focus, key, request, send, initial) diff --git a/tests/native_tentacle_extensions.py b/tests/native_tentacle_extensions.py new file mode 100644 index 0000000..a30cce6 --- /dev/null +++ b/tests/native_tentacle_extensions.py @@ -0,0 +1,100 @@ +"""Private native controls for declarative Tentacle preference pages.""" +import copy +from native_x11 import wait_for + + +def probe_pages(): + return [{"Session": "private-probe", "Revision": 1, + "id": "probe", "title": "Extension probe", "command": "apply", "load": True, + "elements": [{"id": "text", "label": "Extension text", "kind": 2, + "required": True, "max_length": 32}, + {"id": "choice", "label": "Extension choice", "kind": 1, + "items": [{"id": "first", "label": "First choice"}, + {"id": "second", "label": "Second choice"}]}, + {"id": "enabled", "label": "Extension enabled", "kind": 3}], + "values": {"text": {"text": "Initial"}, "choice": {"selected": "first"}, + "enabled": {"checked": False}}}] + + +def check_page(pyatspi, process, window, nodes, walk, focus, key, request, send, initial): + def find(name, role=None): + return next(node for node in nodes if node.name == name and + (role is None or node.getRole() == role)) + + def focused(node): + return any(child.getState().contains(pyatspi.STATE_FOCUSED) for child in walk(node)) + + tabs = next(node for node in nodes if node.getRole() == pyatspi.ROLE_PAGE_TAB_LIST) + index = next(index for index, node in enumerate(tabs) if node.name == "Extension probe") + assert tabs.querySelection().selectChild(index) + text = find("Extension text", pyatspi.ROLE_TEXT) + choice = find("Extension choice", pyatspi.ROLE_COMBO_BOX) + enabled = find("Extension enabled", pyatspi.ROLE_CHECK_BOX) + cancelButton = find("Cancel", pyatspi.ROLE_PUSH_BUTTON) + assert text.queryText().getText(0, -1) == "Initial" + labels = [node for node in nodes if node.name == "Extension text" and + node.getRole() == pyatspi.ROLE_LABEL] + assert labels and any(relation.getRelationType() == pyatspi.RELATION_LABEL_FOR + for relation in labels[0].getRelationSet()), "missing label buddy" + status = find(initial["Message"]) + focus(text) + key("Tab") + key("Down") + # GTK focuses an internal toggle button that need not expose FOCUSED in + # AT-SPI. A keyboard selection change proves that Tab reached the combo. + wait_for(lambda: choice.querySelection().getSelectedChild(0).name == "Second choice", + "Tab did not reach the combo or Down did not change its value") + key("Tab") + wait_for(lambda: focused(enabled), "extension combo trapped Tab") + key("space") + key("shift+Tab") + key("Up") + wait_for(lambda: choice.querySelection().getSelectedChild(0).name == "First choice", + "Shift+Tab did not return to the combo") + key("space") + wait_for(lambda: any(node.getRole() == pyatspi.ROLE_MENU and + node.getState().contains(pyatspi.STATE_SHOWING) for node in walk(choice)), + "extension combo popup did not open from the keyboard") + key("End", "Return") + wait_for(lambda: choice.querySelection().getSelectedChild(0).name == "Second choice", + "popup selection was not accepted") + key("shift+Tab") + wait_for(lambda: focused(text), "extension combo trapped Shift+Tab") + key("ctrl+a") + key("e", "d", "i", "t") + wait_for(lambda: text.queryText().getText(0, -1) == "edit", "entry edit missing") + # A status refresh with unchanged revisions must preserve the active draft. + send({"ExtensionsOnly": True, "ExtensionPages": initial["ExtensionPages"]}) + wait_for(lambda: focused(text), "refresh changed focus") + assert text.queryText().getText(0, -1) == "edit" + focus(find("Apply", pyatspi.ROLE_PUSH_BUTTON)); key("space") + draft = request() + page = draft["ExtensionPages"][0] + assert page["Session"] == "private-probe" and page["Revision"] == 1 + assert page["Values"] == {"text": {"text": "edit"}, "choice": {"selected": "second"}, + "enabled": {"checked": True}}, page + send({"Accepted": False, "CanSave": True, "Message": "Probe rejected"}) + wait_for(lambda: status.name == "Probe rejected", "rejection missing") + assert text.queryText().getText(0, -1) == "edit", "rejection discarded draft" + focus(find("Apply", pyatspi.ROLE_PUSH_BUTTON)); key("space") + draft = request() + pages = copy.deepcopy(initial["ExtensionPages"]) + pages[0]["Revision"] = 2 + pages[0]["values"] = draft["ExtensionPages"][0]["Values"] + send({"Accepted": True, "CanSave": True, "Values": draft["Values"], + "ExtensionPages": pages, "Message": "Probe applied"}) + wait_for(lambda: status.name == "Probe applied", "apply response missing") + assert tabs.querySelection().getSelectedChild(0).name == "Extension probe", "Apply changed the selected tab" + nodes = list(walk(window)) + text = find("Extension text", pyatspi.ROLE_TEXT) + assert text.queryText().getText(0, -1) == "edit", "applied values not restored" + # Removing a focused page must leave the core controls usable. + focus(text) + send({"ExtensionsOnly": True, "ExtensionPages": []}) + wait_for(lambda: not any(node.name == "Extension probe" and + node.getRole() == pyatspi.ROLE_PAGE_TAB for node in tabs), + "revoked extension tab survived") + assert tabs.querySelection().getSelectedChild(0).name == "General", "removal did not return to a core page" + focus(cancelButton); key("space") + assert process.wait(timeout=5) == 0 + print("PASS: extension labels, entries, combos, checkboxes, Tab/Shift+Tab, draft retention, Apply rejection/acceptance and focused-page removal", flush=True)