// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "os" "reflect" "slices" "strings" "time" "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.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...) 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...) 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 } if request.Action == "install" || request.Action == "install-enable" { if request.Entry != nil { return errors.New("unexpected tentacle entry in installation request") } snapshot := runtime.Snapshot() if snapshot.Suppressed || snapshot.Message != "" { return tentacle.ErrUnavailable } index := slices.IndexFunc(snapshot.Repositories, func(value tentacle.Repository) bool { return value.URL == request.URL }) if index < 0 { return errors.New("repository is not configured") } repository := snapshot.Repositories[index] id := tentacle.PackageID(repository, request.ID) if slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) { return errors.New("tentacle is already installed") } runtime.mutex.Lock() if runtime.repositoryBusy { runtime.mutex.Unlock() return tentacle.ErrBusy } runtime.repositoryBusy = true runtime.mutex.Unlock() defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() prepare := runtime.preparePackage if prepare == nil { prepare = tentacle.PreparePackage } prepareCtx, cancel := context.WithTimeout(ctx, 45*time.Second) stop := context.AfterFunc(runtime.lifecycleContext, cancel) prepared, err := prepare(prepareCtx, tentacle.PackageRoot(runtime.registry.Path), repository, request.ID) stop() cancel() if err != nil { return err } defer prepared.Discard() 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} } 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 } runtime.mutex.Lock() if runtime.repositoryBusy { runtime.mutex.Unlock() return tentacle.ErrBusy } runtime.repositoryBusy = true runtime.mutex.Unlock() defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }() request.URL, call.request.URL = origin, origin fetch := runtime.fetchRepository if fetch == nil { fetch = tentacle.FetchRepository } // Fetch on the Preferences worker, leaving lifecycle and native input // free to disable, revoke, and process failures while the network waits. fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) stop := context.AfterFunc(runtime.lifecycleContext, cancel) repository, err := fetch(fetchCtx, origin) stop() cancel() 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 } if call.packageFiles != nil { // Once queued, wait for the lifecycle owner's decision before releasing // files. Returning on caller cancellation here could delete a committed // package. The owner checks cancellation before saving the registry. select { case err := <-call.result: return err case <-runtime.done: return tentacle.ErrUnavailable } } select { case err := <-call.result: return err case <-ctx.Done(): return ctx.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 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) } func (runtime *tentacleRuntime) run_managed(ctx context.Context) error { entries, loadError := runtime.registry.Load() repositories := runtime.registry.Repositories() if loadError == nil { if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil { runtime.logger.Warn("recover tentacle packages", "error", err) } } pending := make(map[string]bool) sessions := make(map[string]*tentacle.Session) problems := make(map[string]string) removedPackages := make(map[string]tentacle.Entry) for _, entry := range entries { pending[entry.ID] = entry.Enabled } wake := make(chan struct{}, 1) notify := func() { select { case wake <- struct{}{}: default: } } var manager *tentacle.Manager var delegation *tentacle.Delegation var serviceDone <-chan struct{} var current *tentacle.Session var ready <-chan struct{} var active []*tentacle.Session var watchers []<-chan struct{} setSnapshot := func() { result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(repositories)} if loadError != nil { result.Message = "Tentacle registry could not be loaded; no tentacles were started." } for _, entry := range entries { status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: problems[entry.ID]} if entry.Enabled { status.State = "starting" } if session := sessions[entry.ID]; session != nil { state, _ := session.Status() status.State = string(state) if state == tentacle.Ready { status.State = "starting" } if (state == tentacle.Failed || state == tentacle.Stopped) && entry.Enabled && status.Message == "" { status.Message = "Tentacle stopped; retry to start it again." } } if pending[entry.ID] { status.State = "starting" } if status.Message != "" && !pending[entry.ID] { status.State = "failed" } if !entry.Enabled && sessions[entry.ID] == nil { status.State = "disabled" } result.Entries = append(result.Entries, status) } runtime.mutex.Lock() runtime.snapshot = result runtime.mutex.Unlock() } setSnapshot() if loadError != nil { runtime.report("Tentacles could not start.") } defer func() { runtime.Disable() cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second) defer cancel() var bindingError error if runtime.input != nil { bindingError = runtime.input.SetTentacleBindings(cleanup, nil) } if runtime.readerContext.Err() != nil { bindingError = nil } var managerError, serviceError error if manager != nil { managerError = manager.Shutdown(cleanup) } if delegation != nil { serviceError = delegation.Close(cleanup) } for _, entry := range removedPackages { if managerError == nil { if err := tentacle.RemovePackage(tentacle.PackageRoot(runtime.registry.Path), entry); err != nil { runtime.logger.Warn("remove tentacle package", "error", err) } } } for _, done := range watchers { <-done } runtime.results.Wait() runtime.stopError = errors.Join(bindingError, managerError, serviceError) if runtime.stopError != nil && runtime.readerContext.Err() == nil { select { case runtime.failures <- runtime.stopError: default: } } }() ensureManager := func() error { if manager != nil { return nil } if runtime.input == nil { return errors.New("native bindings are unavailable") } var err error if runtime.newManager != nil { manager, err = runtime.newManager() return err } parent := runtime.parent if parent == "" || parent == "auto" { program := runtime.guardianProgram if len(program) == 0 { executable, err := os.Executable() if err != nil { return err } program = []string{executable, "--tentacle-guardian"} } if delegation == nil { delegation, err = tentacle.StartDelegation(ctx, program) if err != nil { return err } serviceDone = delegation.Done() } parent = delegation.Parent } if delegation != nil { manager, err = tentacle.NewDelegatedManager(delegation) } else { manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, parent, tentacle.DefaultResourceLimits()) } return err } revoke := func(id string) { if session := sessions[id]; session != nil { session.Disable() runtime.output.RetireRevoked() } } for { if ctx.Err() != nil { return nil } // Reconcile revoked sessions before staging new native grabs. retained := active[:0] for _, session := range active { if state, _ := session.Status(); state == tentacle.Active { retained = append(retained, session) } } changed := len(retained) != len(active) active = retained if changed { runtime.publishCommands(active) if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { select { case runtime.failures <- err: default: } return err } } // Joined sessions release host capacity before an explicit retry/update. for id, session := range sessions { select { case <-session.Done(): if state, failure := session.Status(); state == tentacle.Failed { runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure) } if !pending[id] { index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id }) if index >= 0 && entries[index].Enabled && problems[id] == "" { problems[id] = "Tentacle stopped; retry to start it again." runtime.report("A tentacle stopped unexpectedly.") } } delete(sessions, id) runtime.mutex.Lock() runtime.sessions = slices.DeleteFunc(runtime.sessions, func(value *tentacle.Session) bool { return value == session }) runtime.mutex.Unlock() default: } } if current == nil { for id, entry := range removedPackages { if sessions[id] != nil { 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.") } delete(removedPackages, id) } for _, entry := range entries { if !pending[entry.ID] || sessions[entry.ID] != nil { continue } pending[entry.ID] = false err := ensureManager() var session *tentacle.Session if err == nil { session, err = manager.Start(ctx, entry.Spec()) } if err != nil { problems[entry.ID] = "Tentacle could not start; check resource containment and its program, then retry." runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err) runtime.report("A tentacle could not start.") notify() break } sessions[entry.ID] = session runtime.mutex.Lock() runtime.sessions = append(runtime.sessions, session) if runtime.disabled { session.Disable() } runtime.mutex.Unlock() current, ready = session, session.Ready() done := make(chan struct{}) // Prune joined watcher handles so retries never accumulate history. watchers = slices.DeleteFunc(watchers, func(done <-chan struct{}) bool { select { case <-done: return true default: return false } }) watchers = append(watchers, done) go func() { defer close(done) select { case <-session.Revoked(): case <-ctx.Done(): } runtime.output.RetireRevoked() notify() <-session.Done() notify() }() break } } setSnapshot() select { case <-ctx.Done(): return nil case <-serviceDone: // A lost containment owner invalidates every session. Do not relaunch // into a disappearing cgroup or run uncontained. return tentacle.ErrContainment case <-wake: case call := <-runtime.requests: if err := call.ctx.Err(); err != nil { call.result <- err continue } if loadError != nil { call.result <- errors.New("tentacle registry could not be loaded; repair it and restart") continue } runtime.mutex.Lock() disabled := runtime.disabled runtime.mutex.Unlock() if disabled { call.result <- tentacle.ErrDisabled continue } if strings.HasPrefix(call.request.Action, "repository-") { next, err := change_tentacle_repositories(repositories, call.request, call.repository) if err == nil { err = runtime.registry.SaveState(entries, next) } if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { repositories = next setSnapshot() } call.result <- err continue } if call.packageFiles != nil { index := slices.IndexFunc(repositories, func(value tentacle.Repository) bool { return value.URL == call.repository.URL }) if index < 0 || !reflect.DeepEqual(repositories[index], *call.repository) { call.result <- errors.New("Repository changed during installation; refresh and try again.") continue } } next, err := change_tentacle_entries(entries, call.request) if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, entries) { call.result <- nil continue } if err == nil && call.request.Action != "retry" { err = runtime.registry.Save(next) } if err == nil || errors.Is(err, tentacle.ErrRegistrySync) { id := call.request.ID if call.packageFiles != nil { call.packageFiles.Retain() } if call.request.Action == "remove" { for _, entry := range entries { if entry.ID == id && entry.PackageDirectory != "" { removedPackages[id] = entry } } } revoke(id) entries = next delete(problems, id) delete(pending, id) for _, entry := range entries { if entry.ID == id { pending[id] = entry.Enabled } } // Immediately withdraw dispatch, without waiting for key release. stillActive := slices.DeleteFunc(slices.Clone(active), func(session *tentacle.Session) bool { state, _ := session.Status(); return state != tentacle.Active }) runtime.publishCommands(stillActive) setSnapshot() } call.result <- err case <-ready: session := current current, ready = nil, nil if state, _ := session.Status(); state != tentacle.Ready { continue } candidate := append(slices.Clone(active), session) if err := runtime.bind(ctx, candidate); err != nil { session.Disable() runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err) runtime.report("A tentacle could not acquire its keyboard shortcuts.") continue } if err := session.Activate(); err != nil { session.Disable() if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil { select { case runtime.failures <- err: default: } return err } continue } active = candidate runtime.publishCommands(active) runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity()) } } }