625 lines
19 KiB
Go
625 lines
19 KiB
Go
// 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())
|
|
}
|
|
}
|
|
}
|