// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "slices" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/tentacle" ) type tentacleManagementCall struct { ctx context.Context request tentacle.ManagementRequest result chan error repository *tentacle.Repository packageFiles *tentacle.PreparedPackage } func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot { if runtime == nil { return tentacle.Snapshot{Suppressed: true, Message: "Tentacle management is unavailable for this run."} } if runtime.registry == nil { return tentacle.Snapshot{Suppressed: true, Message: "Explicit development launch lists cannot be managed here."} } runtime.mutex.Lock() 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 { bindings = runtime.input.TentacleBindings() } for index := range result.Entries { result.Entries[index].Entry.Program = append([]string(nil), result.Entries[index].Entry.Program...) result.Entries[index].Entry.RemoteGrants = append([]string(nil), result.Entries[index].Entry.RemoteGrants...) if runtime.disabled { result.Entries[index].State = "suppressed" } result.Entries[index].Shortcuts = nil for _, binding := range bindings { command, exists := runtime.commands[binding.Command] if !exists || command.entryID != result.Entries[index].Entry.ID { continue } layout := "Desktop" if binding.Layout == input.LaptopKeyboardLayout { layout = "Laptop" } for _, key := range binding.Keys { result.Entries[index].Shortcuts = append(result.Entries[index].Shortcuts, fmt.Sprintf("%s: %s (%s)", layout, key, command.title)) } } } result.Suppressed = runtime.disabled return result } // OpenSettings dispatches the explicitly registered settings command using // the preferences window's lifetime, rather than a short management timeout. // Settings storage and Save/Cancel belong to the tentacle's existing form. func (runtime *tentacleRuntime) OpenSettings(ctx context.Context, id string) error { if runtime == nil || runtime.registry == nil { return tentacle.ErrUnavailable } runtime.mutex.Lock() defer runtime.mutex.Unlock() if runtime.disabled { return tentacle.ErrDisabled } index := slices.IndexFunc(runtime.snapshot.Entries, func(status tentacle.EntryStatus) bool { return status.Entry.ID == id }) if index < 0 { return errors.New("Select an installed tentacle first.") } if !runtime.snapshot.Entries[index].Entry.Enabled { return errors.New("Enable this tentacle before opening its settings.") } if runtime.snapshot.Entries[index].State != "active" { return errors.New("Tentacle settings are unavailable until the tentacle is active. Refresh status or retry.") } for key, command := range runtime.commands { if command.entryID == id && command.settings { return runtime.invokeLocked(ctx, key, command) } } return errors.New("No settings for this tentacle.") } // Manage fetches metadata on its caller's worker, then queues a transaction. // Persistence, start and binding work stays on the lifecycle owner, never the // native input callback or GTK thread. func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.ManagementRequest) error { if runtime == nil || runtime.registry == nil { return tentacle.ErrUnavailable } if request.Action == "list" { return nil } if request.Entry != nil { entry := *request.Entry entry.Program = append([]string(nil), entry.Program...) entry.RemoteGrants = append([]string(nil), entry.RemoteGrants...) request.Entry = &entry } call := tentacleManagementCall{ctx: ctx, request: request, result: make(chan error, 1)} runtime.mutex.Lock() disabled := runtime.disabled runtime.mutex.Unlock() if disabled { return tentacle.ErrDisabled } switch request.Action { case "update", "disable", "remove", "retry": runtime.revokeManagedRemote(request.ID) } if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" { repository, err := repositoryForInstallation(runtime.Snapshot(), request) if err != nil { return err } if request.Action == "upgrade" { // Revoke before download/preparation, including a failed or cancelled // update. Rolling back the package must never restore access consent. runtime.revokeManagedRemote(tentacle.PackageID(repository, request.ID)) } if !runtime.beginRepositoryWork() { return tentacle.ErrBusy } defer runtime.endRepositoryWork() prepared, err := runtime.prepareManagedPackage(ctx, repository, request.ID) if err != nil { return err } defer prepared.Discard() 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: operation, ID: prepared.Entry.ID, Entry: &prepared.Entry} } if request.Action == "repository-add" || request.Action == "repository-refresh" { if request.Entry != nil || request.ID != "" { return errors.New("unexpected tentacle entry in repository request") } origin, err := tentacle.RepositoryURL(request.URL) if err != nil { return err } if !runtime.beginRepositoryWork() { return tentacle.ErrBusy } defer runtime.endRepositoryWork() request.URL, call.request.URL = origin, origin repository, err := runtime.fetchManagedRepository(ctx, origin) if err != nil { return err } call.repository = &repository } select { case runtime.requests <- call: case <-ctx.Done(): return ctx.Err() case <-runtime.done: return tentacle.ErrUnavailable default: return tentacle.ErrBusy } // Once queued, wait for the lifecycle owner's decision. Startup requests // wake the owner on cancellation so rollback completes before the response; // package files remain owned until the registry decision is known. select { case err := <-call.result: return err case <-runtime.done: return tentacle.ErrUnavailable } } func change_tentacle_entries(entries []tentacle.Entry, request tentacle.ManagementRequest) ([]tentacle.Entry, error) { next := tentacle.CloneEntries(entries) index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == request.ID }) if request.Action == "add" || request.Action == "update" { if request.Entry == nil || request.Entry.ID != request.ID { return nil, errors.New("a matching tentacle entry is required") } } else if request.Entry != nil { return nil, errors.New("unexpected tentacle entry") } if request.Action != "add" && index < 0 { return nil, errors.New("tentacle does not exist") } switch request.Action { case "add": if index >= 0 { return nil, errors.New("tentacle already exists") } 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": next[index].Enabled = true case "disable": next[index].Enabled = false case "retry": if !next[index].Enabled { return nil, errors.New("enable the tentacle before retrying") } default: return nil, errors.New("unknown tentacle operation") } return next, tentacle.ValidateEntries(next) } // Revoke outside the runtime lock; no session event/cancellation path may call // back into the reader while its registry/command mutex is held. func (runtime *tentacleRuntime) revokeManagedRemote(id string) { runtime.mutex.Lock() sessions := slices.Clone(runtime.sessions) runtime.mutex.Unlock() for _, session := range sessions { if session.TentacleID() == id { session.RevokeRemote() } } }