Added tentacle updater.

This commit is contained in:
Kendell Clark authored and Storm Dragon committed 2026-11-01 12:21:55 -05:00
1 parent f2cc47c8a0
commit 7591f65b58
17 files changed
+948 -22

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ 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.
- [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).
+1 -1
View File
@@ -34,7 +34,7 @@ func (worker *preferencesWorker) manage_tentacle(ctx context.Context, request pr
return response
}
timeout := 6 * time.Second
if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") {
if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") || request.Tentacle.Action == "upgrade" {
timeout = 50 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
+6 -1
View File
@@ -43,7 +43,12 @@ func install_fixture(t *testing.T) (tentacle.Repository, []byte) {
marker := filepath.Join(t.TempDir(), "called")
manifest := fmt.Sprintf("format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo','-test.run=^TestTentacleReaderPeer$','--','reader-peer',%q]\napis=['commands']\ncapabilities=[]\n[protocol]\nmajor=1\nminor=0\n", marker)
var data bytes.Buffer
compressed := gzip.NewWriter(&data)
// These fixtures test package installation, not compression ratios. This
// also keeps repeated update fixtures cheap under the race detector.
compressed, err := gzip.NewWriterLevel(&data, gzip.BestSpeed)
if err != nil {
t.Fatal(err)
}
archive := tar.NewWriter(compressed)
for _, item := range []struct {
name string
+111 -16
View File
@@ -30,6 +30,13 @@ 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 {
@@ -43,6 +50,7 @@ func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot {
defer runtime.mutex.Unlock()
result := runtime.snapshot
result.Repositories = tentacle.CloneRepositories(result.Repositories)
result.Updates = slices.Clone(result.Updates)
result.Entries = append([]tentacle.EntryStatus(nil), result.Entries...)
var bindings []input.BindingOverride
if runtime.input != nil && !runtime.disabled {
@@ -124,7 +132,7 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
if disabled {
return tentacle.ErrDisabled
}
if request.Action == "install" || request.Action == "install-enable" {
if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" {
if request.Entry != nil {
return errors.New("unexpected tentacle entry in installation request")
}
@@ -138,9 +146,37 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
}
repository := snapshot.Repositories[index]
id := tentacle.PackageID(repository, request.ID)
if slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == 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()
@@ -162,9 +198,15 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
return err
}
defer prepared.Discard()
prepared.Entry.Enabled = request.Action == "install-enable"
operation := "add"
if request.Action == "upgrade" {
operation = "update"
// The lifecycle owner preserves enablement at commit time.
} else {
prepared.Entry.Enabled = request.Action == "install-enable"
}
call.packageFiles, call.repository = prepared, &repository
call.request = tentacle.ManagementRequest{Action: "add", ID: prepared.Entry.ID, Entry: &prepared.Entry}
call.request = tentacle.ManagementRequest{Action: operation, ID: prepared.Entry.ID, Entry: &prepared.Entry}
}
if request.Action == "repository-add" || request.Action == "repository-refresh" {
if request.Entry != nil || request.ID != "" {
@@ -240,6 +282,7 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme
next = append(next, *request.Entry)
case "update":
next[index] = *request.Entry
next[index].Enabled = entries[index].Enabled
case "remove":
next = slices.Delete(next, index, index+1)
case "enable":
@@ -268,7 +311,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
sessions := make(map[string]*tentacle.Session)
problems := make(map[string]string)
startups := make(map[string]tentacleStartupCall)
removedPackages := make(map[string]tentacle.Entry)
removedPackages := make(map[string]tentacleRetiredPackage)
for _, entry := range entries {
pending[entry.ID] = entry.Enabled
}
@@ -315,6 +358,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
result.Entries = append(result.Entries, status)
}
result.Updates = tentacle.AvailableUpdates(result)
runtime.mutex.Lock()
runtime.snapshot = result
runtime.mutex.Unlock()
@@ -338,7 +382,8 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
if manager != nil {
managerError = manager.Shutdown(cleanup)
}
for _, entry := range removedPackages {
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)
@@ -382,6 +427,11 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
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()
@@ -389,7 +439,8 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
failStartup := func(id, message string) {
problems[id] = message
if _, exists := startups[id]; !exists {
startup, exists := startups[id]
if !exists {
return
}
revoke(id)
@@ -400,15 +451,31 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
next := tentacle.CloneEntries(entries)
for index := range next {
if next[index].ID == id {
next[index].Enabled = false
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 disabled state could not be saved durably: " + err.Error()
message += " The recovery state could not be saved durably: " + err.Error()
}
replyStartup(id, errors.New(message))
}
@@ -467,15 +534,26 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
}
if current == nil {
for id, entry := range removedPackages {
if sessions[id] != 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, id)
delete(removedPackages, directory)
}
for _, entry := range entries {
if !pending[entry.ID] || sessions[entry.ID] != nil {
@@ -572,6 +650,9 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
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
@@ -591,15 +672,28 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
}
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" {
if call.request.Action == "remove" || call.request.Action == "update" {
for _, entry := range entries {
if entry.ID == id && entry.PackageDirectory != "" {
removedPackages[id] = entry
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)
@@ -619,7 +713,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
runtime.publishCommands(stillActive)
setSnapshot()
if pending[id] {
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify)}
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify), previous: previous}
continue
}
}
@@ -665,6 +759,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
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())
+22
View File
@@ -404,3 +404,25 @@ func TestManagedTentacleCancellationDuringBindingsRollsBack(t *testing.T) {
t.Fatalf("incorrect persisted rollback: %+v %v", disk, err)
}
}
// Updating an entry must preserve the user's enabled preference.
func TestUpdatePreservesEnabledState(t *testing.T) {
for _, enabled := range []bool{false, true} {
current := tentacle.Entry{ID: "example", Program: []string{"/bin/true"}, Enabled: enabled, Version: "0.1.3"}
replacement := tentacle.Entry{ID: "example", Program: []string{"/bin/true"}, Enabled: !enabled, Version: "0.1.4"}
next, err := change_tentacle_entries(
[]tentacle.Entry{current},
tentacle.ManagementRequest{
Action: "update",
ID: current.ID,
Entry: &replacement,
},
)
if err != nil {
t.Fatal(err)
}
if len(next) != 1 || next[0].Enabled != enabled || next[0].Version != "0.1.4" {
t.Fatalf("enabled=%v: unexpected result: %+v", enabled, next)
}
}
}
@@ -0,0 +1,90 @@
package app
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func TestUpgradePreparationFailurePreservesInstallation(t *testing.T) {
repository, data := install_fixture(t)
set_install_transport(t, func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}, nil
})
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
owner, _ := managed_tentacle_fixture(t, registryPath, false)
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
return repository, nil
}
if err := owner.Start(context.Background()); err != nil {
t.Fatal(err)
}
manage := func(action string) error {
return owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: action,
URL: repository.URL,
ID: func() string {
if action == "install" || action == "upgrade" {
return "echo"
}
return ""
}(),
})
}
if err := manage("repository-add"); err != nil {
t.Fatal(err)
}
if err := manage("install"); err != nil {
t.Fatal(err)
}
id := tentacle.PackageID(repository, "echo")
wait_managed_state(t, owner, id, "disabled")
before := owner.Snapshot().Entries[0].Entry
repository.Revision = strings.Repeat("b", 40)
repository.Tentacles[0].Version = "0.2"
if err := manage("repository-refresh"); err != nil {
t.Fatal(err)
}
owner.preparePackage = func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) {
return nil, errors.New("simulated download failure")
}
if err := manage("upgrade"); err == nil {
t.Fatal("upgrade unexpectedly succeeded")
}
after := owner.Snapshot().Entries[0].Entry
if after.Version != before.Version ||
after.Enabled != before.Enabled ||
after.PackageDirectory != before.PackageDirectory {
t.Fatalf("failed upgrade changed installation: before=%+v after=%+v", before, after)
}
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatalf("failed upgrade removed installed package: %v", err)
}
}
@@ -0,0 +1,330 @@
package app
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func prepare_upgrade_fixture(t *testing.T, mode string) (*tentacleRuntime, *tentacleBindingRecorder, tentacle.Repository, tentacle.Entry) {
t.Helper()
repository, data := install_fixture(t)
set_install_transport(t, func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
ContentLength: int64(len(data)),
Body: io.NopCloser(bytes.NewReader(data)),
Header: make(http.Header),
}, nil
})
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
owner, bindings := managed_tentacle_fixture(t, registryPath, false)
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
return repository, nil
}
if err := owner.Start(context.Background()); err != nil {
t.Fatal(err)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "repository-add", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "install-enable", ID: "echo", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
id := tentacle.PackageID(repository, "echo")
wait_managed_state(t, owner, id, "active")
before := owner.Snapshot().Entries[0].Entry
// Rebuild the downloaded archive with a matching version 0.2 manifest.
var updated bytes.Buffer
gzipWriter, err := gzip.NewWriterLevel(&updated, gzip.BestSpeed)
if err != nil {
t.Fatal(err)
}
tarWriter := tar.NewWriter(gzipWriter)
tarReader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
reader := tar.NewReader(tarReader)
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
if header.Name == "tentacle.toml" {
content = bytes.Replace(content, []byte("version='0.1'"), []byte("version='0.2'"), 1)
if mode == "stall" {
content = bytes.Replace(content, []byte(before.Program[len(before.Program)-1]), []byte(filepath.Join(t.TempDir(), "stall")), 1)
}
}
if header.Name == "bin/echo" && mode == "fail" {
content, err = os.ReadFile("/bin/false")
if err != nil {
t.Fatal(err)
}
}
copyHeader := *header
copyHeader.Size = int64(len(content))
if err := tarWriter.WriteHeader(&copyHeader); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write(content); err != nil {
t.Fatal(err)
}
}
if err := tarWriter.Close(); err != nil {
t.Fatal(err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatal(err)
}
if err := tarReader.Close(); err != nil {
t.Fatal(err)
}
updatedData := updated.Bytes()
digest := sha256.Sum256(updatedData)
repository.Revision = strings.Repeat("b", 40)
repository.Tentacles[0].Version = "0.2"
repository.Tentacles[0].Releases = []tentacle.Release{{
Platform: "linux",
Architecture: runtime.GOARCH,
URL: "https://example.org/release-0.2.tar.gz",
SHA256: fmt.Sprintf("%x", digest),
}}
data = updatedData
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "repository-refresh", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
return owner, bindings, repository, before
}
func TestUpgradeReplacesRunningPackageAndCleansOldFiles(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
id := before.ID
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
Action: "upgrade", ID: "echo", URL: repository.URL,
}); err != nil {
t.Fatal(err)
}
wait_managed_state(t, owner, id, "active")
after := owner.Snapshot().Entries[0].Entry
if !after.Enabled {
t.Fatal("upgrade disabled previously enabled tentacle")
}
if after.PackageDirectory == before.PackageDirectory {
t.Fatal("upgrade reused old package directory")
}
waitTentacle(t, func() bool {
_, err := os.Stat(before.PackageDirectory)
return os.IsNotExist(err)
})
if _, err := os.Stat(after.PackageDirectory); err != nil {
t.Fatalf("replacement package missing: %v", err)
}
}
func TestUpgradeFailureAndCancellationRestorePreviousPackage(t *testing.T) {
for _, operation := range []string{"fail", "cancel", "disable", "remove", "shutdown"} {
t.Run(operation, func(t *testing.T) {
mode := "stall"
if operation == "fail" {
mode = "fail"
}
owner, bindings, repository, before := prepare_upgrade_fixture(t, mode)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
if operation != "fail" {
waitTentacle(t, func() bool {
snapshot := owner.Snapshot()
return len(snapshot.Entries) == 1 && snapshot.Entries[0].Entry.Version == "0.2" && snapshot.Entries[0].State == "starting"
})
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatalf("old package lost during startup: %v", err)
}
switch operation {
case "cancel":
cancel()
case "disable", "remove":
manage_tentacle(t, owner, operation, before.ID, nil)
case "shutdown":
if err := owner.Stop(ctx); err != nil {
t.Fatal(err)
}
}
}
select {
case err := <-result:
if err == nil {
t.Fatal("failed or cancelled update reported success")
}
case <-time.After(10 * time.Second):
t.Fatal("update did not finish")
}
if operation == "fail" || operation == "cancel" {
wait_managed_state(t, owner, before.ID, "active")
owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
waitTentacle(t, func() bool { _, err := os.Stat(before.Program[len(before.Program)-1]); return err == nil })
}
if operation == "disable" {
wait_managed_state(t, owner, before.ID, "disabled")
}
if operation == "shutdown" {
restarted, _ := managed_tentacle_fixture(t, owner.registry.Path, false)
if err := restarted.Start(context.Background()); err != nil {
t.Fatal(err)
}
wait_managed_state(t, restarted, before.ID, "active")
}
disk, err := (&tentacle.Registry{Path: owner.registry.Path}).Load()
if err != nil {
t.Fatal(err)
}
if operation == "remove" {
if len(disk) != 0 {
t.Fatal(disk)
}
} else {
expected := before
if operation == "disable" {
expected.Enabled = false
}
if len(disk) != 1 || !reflect.DeepEqual(disk[0], expected) {
t.Fatalf("previous installation not restored: %+v", disk)
}
if _, err := os.Stat(before.PackageDirectory); err != nil {
t.Fatal(err)
}
}
waitTentacle(t, func() bool {
packages, err := filepath.Glob(filepath.Join(tentacle.PackageRoot(owner.registry.Path), "package-*"))
expected := 1
if operation == "remove" {
expected = 0
}
return err == nil && len(packages) == expected
})
})
}
}
func TestUpgradePreparationCancellationLeavesRunningPackage(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
preparing := make(chan struct{})
owner.preparePackage = func(ctx context.Context, _ string, _ tentacle.Repository, _ string) (*tentacle.PreparedPackage, error) {
close(preparing)
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
select {
case <-preparing:
case <-time.After(5 * time.Second):
t.Fatal("preparation did not start")
}
cancel()
select {
case err := <-result:
if err == nil {
t.Fatal("cancelled preparation succeeded")
}
case <-time.After(5 * time.Second):
t.Fatal("preparation did not cancel")
}
status := owner.Snapshot().Entries[0]
if status.State != "active" || !reflect.DeepEqual(status.Entry, before) {
t.Fatalf("cancelled preparation changed running package: %+v", status)
}
}
func TestUpgradeUsesEnablementAtCommit(t *testing.T) {
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
preparing, release := make(chan struct{}), make(chan struct{})
owner.preparePackage = func(ctx context.Context, root string, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) {
close(preparing)
select {
case <-release:
case <-ctx.Done():
return nil, ctx.Err()
}
return tentacle.PreparePackage(ctx, root, repository, id)
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
result := make(chan error, 1)
go func() {
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
}()
select {
case <-preparing:
case <-ctx.Done():
t.Fatal("preparation did not start")
}
manage_tentacle(t, owner, "disable", before.ID, nil)
close(release)
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("upgrade did not finish")
}
wait_managed_state(t, owner, before.ID, "disabled")
status := owner.Snapshot().Entries[0]
if status.Entry.Version != "0.2" || status.Entry.Enabled {
t.Fatal(status)
}
waitTentacle(t, func() bool { _, err := os.Stat(before.PackageDirectory); return os.IsNotExist(err) })
}
+5 -2
View File
@@ -76,7 +76,7 @@ void prefs_init_tentacles(void) {
const struct { const char *id; int action; } buttons[] = {
{"tentacleNew", 9}, {"tentacleRetry", 13}, {"tentacleRemove", 14},
{"tentacleRefresh", 15}, {"tentacleRepositoryRemove", 12}, {"tentacleRepositoryRefresh", 18},
{"tentacleInstall", 19}, {"tentacleSettings", 20},
{"tentacleInstall", 19}, {"tentacleUpdate", 21}, {"tentacleSettings", 20},
};
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));
@@ -124,6 +124,7 @@ void prefs_tentacle_controls(int available, int installed, int enabled, int repo
gtk_widget_set_sensitive(prefs_widget("tentacleSettings"), available && installed);
gtk_widget_set_sensitive(prefs_widget("tentacleRemove"), available && installed);
gtk_widget_set_sensitive(prefs_widget("tentacleInstall"), available && !installed && prefs_tentacle_selected() >= 0);
gtk_widget_set_sensitive(prefs_widget("tentacleUpdate"), available && installed && prefs_tentacle_selected() >= 0);
gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRefresh"), available && repository);
gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRemove"), available && repository);
}
@@ -131,7 +132,9 @@ int prefs_tentacle_confirm(const char *message, const char *label) {
GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(prefs_widget("window")),
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, GTK_MESSAGE_QUESTION,
GTK_BUTTONS_NONE, "%s", message);
gtk_window_set_title(GTK_WINDOW(dialog), "Install tentacle");
gchar *title = g_strdup_printf("%s tentacle", label);
gtk_window_set_title(GTK_WINDOW(dialog), title);
g_free(title);
gtk_dialog_add_button(GTK_DIALOG(dialog), "Cancel", GTK_RESPONSE_CANCEL);
gtk_dialog_add_button(GTK_DIALOG(dialog), label, GTK_RESPONSE_ACCEPT);
gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL);
+1
View File
@@ -307,6 +307,7 @@
</object></child>
<child><object class="GtkButtonBox"><property name="layout-style">start</property><property name="spacing">6</property>
<child><object class="GtkButton" id="tentacleInstall"><property name="label">Install</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleUpdate"><property name="label">Update</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleSettings"><property name="label">Settings</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleRetry"><property name="label">Retry</property><property name="can-focus">True</property></object></child>
<child><object class="GtkButton" id="tentacleRemove"><property name="label">Remove tentacle</property><property name="can-focus">True</property></object></child>
+56
View File
@@ -212,6 +212,62 @@ func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error
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.")
+4 -1
View File
@@ -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 <= 20 && !busy {
if action >= 9 && action <= 21 && !busy {
request, err := tentacles.action(int(action))
if err != nil {
status(err.Error(), true)
@@ -164,6 +164,9 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
if request.Action == "install" || request.Action == "install-enable" {
status("Downloading and checking the tentacle package. Close preferences to cancel before it is saved.", true)
}
if request.Action == "upgrade" {
status("Downloading and checking the tentacle update. Close preferences to cancel.", true)
}
}
continue
}
+1
View File
@@ -24,4 +24,5 @@ type Snapshot struct {
Message string
Suppressed bool
Repositories []Repository
Updates []AvailableUpdate
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"fmt"
"strings"
)
// AvailableUpdate describes a newer repository release for an installed tentacle.
type AvailableUpdate struct {
ID string
Name string
InstalledVersion string
AvailableVersion string
RepositoryURL string
RepositoryID string
LocalID string
}
// packageVersion accepts dotted numeric releases (including existing short
// versions such as 0.1), with optional SemVer prerelease and build identifiers.
type packageVersion struct {
numbers []string
prerelease []string
}
func numeric_identifier(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func version_identifiers(value string, prerelease bool) ([]string, error) {
identifiers := strings.Split(value, ".")
for _, identifier := range identifiers {
if identifier == "" {
return nil, fmt.Errorf("empty version identifier")
}
for _, ch := range identifier {
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '-') {
return nil, fmt.Errorf("invalid version identifier %q", identifier)
}
}
if prerelease && numeric_identifier(identifier) && len(identifier) > 1 && identifier[0] == '0' {
return nil, fmt.Errorf("prerelease number has a leading zero")
}
}
return identifiers, nil
}
func parse_package_version(value string) (packageVersion, error) {
var result packageVersion
core := strings.TrimPrefix(strings.TrimSpace(value), "v")
if before, build, found := strings.Cut(core, "+"); found {
if _, err := version_identifiers(build, false); err != nil {
return result, err
}
core = before
}
if before, prerelease, found := strings.Cut(core, "-"); found {
var err error
result.prerelease, err = version_identifiers(prerelease, true)
if err != nil {
return result, err
}
core = before
}
result.numbers = strings.Split(core, ".")
for _, number := range result.numbers {
if !numeric_identifier(number) {
return result, fmt.Errorf("invalid numeric version %q", value)
}
}
return result, nil
}
// Compare decimal strings without an integer-size limit.
func compare_version_number(first, second string) int {
first, second = strings.TrimLeft(first, "0"), strings.TrimLeft(second, "0")
if len(first) < len(second) {
return -1
}
if len(first) > len(second) {
return 1
}
return strings.Compare(first, second)
}
// IsNewerVersion compares release and prerelease precedence. Build metadata
// does not change precedence; missing numeric components are treated as zero.
func IsNewerVersion(available, installed string) (bool, error) {
first, err := parse_package_version(available)
if err != nil {
return false, err
}
second, err := parse_package_version(installed)
if err != nil {
return false, err
}
for index := 0; index < len(first.numbers) || index < len(second.numbers); index++ {
firstNumber, secondNumber := "0", "0"
if index < len(first.numbers) {
firstNumber = first.numbers[index]
}
if index < len(second.numbers) {
secondNumber = second.numbers[index]
}
if compared := compare_version_number(firstNumber, secondNumber); compared != 0 {
return compared > 0, nil
}
}
if len(first.prerelease) == 0 || len(second.prerelease) == 0 {
return len(first.prerelease) == 0 && len(second.prerelease) != 0, nil
}
for index := 0; index < len(first.prerelease) && index < len(second.prerelease); index++ {
firstID, secondID := first.prerelease[index], second.prerelease[index]
firstNumeric, secondNumeric := numeric_identifier(firstID), numeric_identifier(secondID)
compared := strings.Compare(firstID, secondID)
if firstNumeric && secondNumeric {
compared = compare_version_number(firstID, secondID)
}
if firstNumeric != secondNumeric {
return !firstNumeric, nil
}
if compared != 0 {
return compared > 0, nil
}
}
return len(first.prerelease) > len(second.prerelease), nil
}
// AvailableUpdates finds newer versions of installed repository tentacles.
func AvailableUpdates(snapshot Snapshot) []AvailableUpdate {
var updates []AvailableUpdate
for _, status := range snapshot.Entries {
entry := status.Entry
if entry.Repository == "" || entry.Version == "" {
continue
}
for _, repository := range snapshot.Repositories {
if repository.URL != entry.Repository || repository.ID != entry.RepositoryID {
continue
}
for _, manifest := range repository.Tentacles {
if manifest.ID != entry.LocalID {
continue
}
newer, err := IsNewerVersion(manifest.Version, entry.Version)
if err != nil || !newer {
continue
}
updates = append(updates, AvailableUpdate{
ID: entry.ID,
Name: manifest.Name,
InstalledVersion: entry.Version,
AvailableVersion: manifest.Version,
RepositoryURL: repository.URL,
RepositoryID: repository.ID,
LocalID: manifest.ID,
})
}
}
}
return updates
}
+61
View File
@@ -0,0 +1,61 @@
package tentacle
import "testing"
func TestVersionPrecedence(t *testing.T) {
ordered := []string{"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0", "1.1", "2.0"}
for firstIndex, first := range ordered {
for secondIndex, second := range ordered {
newer, err := IsNewerVersion(first, second)
if err != nil || newer != (firstIndex > secondIndex) {
t.Fatalf("%s > %s: %v, %v", first, second, newer, err)
}
}
}
for _, pair := range [][2]string{{"v0.1", "0.1.0"}, {"1.0.0+build.2", "1.0.0+build.1"}, {"2026.11.01", "2026.11.1"}} {
for _, versions := range [][2]string{pair, {pair[1], pair[0]}} {
if newer, err := IsNewerVersion(versions[0], versions[1]); err != nil || newer {
t.Fatalf("equal precedence: %v: %v %v", versions, newer, err)
}
}
}
if newer, err := IsNewerVersion("999999999999999999999999999", "2"); err != nil || !newer {
t.Fatalf("large numeric component: %v %v", newer, err)
}
for _, invalid := range []string{"", "nightly", "1..0", "1.-2", "1.0-", "1.0-01", "1.0+", "1.0+foo+bar", "1.0-a_1"} {
if _, err := IsNewerVersion(invalid, "1.0"); err == nil {
t.Errorf("accepted invalid version %q", invalid)
}
if _, err := IsNewerVersion("2.0", invalid); err == nil {
t.Errorf("accepted invalid installed version %q", invalid)
}
}
}
func TestAvailableUpdatesRequiresExactSource(t *testing.T) {
repository := Repository{URL: "https://example.org/tentacles", ID: "example.source", Tentacles: []Manifest{{ID: "echo", Name: "Echo", Version: "1.0"}}}
entry := Entry{Repository: repository.URL, RepositoryID: repository.ID, LocalID: "echo", Version: "1.0-rc.1"}
entry.ID = PackageID(repository, entry.LocalID)
snapshot := Snapshot{Entries: []EntryStatus{{Entry: entry}}, Repositories: []Repository{repository}}
updates := AvailableUpdates(snapshot)
if len(updates) != 1 || updates[0].ID != entry.ID || updates[0].AvailableVersion != "1.0" {
t.Fatalf("missing release update: %+v", updates)
}
for _, changed := range []string{"url", "repository-id", "local-id", "version"} {
candidate := snapshot
candidate.Repositories = CloneRepositories(snapshot.Repositories)
switch changed {
case "url":
candidate.Repositories[0].URL = "https://other.example.org/tentacles"
case "repository-id":
candidate.Repositories[0].ID = "example.replacement"
case "local-id":
candidate.Repositories[0].Tentacles[0].ID = "different"
case "version":
candidate.Repositories[0].Tentacles[0].Version = entry.Version
}
if updates := AvailableUpdates(candidate); len(updates) != 0 {
t.Errorf("%s offered unrelated or equal release: %+v", changed, updates)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import "strings"
const Name = "Cthulhu"
const Version = "2026.10.31"
const Version = "2026.11.01"
const Channel = "testing"
var Build = Version
+8
View File
@@ -152,6 +152,9 @@ def inside():
EchoVoice={"Family": {"Name": "Spanish test voice", "Lang": "es"}})
if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1":
initial["Tentacles"] = {"Entries": [], "Suppressed": False}
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
from native_tentacle_updates import update_snapshot
initial["Tentacles"] = update_snapshot()
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
initial["Tentacles"] = {"Entries": [{"Entry": {"ID": "probe", "Name": "Settings probe", "Enabled": True},
"State": "active", "Shortcuts": ["Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)"]}], "Suppressed": False}
@@ -458,6 +461,11 @@ def inside():
check_page(pyatspi, process, window, nodes, named, walk, focus, key,
request, send, initial, application)
return
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
from native_tentacle_updates import check_page
check_page(pyatspi, process, window, named, walk, focus, key,
request, send, initial)
return
if os.environ.get("CTHULHU_PREFS_OCR_ONLY") == "1":
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
assert tabs.querySelection().selectChild(8), "cannot select OCR tab"
+77
View File
@@ -0,0 +1,77 @@
"""Private GTK update confirmation and keyboard workflow; no installed packages."""
import select
from native_x11 import wait_for
def update_snapshot():
repositoryUrl = "https://example.org/tentacles"
return {
"Entries": [{"Entry": {
"ID": "probe", "LocalID": "echo", "Name": "Update probe",
"Repository": repositoryUrl, "RepositoryID": "example.source",
"Version": "1.0", "Enabled": True, "Presentation": True,
}, "State": "active"}],
"Repositories": [{"URL": repositoryUrl, "ID": "example.source", "Name": "Example",
"Tentacles": [{"ID": "echo", "Name": "Update probe", "Version": "1.1",
"Capabilities": ["presentation", "interaction"]}]}],
"Updates": [{"ID": "probe", "LocalID": "echo", "Name": "Update probe",
"InstalledVersion": "1.0", "AvailableVersion": "1.1",
"RepositoryURL": repositoryUrl, "RepositoryID": "example.source"}],
}
def check_page(pyatspi, process, window, named, walk, focus, key, request, send, initial):
tab = next(node for node in walk(window)
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_PAGE_TAB)
assert tab.parent.querySelection().selectChild(tab.getIndexInParent()), "cannot select Tentacles tab"
table = next(node for node in walk(window)
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE)
focus(table)
key("Home")
update = named("Update")
wait_for(lambda: update.getState().contains(pyatspi.STATE_SENSITIVE), "Update did not enable")
# Tab from the table skips disabled Install and reaches Update.
key("Tab")
wait_for(lambda: update.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not reach Update")
key("Tab")
wait_for(lambda: named("Settings").getState().contains(pyatspi.STATE_FOCUSED), "Update trapped Tab")
key("shift+Tab")
wait_for(lambda: update.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not reach Update")
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("Current capabilities:" in child.name for child in walk(node)):
return node
return None
key("space")
dialog = wait_for(confirmation, "keyboard activation did not open confirmation")
message = " ".join(node.name for node in walk(dialog))
assert "version 1.0 to version 1.1" in message, message
assert "Current capabilities: presentation." in message, message
assert "Repository declares: presentation, interaction." in message, message
cancel = next(node for node in walk(dialog) if node.name == "Cancel")
focus(cancel)
key("space")
wait_for(lambda: not confirmation(), "Cancel did not close confirmation")
assert not select.select([process.stdout], [], [], 0.2)[0], "Cancel sent a management request"
# The native button action uses the same path as keyboard activation.
update.queryAction().doAction(0)
dialog = wait_for(confirmation, "button action did not open confirmation")
accept = next(node for node in walk(dialog)
if node.name == "Update" and node.getRole() == pyatspi.ROLE_PUSH_BUTTON)
focus(accept)
key("Return")
value = request()
assert value["Tentacle"] == {"Action": "upgrade", "ID": "echo", "Entry": None,
"URL": "https://example.org/tentacles"}, value["Tentacle"]
send(dict(initial, Message="Update accepted."))
wait_for(lambda: update.getState().contains(pyatspi.STATE_SENSITIVE), "response left controls busy")
focus(named("Cancel"))
key("space")
assert process.wait(timeout=5) == 0
print("PASS: Update Tab/Shift+Tab, keyboard/button activation, versions/capabilities, Cancel, upgrade request")