diff --git a/README.md b/README.md index 862f474..c6cf15e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ Cthulhu is a Linux screen reader written in Go. -- [2026.10.31 release notes](docs/releases/2026.10.31.md): upgrading from Python, building, packaging, and release verification. - [Repository Guidelines](AGENTS.md): layout, commands, and working safeguards. - [Contributing](CONTRIBUTING.md): development, accessibility verification, licensing, and the AI-assisted contribution policy. - [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification. @@ -16,7 +15,6 @@ Cthulhu is a Linux screen reader written in Go. - [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification. - [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation. - [Audacious GUI](docs/testing/audacious.md): isolated Qt/GTK menu and playlist checks, row-focus fixes, and live verification. -- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification. - [Tentacle updates](docs/testing/tentacle-updates.md): package recovery, version comparison, and update 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). @@ -129,7 +127,6 @@ The option is available in global and application preferences. Apply saves it; application choices override the global value. Sticky browse mode still keeps browse mode, and sticky focus mode still keeps focus mode. Use Cthulhu+A to switch back to browse mode when finished interacting. -See [automatic focus verification](docs/testing/structural-focus.md). ## Emoji menu @@ -143,8 +140,6 @@ without using the clipboard. Browse mode blocks typing. Checked copies the emoji without typing it. The choice is remembered for the current reader session. Escape cancels. -See [emoji menu verification](docs/testing/emoji-menu.md) for live checks. - ## Key echo The Key Echo preferences page offers three radio choices: Disable key echo, diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index f135a25..0569f03 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.29.r581.g44581c4 +pkgver=2026.10.31.r588.g9680b58 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support" url="https://git.stormux.org/storm/cthulhu" diff --git a/docs/testing/audacious.md b/docs/testing/audacious.md new file mode 100644 index 0000000..ca16dc7 --- /dev/null +++ b/docs/testing/audacious.md @@ -0,0 +1,32 @@ +# Audacious GUI verification + +The shared AT-SPI table handling recognizes Qt trees exposing the Table +interface and GTK list tables. A uniquely selected row can restore its showing, +focused cell after a menu closes. Multiple selected rows must not produce a +guessed focus target. GUI full-row speech omits cells that are not showing. +When Qt hides a submenu without a focus event, its parent action is accepted +only with fresh provider focus and visibility evidence. + +Run `python3 tests/native_audacious.py` with Audacious's Qt and GTK interfaces, +Xvfb, i3, xdotool, Python gi/pyatspi, and the native build dependencies installed. +The harness creates temporary tracks and private configuration, display and +D-Bus sessions. It checks queued speech and Braille for playlists, nested menus, +the playlist manager, settings, and context menus. It does not verify audible +speech or a physical Braille display. Also run the +[generic Qt checks](qt-control-states.md). + +For live acceptance: + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. In an Audacious playlist, move between tracks. Expect the selected track's + visible row content in speech and the focused cell on Braille, without + hidden filename/path columns. +3. Open Playlist's Sort submenu. Move through commands, then press Left and + Escape. Expect the parent menu item on return and the original track when + the menus close; arrow keys must continue moving through tracks. +4. Open Settings, Tab and Shift+Tab through controls, then close it. Expect each + focused control and restoration of the playlist. Unfocused password-field + initialization must not be announced. +5. Repeat with the other interface when available. Record the exact speech, + Braille, focused control and key sequence for any discrepancy. diff --git a/docs/testing/tentacle-updates.md b/docs/testing/tentacle-updates.md new file mode 100644 index 0000000..1327469 --- /dev/null +++ b/docs/testing/tentacle-updates.md @@ -0,0 +1,44 @@ +# Tentacle update verification + +Refresh an installed Tentacle's repository in Preferences, select the Tentacle, +and choose Update. The confirmation displays both versions and the old and new +capabilities. Cancel must leave the installation unchanged. Update preserves +enablement at the point the package is saved, including a disable requested +while downloading. + +Versions use dotted numeric components, optional prerelease identifiers and +optional build metadata. Missing numeric components compare as zero; build +metadata does not make a release newer. Invalid versions are not offered as +updates. Downloads retain the existing HTTPS, checksum and manifest checks. + +An enabled replacement must register and activate before success is reported. +Startup failure or cancellation attempts to restore the previous package and +enablement. Recovery persistence errors are reported explicitly. Retired files +remain until their session has stopped. A disabled package is replaced without +starting it. + +Automated checks: + +```sh +go test ./internal/tentacle ./internal/app -run 'Test.*(Upgrade|Update|Version)' -count=1 +go build -o /tmp/cthulhu-update-check ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-update-check CTHULHU_PREFS_TENTACLE_UPDATES_ONLY=1 python3 tests/native_preferences.py +``` + +The GTK harness uses fixture data and a private display/bus, checks Tab and +Shift+Tab, confirmation details, Cancel and the upgrade request, and does not +update installed packages. Lifecycle tests exercise activation and rollback. + +For live acceptance: + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. Open Preferences and select an installed test Tentacle with a newer release. + Refresh its repository, then Tab to Update and use Shift+Tab to return. + Expect accessible control names in speech/Braille and no keyboard trap. +3. Activate Update. Verify versions and capabilities are readable, then Cancel. + Verify the installed version and existing shortcuts still work. +4. If you intend to install that release, reopen Update and accept. Verify the + new version, its shortcuts and its previous enabled/disabled state. Close + and reopen Preferences to verify persistence. +5. Record exact speech, Braille, focus and keyboard results for discrepancies. diff --git a/docs/testing/wine-proton.md b/docs/testing/wine-proton.md new file mode 100644 index 0000000..0fc8d45 --- /dev/null +++ b/docs/testing/wine-proton.md @@ -0,0 +1,48 @@ +# Wine and Proton verification + +Cthulhu selects a loader from the application's Wine environment and executable. +It bypasses a recognized Protontricks runtime proxy and searches the Proton +installation for a usable loader. For processes advertising +`PRESSURE_VESSEL_RUNTIME`, it stages the complete helper set under +`$XDG_STATE_HOME/cthulhu/wine-access/helper` (default `~/.local/state`). Sets are +content-addressed and verified before use; running sets are never overwritten. +The helper keeps the reader's D-Bus address and receives the application's +display, authentication and Wine synchronization settings. + +The active profile can contain: + +```toml +[profiles.default.wine] +controller-only = false +excluded-hook-programs = ["ExampleGame.exe"] +``` + +Use your actual active profile name in place of `default`. Exclusions are +case-insensitive executable basenames, without paths. These settings are read +when the Wine manager starts; restart Cthulhu after editing them. They are not +live application-profile switches. The helper must support +`--exclude-hook-program` and `--no-dialog-reader`. + +An excluded program appearing in a prefix switches that prefix's helper to +controller-only mode. That mode remains until the prefix is retired, even if +the excluded program exits while a launcher remains. `controller-only = true` +or a nonempty `WINE_ACCESS_NO_DIALOG_READER` other than `0` requests it globally. + +Run `go test ./internal/wine ./internal/config -count=1` for loader selection, +staging, environment forwarding, exclusion handling and settings persistence. +Fixtures do not establish compatibility with a real Proton runtime or audible +Wine output. + +For live acceptance: + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. Open an ordinary Wine editor. Verify focused controls, typing, selection and + menus are spoken, Braille follows focus, and keyboard navigation is intact. +3. Launch a test Proton application. Verify its supported accessibility output + reaches the reader and closing it does not leave an unnecessary helper. +4. With an intentionally excluded test game, verify its controller speech still + arrives without dialog-reader interference. Check an ordinary editor in a + separate prefix still has normal dialog reading. +5. Report the exact application, prefix relationship, speech, Braille and focus + behavior if a result differs. Redact private paths and text from debug logs. diff --git a/docs/testing/xterm-handoff.md b/docs/testing/xterm-handoff.md new file mode 100644 index 0000000..4759b94 --- /dev/null +++ b/docs/testing/xterm-handoff.md @@ -0,0 +1,30 @@ +# XTerm handoff verification + +While input is delegated to a terminal reader in XTerm, Cthulhu suppresses +ordinary key echo, input-driven speech interruption and lock-state feedback. +It releases its command grabs. On return, Caps Lock tracking adopts the current +XKB lock state before treating Caps Lock as the reader modifier again. + +Focused checks: + +```sh +go test ./internal/app -run 'Test(XTerm|DelegatedInput)' -count=1 +python3 tests/native_x11.py +``` + +The native harness uses a private X display and checks grab suspension, +restoration and lock state with synthetic input. Audible output from another +reader and physical keyboard handling require live verification. + +1. Quit Cthulhu with Cthulhu+Q. Press Ctrl+Escape, then Shift+G to rebuild and + launch it. These are separate key presses. +2. Focus XTerm with your console reader active. Press its reader modifier and + navigation keys. Expect the console reader to receive them, without + Cthulhu modifier echo or Cthulhu interrupting its speech. +3. Change Caps Lock in XTerm, then switch to an ordinary desktop application. + Expect Cthulhu to resume focused-control speech and Braille and accept its + configured modifier commands with the current lock state. +4. Switch back and repeat with keypad commands where configured. Confirm + focus stays on the intended application and no key remains grabbed after + handoff. Restore the original lock state. +5. Record the exact keys, focused application, speech and Braille for failures. diff --git a/internal/app/tentacle_lifecycle.go b/internal/app/tentacle_lifecycle.go new file mode 100644 index 0000000..6bd7a4b --- /dev/null +++ b/internal/app/tentacle_lifecycle.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +// A startup request is owned by the lifecycle worker until activation or failure. +// Cancellation only wakes that worker; it never mutates registry or session state. +type tentacleStartupCall struct { + call tentacleManagementCall + saveError error + stop func() bool + previous *tentacle.Entry +} + +// A retired package stays on disk until the session using it has joined. +type tentacleRetiredPackage struct { + entry tentacle.Entry + session *tentacle.Session +} + +// tentacleLifecycle owns managed state for one run, on the lifecycle goroutine. +// Watchers and cancellation callbacks only notify it; they never mutate these +// maps or transitions. Startup rollback runs before shutdown joins sessions. +type tentacleLifecycle struct { + ctx context.Context + runtime *tentacleRuntime + entries []tentacle.Entry + repositories []tentacle.Repository + loadError error + pending map[string]bool + sessions map[string]*tentacle.Session + problems map[string]string + startups map[string]tentacleStartupCall + removedPackages map[string]tentacleRetiredPackage + wake chan struct{} + manager *tentacle.Manager + current *tentacle.Session + ready <-chan struct{} + active []*tentacle.Session + watchers []<-chan struct{} +} + +func (runtime *tentacleRuntime) run_managed(ctx context.Context) error { + entries, loadError := runtime.registry.Load() + owner := &tentacleLifecycle{ + ctx: ctx, + runtime: runtime, + entries: entries, + loadError: loadError, + repositories: runtime.registry.Repositories(), + pending: make(map[string]bool), + sessions: make(map[string]*tentacle.Session), + problems: make(map[string]string), + startups: make(map[string]tentacleStartupCall), + removedPackages: make(map[string]tentacleRetiredPackage), + wake: make(chan struct{}, 1), + } + if loadError == nil { + if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil { + runtime.logger.Warn("recover tentacle packages", "error", err) + } + } + for _, entry := range entries { + owner.pending[entry.ID] = entry.Enabled + } + owner.setSnapshot() + if loadError != nil { + runtime.report("Tentacles could not start.") + } + defer owner.shutdown() + defer owner.cancelStartups() + return owner.run() +} + +func (owner *tentacleLifecycle) notify() { + select { + case owner.wake <- struct{}{}: + default: + } +} + +func (owner *tentacleLifecycle) setSnapshot() { + result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(owner.repositories)} + if owner.loadError != nil { + result.Message = "Tentacle registry could not be loaded; no tentacles were started." + } + for _, entry := range owner.entries { + status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: owner.problems[entry.ID]} + if entry.Enabled { + status.State = "starting" + } + if session := owner.sessions[entry.ID]; session != nil { + state, _ := session.Status() + status.State = string(state) + if state == tentacle.Ready { + status.State = "starting" + } + if (state == tentacle.Failed || state == tentacle.Stopped) && entry.Enabled && status.Message == "" { + status.Message = "Tentacle stopped; retry to start it again." + } + } + if owner.pending[entry.ID] { + status.State = "starting" + } + if status.Message != "" && !owner.pending[entry.ID] { + status.State = "failed" + } + if !entry.Enabled && owner.sessions[entry.ID] == nil { + status.State = "disabled" + } + result.Entries = append(result.Entries, status) + } + result.Updates = tentacle.AvailableUpdates(result) + owner.runtime.mutex.Lock() + owner.runtime.snapshot = result + owner.runtime.mutex.Unlock() +} + +func (owner *tentacleLifecycle) shutdown() { + owner.runtime.Disable() + cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second) + defer cancel() + var bindingError error + if owner.runtime.input != nil { + bindingError = owner.runtime.input.SetTentacleBindings(cleanup, nil) + } + if owner.runtime.readerContext.Err() != nil { + bindingError = nil + } + var managerError error + if owner.manager != nil { + managerError = owner.manager.Shutdown(cleanup) + } + for _, retired := range owner.removedPackages { + entry := retired.entry + if managerError == nil { + if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil { + owner.runtime.logger.Warn("remove tentacle package", "error", err) + } + } + } + for _, done := range owner.watchers { + <-done + } + owner.runtime.results.Wait() + owner.runtime.stopError = errors.Join(bindingError, managerError) + if owner.runtime.stopError != nil && owner.runtime.readerContext.Err() == nil { + select { + case owner.runtime.failures <- owner.runtime.stopError: + default: + } + } +} + +func (owner *tentacleLifecycle) reconcileBindings() error { + retained := owner.active[:0] + for _, session := range owner.active { + if state, _ := session.Status(); state == tentacle.Active { + retained = append(retained, session) + } + } + changed := len(retained) != len(owner.active) + owner.active = retained + if changed { + owner.runtime.publishCommands(owner.active) + if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil { + select { + case owner.runtime.failures <- err: + default: + } + return err + } + } + + return nil +} + +func (owner *tentacleLifecycle) reapSessions() { + for id, session := range owner.sessions { + select { + case <-session.Done(): + if state, failure := session.Status(); state == tentacle.Failed { + owner.runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure) + } + if !owner.pending[id] { + index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id }) + if index >= 0 && owner.entries[index].Enabled && owner.problems[id] == "" { + owner.problems[id] = "Tentacle stopped; retry to start it again." + owner.runtime.report("A tentacle stopped unexpectedly.") + } + } + delete(owner.sessions, id) + owner.runtime.mutex.Lock() + owner.runtime.sessions = slices.DeleteFunc(owner.runtime.sessions, func(value *tentacle.Session) bool { return value == session }) + owner.runtime.mutex.Unlock() + default: + } + } +} + +func (owner *tentacleLifecycle) removeRetiredPackages() { + for directory, retired := range owner.removedPackages { + entry := retired.entry + if retired.session != nil { + select { + case <-retired.session.Done(): + default: + continue + } + } + if slices.ContainsFunc(owner.entries, func(candidateEntry tentacle.Entry) bool { + return candidateEntry.PackageDirectory == directory + }) { + continue + } + if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil { + owner.runtime.logger.Warn("remove tentacle package", "error", err) + owner.runtime.report("Tentacle was removed, but its package files could not be deleted.") + continue + } + delete(owner.removedPackages, directory) + } +} + +func (owner *tentacleLifecycle) run() error { + for { + if owner.ctx.Err() != nil { + return nil + } + for id, startup := range owner.startups { + if startup.call.ctx.Err() != nil { + owner.failStartup(id, "Tentacle startup was cancelled before activation.") + } + } + // Reconcile revoked sessions before staging new native grabs, and release + // joined sessions before starting replacements or removing their files. + if err := owner.reconcileBindings(); err != nil { + return err + } + owner.reapSessions() + if owner.current == nil { + owner.removeRetiredPackages() + owner.startPending() + } + owner.setSnapshot() + select { + case <-owner.ctx.Done(): + return nil + case <-owner.wake: + case call := <-owner.runtime.requests: + owner.handleRequest(call) + case <-owner.ready: + if err := owner.activateReady(); err != nil { + return err + } + } + } +} diff --git a/internal/app/tentacle_lifecycle_requests.go b/internal/app/tentacle_lifecycle_requests.go new file mode 100644 index 0000000..5240087 --- /dev/null +++ b/internal/app/tentacle_lifecycle_requests.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "reflect" + "slices" + "strings" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (owner *tentacleLifecycle) handleRequest(call tentacleManagementCall) { + if err := call.ctx.Err(); err != nil { + call.result <- err + return + } + if owner.loadError != nil { + call.result <- errors.New("tentacle registry could not be loaded; repair it and restart") + return + } + owner.runtime.mutex.Lock() + disabled := owner.runtime.disabled + owner.runtime.mutex.Unlock() + if disabled { + call.result <- tentacle.ErrDisabled + return + } + if strings.HasPrefix(call.request.Action, "repository-") { + next, err := change_tentacle_repositories(owner.repositories, call.request, call.repository) + if err == nil { + err = owner.runtime.registry.SaveState(owner.entries, next) + } + if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { + owner.repositories = next + owner.setSnapshot() + } + call.result <- err + return + } + if call.packageFiles != nil { + index := slices.IndexFunc(owner.repositories, func(value tentacle.Repository) bool { return value.URL == call.repository.URL }) + if index < 0 || !reflect.DeepEqual(owner.repositories[index], *call.repository) { + call.result <- errors.New("Repository changed during installation; refresh and try again.") + return + } + } + if _, starting := owner.startups[call.request.ID]; starting && call.request.Action != "disable" && call.request.Action != "remove" { + call.result <- tentacle.ErrBusy + return + } + if startup, exists := owner.startups[call.request.ID]; exists && startup.previous != nil { + owner.failStartup(call.request.ID, "Tentacle update was cancelled by another operation.") + } + next, err := change_tentacle_entries(owner.entries, call.request) + if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, owner.entries) { + // Saved enablement is intent, not proof of a live session. An + // explicit enable/update must recover a failed or starting entry. + index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == call.request.ID }) + state := tentacle.Stopped + if session := owner.sessions[call.request.ID]; session != nil { + state, _ = session.Status() + } + if !next[index].Enabled || state == tentacle.Active { + call.result <- nil + return + } + } + if err == nil && call.request.Action != "retry" { + err = owner.runtime.registry.Save(next) + } + if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { + id := call.request.ID + var previous *tentacle.Entry + if call.packageFiles != nil && call.request.Action == "update" { + index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id }) + if index >= 0 && owner.entries[index].Enabled { + saved := tentacle.CloneEntries(owner.entries[index : index+1])[0] + previous = &saved + } + } + owner.replyStartup(id, errors.New("Tentacle startup was cancelled by another operation.")) + if call.packageFiles != nil { + call.packageFiles.Retain() + } + if call.request.Action == "remove" || call.request.Action == "update" { + for _, entry := range owner.entries { + if entry.ID != id || entry.PackageDirectory == "" { + continue + } + if call.request.Action == "update" && (previous != nil || + entry.PackageDirectory == call.request.Entry.PackageDirectory) { + continue + } + owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]} + } + } + owner.revoke(id) + if owner.current != nil && owner.current.TentacleID() == id { + owner.current, owner.ready = nil, nil + } + owner.entries = next + delete(owner.problems, id) + delete(owner.pending, id) + for _, entry := range owner.entries { + if entry.ID == id { + owner.pending[id] = entry.Enabled + } + } + // Immediately withdraw dispatch, without waiting for key release. + stillActive := slices.DeleteFunc(slices.Clone(owner.active), func(session *tentacle.Session) bool { state, _ := session.Status(); return state != tentacle.Active }) + owner.runtime.publishCommands(stillActive) + owner.setSnapshot() + if owner.pending[id] { + owner.startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, owner.notify), previous: previous} + return + } + } + call.result <- err +} diff --git a/internal/app/tentacle_management.go b/internal/app/tentacle_management.go index b5f23cf..65dd2ae 100644 --- a/internal/app/tentacle_management.go +++ b/internal/app/tentacle_management.go @@ -7,10 +7,7 @@ import ( "context" "errors" "fmt" - "reflect" "slices" - "strings" - "time" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/tentacle" @@ -24,21 +21,6 @@ type tentacleManagementCall struct { packageFiles *tentacle.PreparedPackage } -// A startup request is owned by the lifecycle worker until activation or failure. -// Cancellation only wakes that worker; it never mutates registry or session state. -type tentacleStartupCall struct { - call tentacleManagementCall - saveError error - stop func() bool - previous *tentacle.Entry -} - -// A retired package stays on disk until the session using it has joined. -type tentacleRetiredPackage struct { - entry tentacle.Entry - session *tentacle.Session -} - func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot { if runtime == nil { return tentacle.Snapshot{Suppressed: true, Message: "Tentacle management is unavailable for this run."} @@ -133,67 +115,15 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man return tentacle.ErrDisabled } if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" { - if request.Entry != nil { - return errors.New("unexpected tentacle entry in installation request") + repository, err := repositoryForInstallation(runtime.Snapshot(), request) + if err != nil { + return err } - snapshot := runtime.Snapshot() - if snapshot.Suppressed || snapshot.Message != "" { - return tentacle.ErrUnavailable - } - index := slices.IndexFunc(snapshot.Repositories, func(value tentacle.Repository) bool { return value.URL == request.URL }) - if index < 0 { - return errors.New("repository is not configured") - } - repository := snapshot.Repositories[index] - id := tentacle.PackageID(repository, request.ID) - installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) - if request.Action == "upgrade" && !installed { - return errors.New("tentacle is not installed") - } - if request.Action != "upgrade" && installed { - return errors.New("tentacle is already installed") - } - if request.Action == "upgrade" { - for _, status := range snapshot.Entries { - if status.Entry.ID != id { - continue - } - for _, manifest := range repository.Tentacles { - if manifest.ID != request.ID { - continue - } - if manifest.Version == status.Entry.Version { - return errors.New("tentacle is already at the repository version") - } - newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version) - if err != nil { - return fmt.Errorf("cannot compare tentacle versions: %w", err) - } - if !newer { - return errors.New("repository tentacle version is not newer than installed version") - } - break - } - break - } - } - runtime.mutex.Lock() - if runtime.repositoryBusy { - runtime.mutex.Unlock() + if !runtime.beginRepositoryWork() { return tentacle.ErrBusy } - runtime.repositoryBusy = true - runtime.mutex.Unlock() - defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() - prepare := runtime.preparePackage - if prepare == nil { - prepare = tentacle.PreparePackage - } - prepareCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - stop := context.AfterFunc(runtime.lifecycleContext, cancel) - prepared, err := prepare(prepareCtx, tentacle.PackageRoot(runtime.registry.Path), repository, request.ID) - stop() - cancel() + defer runtime.endRepositoryWork() + prepared, err := runtime.prepareManagedPackage(ctx, repository, request.ID) if err != nil { return err } @@ -216,26 +146,12 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man if err != nil { return err } - runtime.mutex.Lock() - if runtime.repositoryBusy { - runtime.mutex.Unlock() + if !runtime.beginRepositoryWork() { return tentacle.ErrBusy } - runtime.repositoryBusy = true - runtime.mutex.Unlock() - defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() + defer runtime.endRepositoryWork() request.URL, call.request.URL = origin, origin - fetch := runtime.fetchRepository - if fetch == nil { - fetch = tentacle.FetchRepository - } - // Fetch on the Preferences worker, leaving lifecycle and native input - // free to disable, revoke, and process failures while the network waits. - fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - stop := context.AfterFunc(runtime.lifecycleContext, cancel) - repository, err := fetch(fetchCtx, origin) - stop() - cancel() + repository, err := runtime.fetchManagedRepository(ctx, origin) if err != nil { return err } @@ -298,471 +214,3 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme } return next, tentacle.ValidateEntries(next) } - -func (runtime *tentacleRuntime) run_managed(ctx context.Context) error { - entries, loadError := runtime.registry.Load() - repositories := runtime.registry.Repositories() - if loadError == nil { - if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil { - runtime.logger.Warn("recover tentacle packages", "error", err) - } - } - pending := make(map[string]bool) - sessions := make(map[string]*tentacle.Session) - problems := make(map[string]string) - startups := make(map[string]tentacleStartupCall) - removedPackages := make(map[string]tentacleRetiredPackage) - for _, entry := range entries { - pending[entry.ID] = entry.Enabled - } - wake := make(chan struct{}, 1) - notify := func() { - select { - case wake <- struct{}{}: - default: - } - } - var manager *tentacle.Manager - var current *tentacle.Session - var ready <-chan struct{} - var active []*tentacle.Session - var watchers []<-chan struct{} - setSnapshot := func() { - result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(repositories)} - if loadError != nil { - result.Message = "Tentacle registry could not be loaded; no tentacles were started." - } - for _, entry := range entries { - status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: problems[entry.ID]} - if entry.Enabled { - status.State = "starting" - } - if session := sessions[entry.ID]; session != nil { - state, _ := session.Status() - status.State = string(state) - if state == tentacle.Ready { - status.State = "starting" - } - if (state == tentacle.Failed || state == tentacle.Stopped) && entry.Enabled && status.Message == "" { - status.Message = "Tentacle stopped; retry to start it again." - } - } - if pending[entry.ID] { - status.State = "starting" - } - if status.Message != "" && !pending[entry.ID] { - status.State = "failed" - } - if !entry.Enabled && sessions[entry.ID] == nil { - status.State = "disabled" - } - result.Entries = append(result.Entries, status) - } - result.Updates = tentacle.AvailableUpdates(result) - runtime.mutex.Lock() - runtime.snapshot = result - runtime.mutex.Unlock() - } - setSnapshot() - if loadError != nil { - runtime.report("Tentacles could not start.") - } - defer func() { - runtime.Disable() - cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second) - defer cancel() - var bindingError error - if runtime.input != nil { - bindingError = runtime.input.SetTentacleBindings(cleanup, nil) - } - if runtime.readerContext.Err() != nil { - bindingError = nil - } - var managerError error - if manager != nil { - managerError = manager.Shutdown(cleanup) - } - for _, retired := range removedPackages { - entry := retired.entry - if managerError == nil { - if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil { - runtime.logger.Warn("remove tentacle package", "error", err) - } - } - } - for _, done := range watchers { - <-done - } - runtime.results.Wait() - runtime.stopError = errors.Join(bindingError, managerError) - if runtime.stopError != nil && runtime.readerContext.Err() == nil { - select { - case runtime.failures <- runtime.stopError: - default: - } - } - }() - ensureManager := func() error { - if manager != nil { - return nil - } - if runtime.input == nil { - return errors.New("native bindings are unavailable") - } - var err error - manager, err = runtime.makeManager() - return err - } - revoke := func(id string) { - if session := sessions[id]; session != nil { - session.Disable() - runtime.output.RetireRevoked() - } - } - replyStartup := func(id string, err error) { - startup, exists := startups[id] - if !exists { - return - } - delete(startups, id) - startup.stop() - if err == nil { - if startup.previous != nil { - previous := *startup.previous - // Starting the replacement already waited for the old session. - removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous} - } - err = startup.saveError - } - setSnapshot() - startup.call.result <- err - } - failStartup := func(id, message string) { - problems[id] = message - startup, exists := startups[id] - if !exists { - return - } - revoke(id) - if current != nil && current.TentacleID() == id { - current, ready = nil, nil - } - delete(pending, id) - next := tentacle.CloneEntries(entries) - for index := range next { - if next[index].ID == id { - if startup.previous != nil { - next[index] = *startup.previous - } else { - next[index].Enabled = false - } - } - } - err := runtime.registry.Save(next) - if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { - if startup.previous != nil { - failed := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) - if failed >= 0 { - entry := entries[failed] - removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]} - } - runtime.mutex.Lock() - disabled := runtime.disabled - runtime.mutex.Unlock() - pending[id] = startup.previous.Enabled && !disabled && ctx.Err() == nil - message += " The previous package was restored." - } - entries = next - } - if err != nil { - message += " The recovery state could not be saved durably: " + err.Error() - } - replyStartup(id, errors.New(message)) - } - defer func() { - for id := range startups { - failStartup(id, "Tentacle startup was cancelled before activation.") - } - }() - for { - if ctx.Err() != nil { - return nil - } - for id, startup := range startups { - if startup.call.ctx.Err() != nil { - failStartup(id, "Tentacle startup was cancelled before activation.") - } - } - // Reconcile revoked sessions before staging new native grabs. - retained := active[:0] - for _, session := range active { - if state, _ := session.Status(); state == tentacle.Active { - retained = append(retained, session) - } - } - changed := len(retained) != len(active) - active = retained - if changed { - runtime.publishCommands(active) - if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { - select { - case runtime.failures <- err: - default: - } - return err - } - } - // Joined sessions release host capacity before an explicit retry/update. - for id, session := range sessions { - select { - case <-session.Done(): - if state, failure := session.Status(); state == tentacle.Failed { - runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure) - } - if !pending[id] { - index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) - if index >= 0 && entries[index].Enabled && problems[id] == "" { - problems[id] = "Tentacle stopped; retry to start it again." - runtime.report("A tentacle stopped unexpectedly.") - } - } - delete(sessions, id) - runtime.mutex.Lock() - runtime.sessions = slices.DeleteFunc(runtime.sessions, func(value *tentacle.Session) bool { return value == session }) - runtime.mutex.Unlock() - default: - } - } - if current == nil { - for directory, retired := range removedPackages { - entry := retired.entry - if retired.session != nil { - select { - case <-retired.session.Done(): - default: - continue - } - } - if slices.ContainsFunc(entries, func(active tentacle.Entry) bool { - return active.PackageDirectory == directory - }) { - continue - } - if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil { - runtime.logger.Warn("remove tentacle package", "error", err) - runtime.report("Tentacle was removed, but its package files could not be deleted.") - continue - } - delete(removedPackages, directory) - } - for _, entry := range entries { - if !pending[entry.ID] || sessions[entry.ID] != nil { - continue - } - pending[entry.ID] = false - err := ensureManager() - var session *tentacle.Session - if err == nil { - session, err = manager.Start(ctx, entry.Spec()) - } - if err != nil { - failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.") - runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err) - runtime.report("A tentacle could not start.") - notify() - break - } - sessions[entry.ID] = session - runtime.mutex.Lock() - runtime.sessions = append(runtime.sessions, session) - if runtime.disabled { - session.Disable() - } - runtime.mutex.Unlock() - current, ready = session, session.Ready() - done := make(chan struct{}) - // Prune joined watcher handles so retries never accumulate history. - watchers = slices.DeleteFunc(watchers, func(done <-chan struct{}) bool { - select { - case <-done: - return true - default: - return false - } - }) - watchers = append(watchers, done) - go func() { - defer close(done) - select { - case <-session.Revoked(): - case <-ctx.Done(): - } - runtime.output.RetireRevoked() - runtime.signal_extensions() - notify() - <-session.Done() - notify() - }() - break - } - } - setSnapshot() - select { - case <-ctx.Done(): - return nil - case <-wake: - case call := <-runtime.requests: - if err := call.ctx.Err(); err != nil { - call.result <- err - continue - } - if loadError != nil { - call.result <- errors.New("tentacle registry could not be loaded; repair it and restart") - continue - } - runtime.mutex.Lock() - disabled := runtime.disabled - runtime.mutex.Unlock() - if disabled { - call.result <- tentacle.ErrDisabled - continue - } - if strings.HasPrefix(call.request.Action, "repository-") { - next, err := change_tentacle_repositories(repositories, call.request, call.repository) - if err == nil { - err = runtime.registry.SaveState(entries, next) - } - if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { - repositories = next - setSnapshot() - } - call.result <- err - continue - } - if call.packageFiles != nil { - index := slices.IndexFunc(repositories, func(value tentacle.Repository) bool { return value.URL == call.repository.URL }) - if index < 0 || !reflect.DeepEqual(repositories[index], *call.repository) { - call.result <- errors.New("Repository changed during installation; refresh and try again.") - continue - } - } - if _, starting := startups[call.request.ID]; starting && call.request.Action != "disable" && call.request.Action != "remove" { - call.result <- tentacle.ErrBusy - continue - } - if startup, exists := startups[call.request.ID]; exists && startup.previous != nil { - failStartup(call.request.ID, "Tentacle update was cancelled by another operation.") - } - next, err := change_tentacle_entries(entries, call.request) - if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, entries) { - // Saved enablement is intent, not proof of a live session. An - // explicit enable/update must recover a failed or starting entry. - index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == call.request.ID }) - state := tentacle.Stopped - if session := sessions[call.request.ID]; session != nil { - state, _ = session.Status() - } - if !next[index].Enabled || state == tentacle.Active { - call.result <- nil - continue - } - } - if err == nil && call.request.Action != "retry" { - err = runtime.registry.Save(next) - } - if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { - id := call.request.ID - var previous *tentacle.Entry - if call.packageFiles != nil && call.request.Action == "update" { - index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) - if index >= 0 && entries[index].Enabled { - saved := tentacle.CloneEntries(entries[index : index+1])[0] - previous = &saved - } - } - replyStartup(id, errors.New("Tentacle startup was cancelled by another operation.")) - if call.packageFiles != nil { - call.packageFiles.Retain() - } - if call.request.Action == "remove" || call.request.Action == "update" { - for _, entry := range entries { - if entry.ID != id || entry.PackageDirectory == "" { - continue - } - if call.request.Action == "update" && (previous != nil || - entry.PackageDirectory == call.request.Entry.PackageDirectory) { - continue - } - removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]} - } - } - revoke(id) - if current != nil && current.TentacleID() == id { - current, ready = nil, nil - } - entries = next - delete(problems, id) - delete(pending, id) - for _, entry := range entries { - if entry.ID == id { - pending[id] = entry.Enabled - } - } - // Immediately withdraw dispatch, without waiting for key release. - stillActive := slices.DeleteFunc(slices.Clone(active), func(session *tentacle.Session) bool { state, _ := session.Status(); return state != tentacle.Active }) - runtime.publishCommands(stillActive) - setSnapshot() - if pending[id] { - startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify), previous: previous} - continue - } - } - call.result <- err - case <-ready: - session := current - current, ready = nil, nil - if state, _ := session.Status(); state != tentacle.Ready { - failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.") - continue - } - if startup, exists := startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil { - failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.") - continue - } - candidate := append(slices.Clone(active), session) - if err := runtime.bind(ctx, candidate); err != nil { - session.Disable() - runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err) - runtime.report("A tentacle could not acquire its keyboard shortcuts.") - failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.") - continue - } - // Native binding setup can wait for held keys. Cancellation during - // that wait must roll back the new grabs before reporting failure. - activationError := ctx.Err() - if startup, exists := startups[session.TentacleID()]; exists && activationError == nil { - activationError = startup.call.ctx.Err() - } - if activationError == nil { - activationError = session.Activate() - } - if activationError != nil { - session.Disable() - if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { - select { - case runtime.failures <- err: - default: - } - return err - } - failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.") - continue - } - active = candidate - delete(problems, session.TentacleID()) - runtime.publishCommands(active) - replyStartup(session.TentacleID(), nil) - runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity()) - } - } -} diff --git a/internal/app/tentacle_preparation.go b/internal/app/tentacle_preparation.go new file mode 100644 index 0000000..2241990 --- /dev/null +++ b/internal/app/tentacle_preparation.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +// Preparation runs on the caller's worker. Its busy reservation remains owned +// by Manage through the lifecycle reply, including rollback after cancellation. +func (runtime *tentacleRuntime) beginRepositoryWork() bool { + runtime.mutex.Lock() + defer runtime.mutex.Unlock() + if runtime.repositoryBusy { + return false + } + runtime.repositoryBusy = true + return true +} + +func (runtime *tentacleRuntime) endRepositoryWork() { + runtime.mutex.Lock() + runtime.repositoryBusy = false + runtime.mutex.Unlock() +} + +func repositoryForInstallation(snapshot tentacle.Snapshot, request tentacle.ManagementRequest) (tentacle.Repository, error) { + if request.Entry != nil { + return tentacle.Repository{}, errors.New("unexpected tentacle entry in installation request") + } + if snapshot.Suppressed || snapshot.Message != "" { + return tentacle.Repository{}, tentacle.ErrUnavailable + } + index := slices.IndexFunc(snapshot.Repositories, func(value tentacle.Repository) bool { return value.URL == request.URL }) + if index < 0 { + return tentacle.Repository{}, errors.New("repository is not configured") + } + repository := snapshot.Repositories[index] + id := tentacle.PackageID(repository, request.ID) + installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) + if request.Action == "upgrade" && !installed { + return tentacle.Repository{}, errors.New("tentacle is not installed") + } + if request.Action != "upgrade" && installed { + return tentacle.Repository{}, errors.New("tentacle is already installed") + } + if request.Action == "upgrade" { + for _, status := range snapshot.Entries { + if status.Entry.ID != id { + continue + } + for _, manifest := range repository.Tentacles { + if manifest.ID != request.ID { + continue + } + if manifest.Version == status.Entry.Version { + return tentacle.Repository{}, errors.New("tentacle is already at the repository version") + } + newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version) + if err != nil { + return tentacle.Repository{}, fmt.Errorf("cannot compare tentacle versions: %w", err) + } + if !newer { + return tentacle.Repository{}, errors.New("repository tentacle version is not newer than installed version") + } + break + } + break + } + } + return repository, nil +} + +func (runtime *tentacleRuntime) prepareManagedPackage(ctx context.Context, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) { + prepare := runtime.preparePackage + if prepare == nil { + prepare = tentacle.PreparePackage + } + prepareCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + stop := context.AfterFunc(runtime.lifecycleContext, cancel) + prepared, err := prepare(prepareCtx, tentacle.PackageRoot(runtime.registry.Path), repository, id) + stop() + cancel() + return prepared, err +} + +func (runtime *tentacleRuntime) fetchManagedRepository(ctx context.Context, origin string) (tentacle.Repository, error) { + fetch := runtime.fetchRepository + if fetch == nil { + fetch = tentacle.FetchRepository + } + // Fetch on the Preferences worker, leaving lifecycle and native input + // free to disable, revoke, and process failures while the network waits. + fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + stop := context.AfterFunc(runtime.lifecycleContext, cancel) + repository, err := fetch(fetchCtx, origin) + stop() + cancel() + return repository, err +} diff --git a/internal/app/tentacle_startup.go b/internal/app/tentacle_startup.go new file mode 100644 index 0000000..aa70726 --- /dev/null +++ b/internal/app/tentacle_startup.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "errors" + "slices" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (owner *tentacleLifecycle) ensureManager() error { + if owner.manager != nil { + return nil + } + if owner.runtime.input == nil { + return errors.New("native bindings are unavailable") + } + var err error + owner.manager, err = owner.runtime.makeManager() + return err +} + +func (owner *tentacleLifecycle) revoke(id string) { + if session := owner.sessions[id]; session != nil { + session.Disable() + owner.runtime.output.RetireRevoked() + } +} + +func (owner *tentacleLifecycle) replyStartup(id string, err error) { + startup, exists := owner.startups[id] + if !exists { + return + } + delete(owner.startups, id) + startup.stop() + if err == nil { + if startup.previous != nil { + previous := *startup.previous + // Starting the replacement already waited for the old session. + owner.removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous} + } + err = startup.saveError + } + owner.setSnapshot() + startup.call.result <- err +} + +func (owner *tentacleLifecycle) failStartup(id, message string) { + owner.problems[id] = message + startup, exists := owner.startups[id] + if !exists { + return + } + owner.revoke(id) + if owner.current != nil && owner.current.TentacleID() == id { + owner.current, owner.ready = nil, nil + } + delete(owner.pending, id) + next := tentacle.CloneEntries(owner.entries) + for index := range next { + if next[index].ID == id { + if startup.previous != nil { + next[index] = *startup.previous + } else { + next[index].Enabled = false + } + } + } + err := owner.runtime.registry.Save(next) + if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { + if startup.previous != nil { + failed := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id }) + if failed >= 0 { + entry := owner.entries[failed] + owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]} + } + owner.runtime.mutex.Lock() + disabled := owner.runtime.disabled + owner.runtime.mutex.Unlock() + owner.pending[id] = startup.previous.Enabled && !disabled && owner.ctx.Err() == nil + message += " The previous package was restored." + } + owner.entries = next + } + if err != nil { + message += " The recovery state could not be saved durably: " + err.Error() + } + owner.replyStartup(id, errors.New(message)) +} + +func (owner *tentacleLifecycle) cancelStartups() { + for id := range owner.startups { + owner.failStartup(id, "Tentacle startup was cancelled before activation.") + } +} + +func (owner *tentacleLifecycle) startPending() { + for _, entry := range owner.entries { + if !owner.pending[entry.ID] || owner.sessions[entry.ID] != nil { + continue + } + owner.pending[entry.ID] = false + err := owner.ensureManager() + var session *tentacle.Session + if err == nil { + session, err = owner.manager.Start(owner.ctx, entry.Spec()) + } + if err != nil { + owner.failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.") + owner.runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err) + owner.runtime.report("A tentacle could not start.") + owner.notify() + break + } + owner.sessions[entry.ID] = session + owner.runtime.mutex.Lock() + owner.runtime.sessions = append(owner.runtime.sessions, session) + if owner.runtime.disabled { + session.Disable() + } + owner.runtime.mutex.Unlock() + owner.current, owner.ready = session, session.Ready() + done := make(chan struct{}) + // Prune joined watcher handles so retries never accumulate history. + owner.watchers = slices.DeleteFunc(owner.watchers, func(done <-chan struct{}) bool { + select { + case <-done: + return true + default: + return false + } + }) + owner.watchers = append(owner.watchers, done) + go func() { + defer close(done) + select { + case <-session.Revoked(): + case <-owner.ctx.Done(): + } + owner.runtime.output.RetireRevoked() + owner.runtime.signal_extensions() + owner.notify() + <-session.Done() + owner.notify() + }() + break + } +} + +func (owner *tentacleLifecycle) activateReady() error { + session := owner.current + owner.current, owner.ready = nil, nil + if state, _ := session.Status(); state != tentacle.Ready { + owner.failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.") + return nil + } + if startup, exists := owner.startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil { + owner.failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.") + return nil + } + candidate := append(slices.Clone(owner.active), session) + if err := owner.runtime.bind(owner.ctx, candidate); err != nil { + session.Disable() + owner.runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err) + owner.runtime.report("A tentacle could not acquire its keyboard shortcuts.") + owner.failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.") + return nil + } + // Native binding setup can wait for held keys. Cancellation during + // that wait must roll back the new grabs before reporting failure. + activationError := owner.ctx.Err() + if startup, exists := owner.startups[session.TentacleID()]; exists && activationError == nil { + activationError = startup.call.ctx.Err() + } + if activationError == nil { + activationError = session.Activate() + } + if activationError != nil { + session.Disable() + if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil { + select { + case owner.runtime.failures <- err: + default: + } + return err + } + owner.failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.") + return nil + } + owner.active = candidate + delete(owner.problems, session.TentacleID()) + owner.runtime.publishCommands(owner.active) + owner.replyStartup(session.TentacleID(), nil) + owner.runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity()) + return nil +} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index c46bcf8..57e811a 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -507,69 +507,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { return } snapshotStarted := time.Now() - target := returnedMenuItem - ownedTarget := false - if target == nil { - target = adapter.libreOfficeFocusTarget(event, eventType) - ownedTarget = target != nil - } - if target == nil { - target, ownedTarget = selectNativeEventTarget(event, eventType) - } + target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedMenuItem) if target == nil { return } - if (eventType == FocusEvent || eventType == FocusedStateEvent) && - C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM { - // Qt menus can change their checked state without a state-change - // event. Reopening the menu must capture the current provider state. - C.atspi_accessible_clear_cache_single(target) - } - if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) && - C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB { - // Firefox can emit selected-state events from the previous tab; - // query the current state/name before using either event path. - C.atspi_accessible_clear_cache_single(target) - } - if eventType == WindowDeactivatedEvent { - // Match Python's key-grab churn check against fresh window state rather - // than an AT-SPI cache entry from before the deactivation event. - C.atspi_accessible_clear_cache_single(event.source) - } - payload := C.cthulhu_event_payload(event) - snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live) - if completeLive { - text := C.cthulhu_live_content(event.source) - snapshot.Text = sanitizeSnapshotField(C.GoString(text)) - snapshot.LiveAtomic = true - C.g_free(C.gpointer(payload)) - payload = text - } - // Shared Qt actions can report focus through their original, hidden menu - // even while another menu emits focus-gained for that very accessible. - // The explicit event establishes focus; the queried state may disagree. - // Do not transfer this evidence to a redirected descendant or a dead item. - if snapshot.ID() == originID && !snapshot.Defunct && - (eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) { - switch snapshot.Role { - case "menu item", "check menu item", "radio menu item": - snapshot.Focused = true - } - } - var inlineMenuID ObjectID - if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct && - (snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" || - snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") { - if menu := C.cthulhu_inline_menu_container(target); menu != nil { - inlineMenuID = nativeObjectID(menu) - C.g_object_unref(C.gpointer(menu)) - if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName { - // The event's detail is authoritative even when libatspi still - // caches the previous SELECTED state on the first visit. - eventType, snapshot.Selected = ActiveDescendantEvent, true - } - } - } + refreshNativeEventTarget(event, target, eventType) + snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive) + eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot) // Chromium can drop ACTIVE from its visible main frame while a tiny // same-process sibling claims it. On X11, the correlated foreground // window is the stronger ownership check performed below. Keep the @@ -579,7 +523,6 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent && eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && eventType != AnnouncementEvent && !snapshot.AutocompletePopup && C.cthulhu_in_inactive_window(event.source) != 0 { - C.g_free(C.gpointer(payload)) if ownedTarget { C.g_object_unref(C.gpointer(target)) } @@ -593,7 +536,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { focusEvent := NewEventWithPayload( eventType, snapshot, - C.GoString(payload), + payload, ) if eventType != nativeEventType { focusEvent.NativeType = nativeEventType @@ -636,7 +579,6 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { if eventType == ActiveDescendantEvent && inlineMenuID.Valid() { focusEvent.OriginPath = inlineMenuID.Path } - C.g_free(C.gpointer(payload)) if ownedTarget { C.g_object_unref(C.gpointer(target)) } diff --git a/internal/atspi/adapter_event_target_native.go b/internal/atspi/adapter_event_target_native.go new file mode 100644 index 0000000..e1aefcb --- /dev/null +++ b/internal/atspi/adapter_event_target_native.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +*/ +import "C" + +// resolveNativeEventTarget runs on the GLib owner. The returned menu item is +// borrowed from the callback; LibreOffice and selected descendants can return +// owned references. The caller releases an owned target after event capture. +func (adapter *Adapter) resolveNativeEventTarget(event *C.AtspiEvent, eventType string, returnedMenuItem *C.AtspiAccessible) (*C.AtspiAccessible, bool) { + target := returnedMenuItem + ownedTarget := false + if target == nil { + target = adapter.libreOfficeFocusTarget(event, eventType) + ownedTarget = target != nil + } + if target == nil { + target, ownedTarget = selectNativeEventTarget(event, eventType) + } + return target, ownedTarget +} + +// Refresh only the established provider caches, in query order, before capture. +// Both accessibles are borrowed and stay on the GLib owner thread. +func refreshNativeEventTarget(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string) { + if (eventType == FocusEvent || eventType == FocusedStateEvent) && + C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM { + // Qt menus can change their checked state without a state-change + // event. Reopening the menu must capture the current provider state. + C.atspi_accessible_clear_cache_single(target) + } + if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) && + C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB { + // Firefox can emit selected-state events from the previous tab; + // query the current state/name before using either event path. + C.atspi_accessible_clear_cache_single(target) + } + if eventType == WindowDeactivatedEvent { + // Match Python's key-grab churn check against fresh window state rather + // than an AT-SPI cache entry from before the deactivation event. + C.atspi_accessible_clear_cache_single(event.source) + } +} + +// captureNativeEventContent borrows event/target and owns all payload buffers. +// A completed atomic live region replaces the event payload before copying it. +// No native references escape this helper; all calls remain on the GLib owner. +func (adapter *Adapter) captureNativeEventContent(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, live nativeLiveProperties, completeLive bool) (Snapshot, ObjectID, string) { + payload := C.cthulhu_event_payload(event) + defer func() { C.g_free(C.gpointer(payload)) }() + snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live) + if completeLive { + text := C.cthulhu_live_content(event.source) + snapshot.Text = sanitizeSnapshotField(C.GoString(text)) + snapshot.LiveAtomic = true + C.g_free(C.gpointer(payload)) + payload = text + } + return snapshot, popupFor, C.GoString(payload) +} + +// normalizeNativeMenuFocus applies provider evidence to this event's snapshot. +// It borrows target and releases the temporary inline-menu reference locally. +func normalizeNativeMenuFocus(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, snapshot *Snapshot) (string, ObjectID) { + // Shared Qt actions can report focus through their original, hidden menu + // even while another menu emits focus-gained for that very accessible. + // The explicit event establishes focus; the queried state may disagree. + // Do not transfer this evidence to a redirected descendant or a dead item. + if snapshot.ID() == originID && !snapshot.Defunct && + (eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) { + switch snapshot.Role { + case "menu item", "check menu item", "radio menu item": + snapshot.Focused = true + } + } + var inlineMenuID ObjectID + if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct && + (snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" || + snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") { + if menu := C.cthulhu_inline_menu_container(target); menu != nil { + inlineMenuID = nativeObjectID(menu) + C.g_object_unref(C.gpointer(menu)) + if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName { + // The event's detail is authoritative even when libatspi still + // caches the previous SELECTED state on the first visit. + eventType, snapshot.Selected = ActiveDescendantEvent, true + } + } + } + return eventType, inlineMenuID +} diff --git a/internal/preferences/native.h b/internal/preferences/native.h index 574508e..a3e804a 100644 --- a/internal/preferences/native.h +++ b/internal/preferences/native.h @@ -2,6 +2,22 @@ // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later #include +// Shared action IDs for the native callbacks and Go preferences dispatcher. +// Value 10 is reserved; preserve the existing wire values. +enum { + PREFS_TENTACLE_REPOSITORY_ADD = 9, + PREFS_TENTACLE_TOGGLE = 11, + PREFS_TENTACLE_REPOSITORY_REMOVE = 12, + PREFS_TENTACLE_RETRY = 13, + PREFS_TENTACLE_REMOVE = 14, + PREFS_TENTACLE_REFRESH = 15, + PREFS_TENTACLE_SELECT = 16, + PREFS_TENTACLE_REPOSITORY_SELECT = 17, + PREFS_TENTACLE_REPOSITORY_REFRESH = 18, + PREFS_TENTACLE_INSTALL = 19, + PREFS_TENTACLE_SETTINGS = 20, + PREFS_TENTACLE_UPDATE = 21, +}; int prefs_init(const char *xml); void prefs_set_application(const char *name); void prefs_iterate(void); diff --git a/internal/preferences/native_tentacles.c b/internal/preferences/native_tentacles.c index 129ee2d..e084047 100644 --- a/internal/preferences/native_tentacles.c +++ b/internal/preferences/native_tentacles.c @@ -22,7 +22,7 @@ static void name_toggle_cells(void) { } static void tentacle_selected(GtkTreeSelection *selection, gpointer data) { (void)selection; (void)data; - if (!updating) prefs_set_action(16); + if (!updating) prefs_set_action(PREFS_TENTACLE_SELECT); } static void tentacle_clicked(GtkButton *button, gpointer data) { (void)button; @@ -36,12 +36,12 @@ static void tentacle_toggled(GtkCellRendererToggle *renderer, gchar *pathString, gtk_tree_path_free(path); // The accepted backend snapshot owns the check state; never check // optimistically before persistence or after an operation has failed. - prefs_set_action(11); + prefs_set_action(PREFS_TENTACLE_TOGGLE); } static gboolean tentacle_key(GtkWidget *widget, GdkEventKey *event, gpointer data) { (void)widget; (void)data; if (event->keyval != GDK_KEY_space && event->keyval != GDK_KEY_KP_Space) return FALSE; - prefs_set_action(11); + prefs_set_action(PREFS_TENTACLE_TOGGLE); return TRUE; } static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint index, gpointer data) { @@ -54,7 +54,7 @@ static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint in gtk_container_add(GTK_CONTAINER(page), scroll); g_object_unref(scroll); } - if (!updating) prefs_set_action(17); + if (!updating) prefs_set_action(PREFS_TENTACLE_REPOSITORY_SELECT); } void prefs_init_tentacles(void) { updating = FALSE; @@ -74,9 +74,15 @@ void prefs_init_tentacles(void) { g_signal_connect(tree, "key-press-event", G_CALLBACK(tentacle_key), NULL); g_signal_connect(prefs_widget("tentacleRepositories"), "switch-page", G_CALLBACK(repository_switched), NULL); const struct { const char *id; int action; } buttons[] = { - {"tentacleNew", 9}, {"tentacleRetry", 13}, {"tentacleRemove", 14}, - {"tentacleRefresh", 15}, {"tentacleRepositoryRemove", 12}, {"tentacleRepositoryRefresh", 18}, - {"tentacleInstall", 19}, {"tentacleUpdate", 21}, {"tentacleSettings", 20}, + {"tentacleNew", PREFS_TENTACLE_REPOSITORY_ADD}, + {"tentacleRetry", PREFS_TENTACLE_RETRY}, + {"tentacleRemove", PREFS_TENTACLE_REMOVE}, + {"tentacleRefresh", PREFS_TENTACLE_REFRESH}, + {"tentacleRepositoryRemove", PREFS_TENTACLE_REPOSITORY_REMOVE}, + {"tentacleRepositoryRefresh", PREFS_TENTACLE_REPOSITORY_REFRESH}, + {"tentacleInstall", PREFS_TENTACLE_INSTALL}, + {"tentacleUpdate", PREFS_TENTACLE_UPDATE}, + {"tentacleSettings", PREFS_TENTACLE_SETTINGS}, }; for (unsigned i = 0; i < G_N_ELEMENTS(buttons); i++) g_signal_connect(prefs_widget(buttons[i].id), "clicked", G_CALLBACK(tentacle_clicked), GINT_TO_POINTER(buttons[i].action)); diff --git a/internal/preferences/tentacle_actions.go b/internal/preferences/tentacle_actions.go new file mode 100644 index 0000000..bcfedcc --- /dev/null +++ b/internal/preferences/tentacle_actions.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +/* +#include +#include "native.h" +*/ +import "C" + +import ( + "errors" + "unsafe" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error) { + if action == C.PREFS_TENTACLE_SELECT { + if selected := page.selected(); selected != nil { + page.selectedKey = selected.key + } + page.controls() + return nil, nil + } + if action == C.PREFS_TENTACLE_REPOSITORY_SELECT { + page.render() + return nil, nil + } + if action == C.PREFS_TENTACLE_REFRESH { + return &tentacle.ManagementRequest{Action: "list"}, nil + } + if page.snapshot == nil || page.snapshot.Suppressed || page.snapshot.Message != "" { + message := "Tentacle management is unavailable for this run." + if page.snapshot != nil && page.snapshot.Message != "" { + message = page.snapshot.Message + } + return nil, errors.New(message) + } + if action == C.PREFS_TENTACLE_REPOSITORY_ADD { + id := C.CString("tentacleRepositoryURL") + origin, err := tentacle.RepositoryURL(C.GoString(C.prefs_entry_get(id))) + C.free(unsafe.Pointer(id)) + if err != nil { + return nil, err + } + return &tentacle.ManagementRequest{Action: "repository-add", URL: origin}, nil + } + if action == C.PREFS_TENTACLE_REPOSITORY_REMOVE || action == C.PREFS_TENTACLE_REPOSITORY_REFRESH { + origin := page.origin() + if origin == "" { + return nil, errors.New("Select a repository tab first.") + } + operation := "repository-remove" + if action == C.PREFS_TENTACLE_REPOSITORY_REFRESH { + operation = "repository-refresh" + } + return &tentacle.ManagementRequest{Action: operation, URL: origin}, nil + } + selected := page.selected() + if selected == nil { + return nil, errors.New("Select a tentacle first.") + } + page.selectedKey = selected.key + if action == C.PREFS_TENTACLE_UPDATE { + return page.updatePackage(selected) + } + if !selected.installed { + return page.installPackage(selected, action) + } + operation := "" + switch action { + case C.PREFS_TENTACLE_TOGGLE: + operation = "enable" + if selected.status.Entry.Enabled { + operation = "disable" + } + case C.PREFS_TENTACLE_RETRY: + operation = "retry" + case C.PREFS_TENTACLE_REMOVE: + operation = "remove" + case C.PREFS_TENTACLE_SETTINGS: + operation = "settings" + default: + return nil, errors.New("Unknown tentacle action.") + } + return &tentacle.ManagementRequest{Action: operation, ID: selected.status.Entry.ID}, nil +} + +func (page *tentaclePage) updatePackage(selected *tentacleRow) (*tentacle.ManagementRequest, error) { + if !selected.installed { + return nil, errors.New("Install this tentacle first.") + } + entry := selected.status.Entry + for _, update := range page.snapshot.Updates { + if update.ID != entry.ID { + continue + } + manifest := findTentacleManifest(page.snapshot, update.RepositoryURL, update.RepositoryID, update.LocalID) + if manifest == nil { + return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.") + } + message := tentacleUpdateConfirmation(entry, update, *manifest) + if !confirmTentaclePackage(message, "Update") { + return nil, nil + } + return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil + } + return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.") +} + +func (page *tentaclePage) installPackage(selected *tentacleRow, action int) (*tentacle.ManagementRequest, error) { + if action != C.PREFS_TENTACLE_TOGGLE && action != C.PREFS_TENTACLE_INSTALL { + return nil, errors.New("Install this tentacle first.") + } + entry := selected.status.Entry + manifest := findTentacleManifest(page.snapshot, entry.Repository, entry.RepositoryID, entry.LocalID) + if manifest == nil || len(manifest.Releases) == 0 { + return nil, errors.New("No package has been published for this tentacle. Refresh the repository after a release is published.") + } + operation, label := "install", "Install" + if action == C.PREFS_TENTACLE_TOGGLE { + operation, label = "install-enable", "Install and enable" + } + message := tentacleInstallConfirmation(entry, *manifest, label) + if !confirmTentaclePackage(message, label) { + return nil, nil + } + return &tentacle.ManagementRequest{Action: operation, ID: entry.LocalID, URL: entry.Repository}, nil +} + +func confirmTentaclePackage(message, label string) bool { + text, button := C.CString(message), C.CString(label) + defer C.free(unsafe.Pointer(text)) + defer C.free(unsafe.Pointer(button)) + return C.prefs_tentacle_confirm(text, button) != 0 +} + +// Snapshot repositories have unique identities and local package IDs. +func findTentacleManifest(snapshot *tentacle.Snapshot, origin, repositoryID, localID string) *tentacle.Manifest { + for _, repository := range snapshot.Repositories { + if repository.URL != origin || repository.ID != repositoryID { + continue + } + for index := range repository.Tentacles { + if repository.Tentacles[index].ID == localID { + return &repository.Tentacles[index] + } + } + } + return nil +} diff --git a/internal/preferences/tentacle_confirmation.go b/internal/preferences/tentacle_confirmation.go new file mode 100644 index 0000000..b3692fe --- /dev/null +++ b/internal/preferences/tentacle_confirmation.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "fmt" + "slices" + "strings" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func tentacleUpdateCapabilities(entry tentacle.Entry, manifest tentacle.Manifest) string { + var previous []string + if entry.Presentation { + previous = append(previous, "presentation") + } + if entry.Interaction { + previous = append(previous, "interaction") + } + if entry.ReaderSettings { + previous = append(previous, "reader-settings") + } + if len(previous) == 0 { + previous = append(previous, "none") + } + declared := manifest.Capabilities + if len(declared) == 0 { + declared = []string{"none"} + } + return "Current capabilities: " + strings.Join(previous, ", ") + + ". Repository declares: " + strings.Join(declared, ", ") + "." +} + +func tentacleInstallConfirmation(entry tentacle.Entry, manifest tentacle.Manifest, label string) string { + permissions := "This tentacle can register reader commands." + if slices.Contains(manifest.Capabilities, "presentation") { + permissions = "This tentacle can register reader commands and send speech and braille messages." + } + 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." + } + return fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions) +} + +func tentacleUpdateConfirmation(entry tentacle.Entry, update tentacle.AvailableUpdate, manifest tentacle.Manifest) string { + permissions := tentacleUpdateCapabilities(entry, manifest) + return fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions) +} diff --git a/internal/preferences/tentacle_confirmation_test.go b/internal/preferences/tentacle_confirmation_test.go new file mode 100644 index 0000000..e4e4e62 --- /dev/null +++ b/internal/preferences/tentacle_confirmation_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package preferences + +import ( + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/tentacle" +) + +func TestTentacleUpdateConfirmationDescribesPermissionChanges(t *testing.T) { + entry := tentacle.Entry{Presentation: true} + update := tentacle.AvailableUpdate{Name: "Example", InstalledVersion: "1.0", AvailableVersion: "1.1"} + manifest := tentacle.Manifest{Capabilities: []string{"presentation", "interaction", "reader-settings"}} + message := tentacleUpdateConfirmation(entry, update, manifest) + for _, want := range []string{ + "Update Example from version 1.0 to version 1.1?", + "Current capabilities: presentation.", + "Repository declares: presentation, interaction, reader-settings.", + "Changes take effect immediately.", + } { + if !strings.Contains(message, want) { + t.Fatalf("confirmation missing %q: %s", want, message) + } + } + message = tentacleUpdateConfirmation(tentacle.Entry{}, update, tentacle.Manifest{}) + if !strings.Contains(message, "Current capabilities: none. Repository declares: none.") { + t.Fatalf("empty capability sets were not described: %s", message) + } +} + +func TestTentacleInstallConfirmationDescribesInteractionAndSettings(t *testing.T) { + entry := tentacle.Entry{Name: "Example", Version: "1.1", Repository: "https://example.org/tentacles"} + manifest := tentacle.Manifest{Capabilities: []string{"presentation", "interaction", "reader-settings"}} + message := tentacleInstallConfirmation(entry, manifest, "Install and enable") + for _, want := range []string{ + "Install and enable Example version 1.1 from https://example.org/tentacles?", + "read the focused item and clipboard when invoked", + "temporarily change the reader voice configuration", + "resource limits do not protect your files or network", + } { + if !strings.Contains(message, want) { + t.Fatalf("confirmation missing %q: %s", want, message) + } + } + message = tentacleInstallConfirmation(entry, tentacle.Manifest{}, "Install") + if !strings.Contains(message, "This tentacle can register reader commands.") || strings.Contains(message, "write clipboard text") { + t.Fatalf("command-only installation described incorrectly: %s", message) + } +} diff --git a/internal/preferences/tentacles.go b/internal/preferences/tentacles.go index 8832113..30a4a1a 100644 --- a/internal/preferences/tentacles.go +++ b/internal/preferences/tentacles.go @@ -10,10 +10,7 @@ package preferences import "C" import ( - "errors" - "fmt" "reflect" - "slices" "strings" "unsafe" @@ -165,171 +162,6 @@ func (page *tentaclePage) populate(snapshot *tentacle.Snapshot) { page.render() } -func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error) { - if action == 16 { - if selected := page.selected(); selected != nil { - page.selectedKey = selected.key - } - page.controls() - return nil, nil - } - if action == 17 { - page.render() - return nil, nil - } - if action == 15 { - return &tentacle.ManagementRequest{Action: "list"}, nil - } - if page.snapshot == nil || page.snapshot.Suppressed || page.snapshot.Message != "" { - message := "Tentacle management is unavailable for this run." - if page.snapshot != nil && page.snapshot.Message != "" { - message = page.snapshot.Message - } - return nil, errors.New(message) - } - if action == 9 { - id := C.CString("tentacleRepositoryURL") - origin, err := tentacle.RepositoryURL(C.GoString(C.prefs_entry_get(id))) - C.free(unsafe.Pointer(id)) - if err != nil { - return nil, err - } - return &tentacle.ManagementRequest{Action: "repository-add", URL: origin}, nil - } - if action == 12 || action == 18 { - origin := page.origin() - if origin == "" { - return nil, errors.New("Select a repository tab first.") - } - operation := "repository-remove" - if action == 18 { - operation = "repository-refresh" - } - return &tentacle.ManagementRequest{Action: operation, URL: origin}, nil - } - selected := page.selected() - if selected == nil { - return nil, errors.New("Select a tentacle first.") - } - page.selectedKey = selected.key - if action == 21 { - if !selected.installed { - return nil, errors.New("Install this tentacle first.") - } - entry := selected.status.Entry - for _, update := range page.snapshot.Updates { - if update.ID != entry.ID { - continue - } - permissions := "" - foundManifest := false - for _, repository := range page.snapshot.Repositories { - if repository.URL != update.RepositoryURL || repository.ID != update.RepositoryID { - continue - } - for _, manifest := range repository.Tentacles { - if manifest.ID != update.LocalID { - continue - } - foundManifest = true - var previous []string - if entry.Presentation { - previous = append(previous, "presentation") - } - if entry.Interaction { - previous = append(previous, "interaction") - } - if entry.ReaderSettings { - previous = append(previous, "reader-settings") - } - if len(previous) == 0 { - previous = append(previous, "none") - } - declared := manifest.Capabilities - if len(declared) == 0 { - declared = []string{"none"} - } - permissions = "Current capabilities: " + strings.Join(previous, ", ") + - ". Repository declares: " + strings.Join(declared, ", ") + "." - } - } - if !foundManifest { - return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.") - } - message := fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions) - text, button := C.CString(message), C.CString("Update") - accepted := C.prefs_tentacle_confirm(text, button) - C.free(unsafe.Pointer(text)) - C.free(unsafe.Pointer(button)) - if accepted == 0 { - return nil, nil - } - return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil - } - return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.") - } - if !selected.installed { - if action != 11 && action != 19 { - return nil, errors.New("Install this tentacle first.") - } - entry := selected.status.Entry - var manifest *tentacle.Manifest - for _, repository := range page.snapshot.Repositories { - if repository.URL == entry.Repository && repository.ID == entry.RepositoryID { - for index := range repository.Tentacles { - if repository.Tentacles[index].ID == entry.LocalID { - manifest = &repository.Tentacles[index] - break - } - } - } - } - if manifest == nil || len(manifest.Releases) == 0 { - return nil, errors.New("No package has been published for this tentacle. Refresh the repository after a release is published.") - } - permissions := "This tentacle can register reader commands." - if slices.Contains(manifest.Capabilities, "presentation") { - permissions = "This tentacle can register reader commands and send speech and braille messages." - } - 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" - } - message := fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions) - text, button := C.CString(message), C.CString(label) - accepted := C.prefs_tentacle_confirm(text, button) - C.free(unsafe.Pointer(text)) - C.free(unsafe.Pointer(button)) - if accepted == 0 { - return nil, nil - } - return &tentacle.ManagementRequest{Action: operation, ID: entry.LocalID, URL: entry.Repository}, nil - } - operation := "" - switch action { - case 11: - operation = "enable" - if selected.status.Entry.Enabled { - operation = "disable" - } - case 13: - operation = "retry" - case 14: - operation = "remove" - case 20: - operation = "settings" - default: - return nil, errors.New("Unknown tentacle action.") - } - return &tentacle.ManagementRequest{Action: operation, ID: selected.status.Entry.ID}, nil -} - func (page *tentaclePage) accepted(request *tentacle.ManagementRequest) { if request.Action == "repository-add" { id, empty := C.CString("tentacleRepositoryURL"), C.CString("") diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go index 2c452d3..afd4bed 100644 --- a/internal/preferences/ui.go +++ b/internal/preferences/ui.go @@ -145,7 +145,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error { session.refreshSpeechVoices() session.refreshKeyBindingLayout() action := C.prefs_action() - if action >= 9 && action <= 21 && !busy { + if action >= C.PREFS_TENTACLE_REPOSITORY_ADD && action <= C.PREFS_TENTACLE_UPDATE && !busy { request, err := tentacles.action(int(action)) if err != nil { status(err.Error(), true) diff --git a/internal/wine/helper_staging.go b/internal/wine/helper_staging.go new file mode 100644 index 0000000..0b6d2d0 --- /dev/null +++ b/internal/wine/helper_staging.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package wine + +import ( + "bytes" + "crypto/sha256" + "fmt" + "os" + "path/filepath" +) + +// A helperSet is a launch-local snapshot of the complete installed helper. +// Publishing and verification use the same bytes and modes that form its digest. +type helperSet struct { + files []helperFile + digest string +} + +type helperFile struct { + name string + data []byte + mode os.FileMode +} + +// A pressure-vessel wineserver cannot see the host's /usr. Publish complete, +// immutable helper sets in the home-mounted state directory. Never overwrite +// an older set: running processes may still have its sidecar mapped. +func stage_helper(helper string, environment map[string]string) (string, error) { + if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present { + return helper, nil + } + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + stateHome = filepath.Join(home, ".local", "state") + } + if !filepath.IsAbs(stateHome) { + return "", fmt.Errorf("Wine helper state directory must be absolute") + } + set, err := readHelperSet(helper) + if err != nil { + return "", err + } + stage, err := set.publish(filepath.Join(stateHome, "cthulhu", "wine-access", "helper")) + if err != nil { + return "", err + } + return filepath.Join(stage, filepath.Base(helper)), nil +} + +func readHelperSet(helper string) (*helperSet, error) { + names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"} + set := &helperSet{} + digest := sha256.New() + for _, name := range names { + path := filepath.Join(filepath.Dir(helper), name) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read helper set: %w", err) + } + info, err := os.Stat(path) + if err != nil { + return nil, err + } + file := helperFile{name: name, data: data, mode: 0600} + if info.Mode().Perm()&0111 != 0 { + file.mode = 0700 + } + fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), file.mode) + digest.Write(data) + set.files = append(set.files, file) + } + set.digest = fmt.Sprintf("%x", digest.Sum(nil)) + return set, nil +} + +func (set *helperSet) publish(root string) (string, error) { + if err := os.MkdirAll(root, 0700); err != nil { + return "", err + } + stage := filepath.Join(root, set.digest) + if _, err := os.Stat(stage); os.IsNotExist(err) { + temporary, err := os.MkdirTemp(root, ".stage-") + if err != nil { + return "", err + } + defer os.RemoveAll(temporary) + for _, file := range set.files { + if err := os.WriteFile(filepath.Join(temporary, file.name), file.data, file.mode); err != nil { + return "", err + } + } + if err := os.Rename(temporary, stage); err != nil { + if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() { + return "", err + } + } + } + if err := set.verify(stage); err != nil { + return "", err + } + return stage, nil +} + +func (set *helperSet) verify(stage string) error { + for _, file := range set.files { + path := filepath.Join(stage, file.name) + data, err := os.ReadFile(path) + if err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !bytes.Equal(data, file.data) || info.Mode().Perm() != file.mode { + return fmt.Errorf("incomplete or modified staged Wine helper") + } + } + return nil +} diff --git a/internal/wine/proton.go b/internal/wine/proton.go index e236aad..51390d2 100644 --- a/internal/wine/proton.go +++ b/internal/wine/proton.go @@ -5,8 +5,6 @@ package wine import ( "bytes" - "crypto/sha256" - "fmt" "os" "path/filepath" "sort" @@ -83,81 +81,3 @@ func (manager *Manager) helper_arguments(controllerOnly bool) []string { } return arguments } - -// A pressure-vessel wineserver cannot see the host's /usr. Publish complete, -// immutable helper sets in the home-mounted state directory. Never overwrite -// an older set: running processes may still have its sidecar mapped. -func stage_helper(helper string, environment map[string]string) (string, error) { - if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present { - return helper, nil - } - stateHome := os.Getenv("XDG_STATE_HOME") - if stateHome == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - stateHome = filepath.Join(home, ".local", "state") - } - if !filepath.IsAbs(stateHome) { - return "", fmt.Errorf("Wine helper state directory must be absolute") - } - names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"} - contents := make(map[string][]byte) - modes := make(map[string]os.FileMode) - digest := sha256.New() - for _, name := range names { - path := filepath.Join(filepath.Dir(helper), name) - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read helper set: %w", err) - } - info, err := os.Stat(path) - if err != nil { - return "", err - } - contents[name], modes[name] = data, 0600 - if info.Mode().Perm()&0111 != 0 { - modes[name] = 0700 - } - fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), modes[name]) - digest.Write(data) - } - root := filepath.Join(stateHome, "cthulhu", "wine-access", "helper") - if err := os.MkdirAll(root, 0700); err != nil { - return "", err - } - stage := filepath.Join(root, fmt.Sprintf("%x", digest.Sum(nil))) - if _, err := os.Stat(stage); os.IsNotExist(err) { - temporary, err := os.MkdirTemp(root, ".stage-") - if err != nil { - return "", err - } - defer os.RemoveAll(temporary) - for _, name := range names { - if err := os.WriteFile(filepath.Join(temporary, name), contents[name], modes[name]); err != nil { - return "", err - } - } - if err := os.Rename(temporary, stage); err != nil { - if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() { - return "", err - } - } - } - for _, name := range names { - path := filepath.Join(stage, name) - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - info, err := os.Stat(path) - if err != nil { - return "", err - } - if !bytes.Equal(data, contents[name]) || info.Mode().Perm() != modes[name] { - return "", fmt.Errorf("incomplete or modified staged Wine helper") - } - } - return filepath.Join(stage, filepath.Base(helper)), nil -} diff --git a/tests/native_tentacles.py b/tests/native_tentacles.py new file mode 100644 index 0000000..af5b66d --- /dev/null +++ b/tests/native_tentacles.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tentacle management action routing with fixture responses, no downloads.""" +import select + +from native_x11 import wait_for + + +def check_page(pyatspi, process, window, nodes, named, walk, focus, key, + request, send, initial, application): + tab = next(node for node in nodes + if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_PAGE_TAB) + assert tab.parent.querySelection().selectChild(tab.getIndexInParent()) + origin = "https://example.org/tentacles" + repository = {"URL": origin, "ID": "example.source", "Name": "Example", + "Tentacles": [{"ID": "echo", "Name": "Example package", "Version": "1.0", + "Capabilities": ["presentation"], "Releases": [{}]}]} + snapshot = {"Entries": [], "Repositories": [repository]} + table = next(node for node in walk(window) + if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE) + + def press(name): + button = named(name) + wait_for(lambda: button.getState().contains(pyatspi.STATE_SENSITIVE), name + " disabled") + focus(button) + key("space") + + status_node = named(initial["Message"]) + + def accept(action, package_id="", url=""): + value = request()["Tentacle"] + assert value == {"Action": action, "ID": package_id, "URL": url, "Entry": None}, value + message = "Accepted " + action + if action == "settings": + # Successful settings replies intentionally leave the status alone. + # Observe a changed snapshot cell as the response barrier instead. + snapshot["Entries"][0]["Message"] = message + send(dict(initial, Tentacles=snapshot, Message=message)) + if action == "settings": + wait_for(lambda: any(node.name == message for node in walk(table)), + "settings response was not rendered") + else: + wait_for(lambda: status_node.name == message, action + " response missing") + wait_for(lambda: named("Refresh status").getState().contains(pyatspi.STATE_SENSITIVE), + action + " left preferences busy") + + url_entry = next(node for node in walk(window) + if node.name == "Repository URL" and + node.getState().contains(pyatspi.STATE_EDITABLE)) + url_entry.queryEditableText().setTextContents(origin) + press("Add repository") + accept("repository-add", url=origin) + wait_for(lambda: table.queryTable().nRows == 1, "catalog row missing") + focus(table) + key("Home") + + def confirmation(): + for app in pyatspi.Registry.getDesktop(0): + for node in app: + if node and node.getRole() in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT): + if any("Only install packages" in child.name for child in walk(node)): + return node + return None + + def answer(label): + dialog = wait_for(confirmation, "install confirmation missing") + message = " ".join(node.name for node in walk(dialog)) + assert "Example package version 1.0" in message, message + assert "send speech and braille messages" in message, message + button = next(node for node in walk(dialog) + if node.name == label and node.getRole() == pyatspi.ROLE_PUSH_BUTTON) + focus(button) + key("space") + wait_for(lambda: not confirmation(), "confirmation did not close") + + press("Install") + answer("Cancel") + assert not select.select([process.stdout], [], [], 0.2)[0], "Cancel submitted installation" + press("Install") + answer("Install") + entry = {"ID": "installed-echo", "LocalID": "echo", "Name": "Example package", + "Version": "1.0", "Repository": origin, "RepositoryID": "example.source", + "Presentation": True, "Enabled": False} + status = {"Entry": entry, "State": "disabled"} + snapshot["Entries"] = [status] + accept("install", "echo", origin) + focus(table) + key("Home", "space") + entry["Enabled"], status["State"] = True, "active" + accept("enable", entry["ID"]) + press("Retry") + accept("retry", entry["ID"]) + press("Settings") + accept("settings", entry["ID"]) + focus(table) + key("space") + entry["Enabled"], status["State"] = False, "disabled" + accept("disable", entry["ID"]) + press("Remove tentacle") + snapshot["Entries"] = [] + accept("remove", entry["ID"]) + press("Refresh status") + accept("list") + repositories = named("Tentacle repositories") + assert repositories.querySelection().selectChild(1), "repository tab missing" + press("Refresh repository") + accept("repository-refresh", url=origin) + press("Remove repository") + snapshot["Repositories"] = [] + accept("repository-remove", url=origin) + wait_for(lambda: table.queryTable().nRows == 0, "removed repository left rows") + press("Cancel") + assert process.wait(timeout=5) == 0 + print("PASS: repository actions, install confirmation/Cancel, enable/disable, retry, settings, remove, refresh")