Files
cthulhu/internal/app/tentacle_management.go
ak47 573d15d7b4 Add Remote session foundations and feasibility checks
Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates.

Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
2026-11-03 22:33:26 -05:00

241 lines
8.0 KiB
Go

// 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()
}
}
}