From 7591f65b58d26703e4fa6f65980cf170b1eef6b9 Mon Sep 17 00:00:00 2001 From: Kendell Clark Date: Sun, 1 Nov 2026 12:21:55 -0500 Subject: [PATCH] Added tentacle updater. --- README.md | 1 + internal/app/preferences_tentacles.go | 2 +- internal/app/tentacle_install_test.go | 7 +- internal/app/tentacle_management.go | 127 ++++++- internal/app/tentacle_management_test.go | 22 ++ internal/app/tentacle_upgrade_failure_test.go | 90 +++++ .../app/tentacle_upgrade_lifecycle_test.go | 330 ++++++++++++++++++ internal/preferences/native_tentacles.c | 7 +- internal/preferences/preferences.ui | 1 + internal/preferences/tentacles.go | 56 +++ internal/preferences/ui.go | 5 +- internal/tentacle/management.go | 1 + internal/tentacle/updates.go | 173 +++++++++ internal/tentacle/updates_test.go | 61 ++++ internal/version/version.go | 2 +- tests/native_preferences.py | 8 + tests/native_tentacle_updates.py | 77 ++++ 17 files changed, 948 insertions(+), 22 deletions(-) create mode 100644 internal/app/tentacle_upgrade_failure_test.go create mode 100644 internal/app/tentacle_upgrade_lifecycle_test.go create mode 100644 internal/tentacle/updates.go create mode 100644 internal/tentacle/updates_test.go create mode 100644 tests/native_tentacle_updates.py diff --git a/README.md b/README.md index 6c5b7dd..855bdcc 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/internal/app/preferences_tentacles.go b/internal/app/preferences_tentacles.go index 8a52937..a002b97 100644 --- a/internal/app/preferences_tentacles.go +++ b/internal/app/preferences_tentacles.go @@ -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) diff --git a/internal/app/tentacle_install_test.go b/internal/app/tentacle_install_test.go index 60606cc..2f57987 100644 --- a/internal/app/tentacle_install_test.go +++ b/internal/app/tentacle_install_test.go @@ -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 diff --git a/internal/app/tentacle_management.go b/internal/app/tentacle_management.go index 7bce3b4..b5f23cf 100644 --- a/internal/app/tentacle_management.go +++ b/internal/app/tentacle_management.go @@ -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()) diff --git a/internal/app/tentacle_management_test.go b/internal/app/tentacle_management_test.go index f223ea7..7c97b00 100644 --- a/internal/app/tentacle_management_test.go +++ b/internal/app/tentacle_management_test.go @@ -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) + } + } +} diff --git a/internal/app/tentacle_upgrade_failure_test.go b/internal/app/tentacle_upgrade_failure_test.go new file mode 100644 index 0000000..602570f --- /dev/null +++ b/internal/app/tentacle_upgrade_failure_test.go @@ -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) + } +} diff --git a/internal/app/tentacle_upgrade_lifecycle_test.go b/internal/app/tentacle_upgrade_lifecycle_test.go new file mode 100644 index 0000000..c5f8d8a --- /dev/null +++ b/internal/app/tentacle_upgrade_lifecycle_test.go @@ -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(©Header); 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) }) +} diff --git a/internal/preferences/native_tentacles.c b/internal/preferences/native_tentacles.c index 9cc3df8..129ee2d 100644 --- a/internal/preferences/native_tentacles.c +++ b/internal/preferences/native_tentacles.c @@ -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); diff --git a/internal/preferences/preferences.ui b/internal/preferences/preferences.ui index e020bd7..33ff8c9 100644 --- a/internal/preferences/preferences.ui +++ b/internal/preferences/preferences.ui @@ -307,6 +307,7 @@ start6 InstallTrue + UpdateTrue SettingsTrue RetryTrue Remove tentacleTrue diff --git a/internal/preferences/tentacles.go b/internal/preferences/tentacles.go index 2671a0d..8832113 100644 --- a/internal/preferences/tentacles.go +++ b/internal/preferences/tentacles.go @@ -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.") diff --git a/internal/preferences/ui.go b/internal/preferences/ui.go index cd8bf45..2c452d3 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 <= 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 } diff --git a/internal/tentacle/management.go b/internal/tentacle/management.go index a7dfd77..3806f43 100644 --- a/internal/tentacle/management.go +++ b/internal/tentacle/management.go @@ -24,4 +24,5 @@ type Snapshot struct { Message string Suppressed bool Repositories []Repository + Updates []AvailableUpdate } diff --git a/internal/tentacle/updates.go b/internal/tentacle/updates.go new file mode 100644 index 0000000..f0262a4 --- /dev/null +++ b/internal/tentacle/updates.go @@ -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 +} diff --git a/internal/tentacle/updates_test.go b/internal/tentacle/updates_test.go new file mode 100644 index 0000000..734768b --- /dev/null +++ b/internal/tentacle/updates_test.go @@ -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) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 0579d4e..7389e76 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -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 diff --git a/tests/native_preferences.py b/tests/native_preferences.py index 6281d87..8dcc107 100644 --- a/tests/native_preferences.py +++ b/tests/native_preferences.py @@ -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" diff --git a/tests/native_tentacle_updates.py b/tests/native_tentacle_updates.py new file mode 100644 index 0000000..aed3200 --- /dev/null +++ b/tests/native_tentacle_updates.py @@ -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")