217 lines
7.0 KiB
Go
217 lines
7.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...)
|
|
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" || request.Action == "upgrade" {
|
|
repository, err := repositoryForInstallation(runtime.Snapshot(), request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
}
|