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.
241 lines
8.0 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|