Code review and cleanup.
This commit is contained in:
1 parent
bbe931335f
commit
92d6ad3cc4
23 files changed
+1497
-886
No files matched your search
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
// A startup request is owned by the lifecycle worker until activation or failure.
|
||||
// Cancellation only wakes that worker; it never mutates registry or session state.
|
||||
type tentacleStartupCall struct {
|
||||
call tentacleManagementCall
|
||||
saveError error
|
||||
stop func() bool
|
||||
previous *tentacle.Entry
|
||||
}
|
||||
|
||||
// A retired package stays on disk until the session using it has joined.
|
||||
type tentacleRetiredPackage struct {
|
||||
entry tentacle.Entry
|
||||
session *tentacle.Session
|
||||
}
|
||||
|
||||
// tentacleLifecycle owns managed state for one run, on the lifecycle goroutine.
|
||||
// Watchers and cancellation callbacks only notify it; they never mutate these
|
||||
// maps or transitions. Startup rollback runs before shutdown joins sessions.
|
||||
type tentacleLifecycle struct {
|
||||
ctx context.Context
|
||||
runtime *tentacleRuntime
|
||||
entries []tentacle.Entry
|
||||
repositories []tentacle.Repository
|
||||
loadError error
|
||||
pending map[string]bool
|
||||
sessions map[string]*tentacle.Session
|
||||
problems map[string]string
|
||||
startups map[string]tentacleStartupCall
|
||||
removedPackages map[string]tentacleRetiredPackage
|
||||
wake chan struct{}
|
||||
manager *tentacle.Manager
|
||||
current *tentacle.Session
|
||||
ready <-chan struct{}
|
||||
active []*tentacle.Session
|
||||
watchers []<-chan struct{}
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
entries, loadError := runtime.registry.Load()
|
||||
owner := &tentacleLifecycle{
|
||||
ctx: ctx,
|
||||
runtime: runtime,
|
||||
entries: entries,
|
||||
loadError: loadError,
|
||||
repositories: runtime.registry.Repositories(),
|
||||
pending: make(map[string]bool),
|
||||
sessions: make(map[string]*tentacle.Session),
|
||||
problems: make(map[string]string),
|
||||
startups: make(map[string]tentacleStartupCall),
|
||||
removedPackages: make(map[string]tentacleRetiredPackage),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
if loadError == nil {
|
||||
if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil {
|
||||
runtime.logger.Warn("recover tentacle packages", "error", err)
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
owner.pending[entry.ID] = entry.Enabled
|
||||
}
|
||||
owner.setSnapshot()
|
||||
if loadError != nil {
|
||||
runtime.report("Tentacles could not start.")
|
||||
}
|
||||
defer owner.shutdown()
|
||||
defer owner.cancelStartups()
|
||||
return owner.run()
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) notify() {
|
||||
select {
|
||||
case owner.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) setSnapshot() {
|
||||
result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(owner.repositories)}
|
||||
if owner.loadError != nil {
|
||||
result.Message = "Tentacle registry could not be loaded; no tentacles were started."
|
||||
}
|
||||
for _, entry := range owner.entries {
|
||||
status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: owner.problems[entry.ID]}
|
||||
if entry.Enabled {
|
||||
status.State = "starting"
|
||||
}
|
||||
if session := owner.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 owner.pending[entry.ID] {
|
||||
status.State = "starting"
|
||||
}
|
||||
if status.Message != "" && !owner.pending[entry.ID] {
|
||||
status.State = "failed"
|
||||
}
|
||||
if !entry.Enabled && owner.sessions[entry.ID] == nil {
|
||||
status.State = "disabled"
|
||||
}
|
||||
result.Entries = append(result.Entries, status)
|
||||
}
|
||||
result.Updates = tentacle.AvailableUpdates(result)
|
||||
owner.runtime.mutex.Lock()
|
||||
owner.runtime.snapshot = result
|
||||
owner.runtime.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) shutdown() {
|
||||
owner.runtime.Disable()
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second)
|
||||
defer cancel()
|
||||
var bindingError error
|
||||
if owner.runtime.input != nil {
|
||||
bindingError = owner.runtime.input.SetTentacleBindings(cleanup, nil)
|
||||
}
|
||||
if owner.runtime.readerContext.Err() != nil {
|
||||
bindingError = nil
|
||||
}
|
||||
var managerError error
|
||||
if owner.manager != nil {
|
||||
managerError = owner.manager.Shutdown(cleanup)
|
||||
}
|
||||
for _, retired := range owner.removedPackages {
|
||||
entry := retired.entry
|
||||
if managerError == nil {
|
||||
if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil {
|
||||
owner.runtime.logger.Warn("remove tentacle package", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, done := range owner.watchers {
|
||||
<-done
|
||||
}
|
||||
owner.runtime.results.Wait()
|
||||
owner.runtime.stopError = errors.Join(bindingError, managerError)
|
||||
if owner.runtime.stopError != nil && owner.runtime.readerContext.Err() == nil {
|
||||
select {
|
||||
case owner.runtime.failures <- owner.runtime.stopError:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) reconcileBindings() error {
|
||||
retained := owner.active[:0]
|
||||
for _, session := range owner.active {
|
||||
if state, _ := session.Status(); state == tentacle.Active {
|
||||
retained = append(retained, session)
|
||||
}
|
||||
}
|
||||
changed := len(retained) != len(owner.active)
|
||||
owner.active = retained
|
||||
if changed {
|
||||
owner.runtime.publishCommands(owner.active)
|
||||
if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil {
|
||||
select {
|
||||
case owner.runtime.failures <- err:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) reapSessions() {
|
||||
for id, session := range owner.sessions {
|
||||
select {
|
||||
case <-session.Done():
|
||||
if state, failure := session.Status(); state == tentacle.Failed {
|
||||
owner.runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure)
|
||||
}
|
||||
if !owner.pending[id] {
|
||||
index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id })
|
||||
if index >= 0 && owner.entries[index].Enabled && owner.problems[id] == "" {
|
||||
owner.problems[id] = "Tentacle stopped; retry to start it again."
|
||||
owner.runtime.report("A tentacle stopped unexpectedly.")
|
||||
}
|
||||
}
|
||||
delete(owner.sessions, id)
|
||||
owner.runtime.mutex.Lock()
|
||||
owner.runtime.sessions = slices.DeleteFunc(owner.runtime.sessions, func(value *tentacle.Session) bool { return value == session })
|
||||
owner.runtime.mutex.Unlock()
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) removeRetiredPackages() {
|
||||
for directory, retired := range owner.removedPackages {
|
||||
entry := retired.entry
|
||||
if retired.session != nil {
|
||||
select {
|
||||
case <-retired.session.Done():
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(owner.entries, func(candidateEntry tentacle.Entry) bool {
|
||||
return candidateEntry.PackageDirectory == directory
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil {
|
||||
owner.runtime.logger.Warn("remove tentacle package", "error", err)
|
||||
owner.runtime.report("Tentacle was removed, but its package files could not be deleted.")
|
||||
continue
|
||||
}
|
||||
delete(owner.removedPackages, directory)
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) run() error {
|
||||
for {
|
||||
if owner.ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
for id, startup := range owner.startups {
|
||||
if startup.call.ctx.Err() != nil {
|
||||
owner.failStartup(id, "Tentacle startup was cancelled before activation.")
|
||||
}
|
||||
}
|
||||
// Reconcile revoked sessions before staging new native grabs, and release
|
||||
// joined sessions before starting replacements or removing their files.
|
||||
if err := owner.reconcileBindings(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner.reapSessions()
|
||||
if owner.current == nil {
|
||||
owner.removeRetiredPackages()
|
||||
owner.startPending()
|
||||
}
|
||||
owner.setSnapshot()
|
||||
select {
|
||||
case <-owner.ctx.Done():
|
||||
return nil
|
||||
case <-owner.wake:
|
||||
case call := <-owner.runtime.requests:
|
||||
owner.handleRequest(call)
|
||||
case <-owner.ready:
|
||||
if err := owner.activateReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func (owner *tentacleLifecycle) handleRequest(call tentacleManagementCall) {
|
||||
if err := call.ctx.Err(); err != nil {
|
||||
call.result <- err
|
||||
return
|
||||
}
|
||||
if owner.loadError != nil {
|
||||
call.result <- errors.New("tentacle registry could not be loaded; repair it and restart")
|
||||
return
|
||||
}
|
||||
owner.runtime.mutex.Lock()
|
||||
disabled := owner.runtime.disabled
|
||||
owner.runtime.mutex.Unlock()
|
||||
if disabled {
|
||||
call.result <- tentacle.ErrDisabled
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(call.request.Action, "repository-") {
|
||||
next, err := change_tentacle_repositories(owner.repositories, call.request, call.repository)
|
||||
if err == nil {
|
||||
err = owner.runtime.registry.SaveState(owner.entries, next)
|
||||
}
|
||||
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
|
||||
owner.repositories = next
|
||||
owner.setSnapshot()
|
||||
}
|
||||
call.result <- err
|
||||
return
|
||||
}
|
||||
if call.packageFiles != nil {
|
||||
index := slices.IndexFunc(owner.repositories, func(value tentacle.Repository) bool { return value.URL == call.repository.URL })
|
||||
if index < 0 || !reflect.DeepEqual(owner.repositories[index], *call.repository) {
|
||||
call.result <- errors.New("Repository changed during installation; refresh and try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, starting := owner.startups[call.request.ID]; starting && call.request.Action != "disable" && call.request.Action != "remove" {
|
||||
call.result <- tentacle.ErrBusy
|
||||
return
|
||||
}
|
||||
if startup, exists := owner.startups[call.request.ID]; exists && startup.previous != nil {
|
||||
owner.failStartup(call.request.ID, "Tentacle update was cancelled by another operation.")
|
||||
}
|
||||
next, err := change_tentacle_entries(owner.entries, call.request)
|
||||
if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, owner.entries) {
|
||||
// Saved enablement is intent, not proof of a live session. An
|
||||
// explicit enable/update must recover a failed or starting entry.
|
||||
index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == call.request.ID })
|
||||
state := tentacle.Stopped
|
||||
if session := owner.sessions[call.request.ID]; session != nil {
|
||||
state, _ = session.Status()
|
||||
}
|
||||
if !next[index].Enabled || state == tentacle.Active {
|
||||
call.result <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
if err == nil && call.request.Action != "retry" {
|
||||
err = owner.runtime.registry.Save(next)
|
||||
}
|
||||
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
|
||||
id := call.request.ID
|
||||
var previous *tentacle.Entry
|
||||
if call.packageFiles != nil && call.request.Action == "update" {
|
||||
index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id })
|
||||
if index >= 0 && owner.entries[index].Enabled {
|
||||
saved := tentacle.CloneEntries(owner.entries[index : index+1])[0]
|
||||
previous = &saved
|
||||
}
|
||||
}
|
||||
owner.replyStartup(id, errors.New("Tentacle startup was cancelled by another operation."))
|
||||
if call.packageFiles != nil {
|
||||
call.packageFiles.Retain()
|
||||
}
|
||||
if call.request.Action == "remove" || call.request.Action == "update" {
|
||||
for _, entry := range owner.entries {
|
||||
if entry.ID != id || entry.PackageDirectory == "" {
|
||||
continue
|
||||
}
|
||||
if call.request.Action == "update" && (previous != nil ||
|
||||
entry.PackageDirectory == call.request.Entry.PackageDirectory) {
|
||||
continue
|
||||
}
|
||||
owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]}
|
||||
}
|
||||
}
|
||||
owner.revoke(id)
|
||||
if owner.current != nil && owner.current.TentacleID() == id {
|
||||
owner.current, owner.ready = nil, nil
|
||||
}
|
||||
owner.entries = next
|
||||
delete(owner.problems, id)
|
||||
delete(owner.pending, id)
|
||||
for _, entry := range owner.entries {
|
||||
if entry.ID == id {
|
||||
owner.pending[id] = entry.Enabled
|
||||
}
|
||||
}
|
||||
// Immediately withdraw dispatch, without waiting for key release.
|
||||
stillActive := slices.DeleteFunc(slices.Clone(owner.active), func(session *tentacle.Session) bool { state, _ := session.Status(); return state != tentacle.Active })
|
||||
owner.runtime.publishCommands(stillActive)
|
||||
owner.setSnapshot()
|
||||
if owner.pending[id] {
|
||||
owner.startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, owner.notify), previous: previous}
|
||||
return
|
||||
}
|
||||
}
|
||||
call.result <- err
|
||||
}
|
||||
@@ -7,10 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
@@ -24,21 +21,6 @@ type tentacleManagementCall struct {
|
||||
packageFiles *tentacle.PreparedPackage
|
||||
}
|
||||
|
||||
// A startup request is owned by the lifecycle worker until activation or failure.
|
||||
// Cancellation only wakes that worker; it never mutates registry or session state.
|
||||
type tentacleStartupCall struct {
|
||||
call tentacleManagementCall
|
||||
saveError error
|
||||
stop func() bool
|
||||
previous *tentacle.Entry
|
||||
}
|
||||
|
||||
// A retired package stays on disk until the session using it has joined.
|
||||
type tentacleRetiredPackage struct {
|
||||
entry tentacle.Entry
|
||||
session *tentacle.Session
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot {
|
||||
if runtime == nil {
|
||||
return tentacle.Snapshot{Suppressed: true, Message: "Tentacle management is unavailable for this run."}
|
||||
@@ -133,67 +115,15 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
return tentacle.ErrDisabled
|
||||
}
|
||||
if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" {
|
||||
if request.Entry != nil {
|
||||
return errors.New("unexpected tentacle entry in installation request")
|
||||
repository, err := repositoryForInstallation(runtime.Snapshot(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id })
|
||||
if request.Action == "upgrade" && !installed {
|
||||
return errors.New("tentacle is not installed")
|
||||
}
|
||||
if request.Action != "upgrade" && installed {
|
||||
return errors.New("tentacle is already installed")
|
||||
}
|
||||
if request.Action == "upgrade" {
|
||||
for _, status := range snapshot.Entries {
|
||||
if status.Entry.ID != id {
|
||||
continue
|
||||
}
|
||||
for _, manifest := range repository.Tentacles {
|
||||
if manifest.ID != request.ID {
|
||||
continue
|
||||
}
|
||||
if manifest.Version == status.Entry.Version {
|
||||
return errors.New("tentacle is already at the repository version")
|
||||
}
|
||||
newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare tentacle versions: %w", err)
|
||||
}
|
||||
if !newer {
|
||||
return errors.New("repository tentacle version is not newer than installed version")
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
runtime.mutex.Lock()
|
||||
if runtime.repositoryBusy {
|
||||
runtime.mutex.Unlock()
|
||||
if !runtime.beginRepositoryWork() {
|
||||
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()
|
||||
defer runtime.endRepositoryWork()
|
||||
prepared, err := runtime.prepareManagedPackage(ctx, repository, request.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -216,26 +146,12 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.mutex.Lock()
|
||||
if runtime.repositoryBusy {
|
||||
runtime.mutex.Unlock()
|
||||
if !runtime.beginRepositoryWork() {
|
||||
return tentacle.ErrBusy
|
||||
}
|
||||
runtime.repositoryBusy = true
|
||||
runtime.mutex.Unlock()
|
||||
defer func() { runtime.mutex.Lock(); runtime.repositoryBusy = false; runtime.mutex.Unlock() }()
|
||||
defer runtime.endRepositoryWork()
|
||||
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()
|
||||
repository, err := runtime.fetchManagedRepository(ctx, origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -298,471 +214,3 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme
|
||||
}
|
||||
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)
|
||||
startups := make(map[string]tentacleStartupCall)
|
||||
removedPackages := make(map[string]tentacleRetiredPackage)
|
||||
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 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)
|
||||
}
|
||||
result.Updates = tentacle.AvailableUpdates(result)
|
||||
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 error
|
||||
if manager != nil {
|
||||
managerError = manager.Shutdown(cleanup)
|
||||
}
|
||||
for _, retired := range removedPackages {
|
||||
entry := retired.entry
|
||||
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)
|
||||
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
|
||||
manager, err = runtime.makeManager()
|
||||
return err
|
||||
}
|
||||
revoke := func(id string) {
|
||||
if session := sessions[id]; session != nil {
|
||||
session.Disable()
|
||||
runtime.output.RetireRevoked()
|
||||
}
|
||||
}
|
||||
replyStartup := func(id string, err error) {
|
||||
startup, exists := startups[id]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
delete(startups, id)
|
||||
startup.stop()
|
||||
if err == nil {
|
||||
if startup.previous != nil {
|
||||
previous := *startup.previous
|
||||
// Starting the replacement already waited for the old session.
|
||||
removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous}
|
||||
}
|
||||
err = startup.saveError
|
||||
}
|
||||
setSnapshot()
|
||||
startup.call.result <- err
|
||||
}
|
||||
failStartup := func(id, message string) {
|
||||
problems[id] = message
|
||||
startup, exists := startups[id]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
revoke(id)
|
||||
if current != nil && current.TentacleID() == id {
|
||||
current, ready = nil, nil
|
||||
}
|
||||
delete(pending, id)
|
||||
next := tentacle.CloneEntries(entries)
|
||||
for index := range next {
|
||||
if next[index].ID == id {
|
||||
if startup.previous != nil {
|
||||
next[index] = *startup.previous
|
||||
} else {
|
||||
next[index].Enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
err := runtime.registry.Save(next)
|
||||
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
|
||||
if startup.previous != nil {
|
||||
failed := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id })
|
||||
if failed >= 0 {
|
||||
entry := entries[failed]
|
||||
removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]}
|
||||
}
|
||||
runtime.mutex.Lock()
|
||||
disabled := runtime.disabled
|
||||
runtime.mutex.Unlock()
|
||||
pending[id] = startup.previous.Enabled && !disabled && ctx.Err() == nil
|
||||
message += " The previous package was restored."
|
||||
}
|
||||
entries = next
|
||||
}
|
||||
if err != nil {
|
||||
message += " The recovery state could not be saved durably: " + err.Error()
|
||||
}
|
||||
replyStartup(id, errors.New(message))
|
||||
}
|
||||
defer func() {
|
||||
for id := range startups {
|
||||
failStartup(id, "Tentacle startup was cancelled before activation.")
|
||||
}
|
||||
}()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
for id, startup := range startups {
|
||||
if startup.call.ctx.Err() != nil {
|
||||
failStartup(id, "Tentacle startup was cancelled before activation.")
|
||||
}
|
||||
}
|
||||
// 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 directory, retired := range removedPackages {
|
||||
entry := retired.entry
|
||||
if retired.session != nil {
|
||||
select {
|
||||
case <-retired.session.Done():
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(entries, func(active tentacle.Entry) bool {
|
||||
return active.PackageDirectory == directory
|
||||
}) {
|
||||
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.")
|
||||
continue
|
||||
}
|
||||
delete(removedPackages, directory)
|
||||
}
|
||||
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 {
|
||||
failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.")
|
||||
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()
|
||||
runtime.signal_extensions()
|
||||
notify()
|
||||
<-session.Done()
|
||||
notify()
|
||||
}()
|
||||
break
|
||||
}
|
||||
}
|
||||
setSnapshot()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
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
|
||||
}
|
||||
}
|
||||
if _, starting := startups[call.request.ID]; starting && call.request.Action != "disable" && call.request.Action != "remove" {
|
||||
call.result <- tentacle.ErrBusy
|
||||
continue
|
||||
}
|
||||
if startup, exists := startups[call.request.ID]; exists && startup.previous != nil {
|
||||
failStartup(call.request.ID, "Tentacle update was cancelled by another operation.")
|
||||
}
|
||||
next, err := change_tentacle_entries(entries, call.request)
|
||||
if err == nil && call.request.Action != "retry" && reflect.DeepEqual(next, entries) {
|
||||
// Saved enablement is intent, not proof of a live session. An
|
||||
// explicit enable/update must recover a failed or starting entry.
|
||||
index := slices.IndexFunc(next, func(entry tentacle.Entry) bool { return entry.ID == call.request.ID })
|
||||
state := tentacle.Stopped
|
||||
if session := sessions[call.request.ID]; session != nil {
|
||||
state, _ = session.Status()
|
||||
}
|
||||
if !next[index].Enabled || state == tentacle.Active {
|
||||
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
|
||||
var previous *tentacle.Entry
|
||||
if call.packageFiles != nil && call.request.Action == "update" {
|
||||
index := slices.IndexFunc(entries, func(entry tentacle.Entry) bool { return entry.ID == id })
|
||||
if index >= 0 && entries[index].Enabled {
|
||||
saved := tentacle.CloneEntries(entries[index : index+1])[0]
|
||||
previous = &saved
|
||||
}
|
||||
}
|
||||
replyStartup(id, errors.New("Tentacle startup was cancelled by another operation."))
|
||||
if call.packageFiles != nil {
|
||||
call.packageFiles.Retain()
|
||||
}
|
||||
if call.request.Action == "remove" || call.request.Action == "update" {
|
||||
for _, entry := range entries {
|
||||
if entry.ID != id || entry.PackageDirectory == "" {
|
||||
continue
|
||||
}
|
||||
if call.request.Action == "update" && (previous != nil ||
|
||||
entry.PackageDirectory == call.request.Entry.PackageDirectory) {
|
||||
continue
|
||||
}
|
||||
removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: sessions[id]}
|
||||
}
|
||||
}
|
||||
revoke(id)
|
||||
if current != nil && current.TentacleID() == id {
|
||||
current, ready = nil, nil
|
||||
}
|
||||
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()
|
||||
if pending[id] {
|
||||
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify), previous: previous}
|
||||
continue
|
||||
}
|
||||
}
|
||||
call.result <- err
|
||||
case <-ready:
|
||||
session := current
|
||||
current, ready = nil, nil
|
||||
if state, _ := session.Status(); state != tentacle.Ready {
|
||||
failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.")
|
||||
continue
|
||||
}
|
||||
if startup, exists := startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil {
|
||||
failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.")
|
||||
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.")
|
||||
failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.")
|
||||
continue
|
||||
}
|
||||
// Native binding setup can wait for held keys. Cancellation during
|
||||
// that wait must roll back the new grabs before reporting failure.
|
||||
activationError := ctx.Err()
|
||||
if startup, exists := startups[session.TentacleID()]; exists && activationError == nil {
|
||||
activationError = startup.call.ctx.Err()
|
||||
}
|
||||
if activationError == nil {
|
||||
activationError = session.Activate()
|
||||
}
|
||||
if activationError != nil {
|
||||
session.Disable()
|
||||
if err := runtime.bind(ctx, active); err != nil && ctx.Err() == nil {
|
||||
select {
|
||||
case runtime.failures <- err:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.")
|
||||
continue
|
||||
}
|
||||
active = candidate
|
||||
delete(problems, session.TentacleID())
|
||||
runtime.publishCommands(active)
|
||||
replyStartup(session.TentacleID(), nil)
|
||||
runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
// Preparation runs on the caller's worker. Its busy reservation remains owned
|
||||
// by Manage through the lifecycle reply, including rollback after cancellation.
|
||||
func (runtime *tentacleRuntime) beginRepositoryWork() bool {
|
||||
runtime.mutex.Lock()
|
||||
defer runtime.mutex.Unlock()
|
||||
if runtime.repositoryBusy {
|
||||
return false
|
||||
}
|
||||
runtime.repositoryBusy = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) endRepositoryWork() {
|
||||
runtime.mutex.Lock()
|
||||
runtime.repositoryBusy = false
|
||||
runtime.mutex.Unlock()
|
||||
}
|
||||
|
||||
func repositoryForInstallation(snapshot tentacle.Snapshot, request tentacle.ManagementRequest) (tentacle.Repository, error) {
|
||||
if request.Entry != nil {
|
||||
return tentacle.Repository{}, errors.New("unexpected tentacle entry in installation request")
|
||||
}
|
||||
if snapshot.Suppressed || snapshot.Message != "" {
|
||||
return tentacle.Repository{}, tentacle.ErrUnavailable
|
||||
}
|
||||
index := slices.IndexFunc(snapshot.Repositories, func(value tentacle.Repository) bool { return value.URL == request.URL })
|
||||
if index < 0 {
|
||||
return tentacle.Repository{}, errors.New("repository is not configured")
|
||||
}
|
||||
repository := snapshot.Repositories[index]
|
||||
id := tentacle.PackageID(repository, request.ID)
|
||||
installed := slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id })
|
||||
if request.Action == "upgrade" && !installed {
|
||||
return tentacle.Repository{}, errors.New("tentacle is not installed")
|
||||
}
|
||||
if request.Action != "upgrade" && installed {
|
||||
return tentacle.Repository{}, errors.New("tentacle is already installed")
|
||||
}
|
||||
if request.Action == "upgrade" {
|
||||
for _, status := range snapshot.Entries {
|
||||
if status.Entry.ID != id {
|
||||
continue
|
||||
}
|
||||
for _, manifest := range repository.Tentacles {
|
||||
if manifest.ID != request.ID {
|
||||
continue
|
||||
}
|
||||
if manifest.Version == status.Entry.Version {
|
||||
return tentacle.Repository{}, errors.New("tentacle is already at the repository version")
|
||||
}
|
||||
newer, err := tentacle.IsNewerVersion(manifest.Version, status.Entry.Version)
|
||||
if err != nil {
|
||||
return tentacle.Repository{}, fmt.Errorf("cannot compare tentacle versions: %w", err)
|
||||
}
|
||||
if !newer {
|
||||
return tentacle.Repository{}, errors.New("repository tentacle version is not newer than installed version")
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) prepareManagedPackage(ctx context.Context, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) {
|
||||
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, id)
|
||||
stop()
|
||||
cancel()
|
||||
return prepared, err
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) fetchManagedRepository(ctx context.Context, origin string) (tentacle.Repository, error) {
|
||||
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()
|
||||
return repository, err
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func (owner *tentacleLifecycle) ensureManager() error {
|
||||
if owner.manager != nil {
|
||||
return nil
|
||||
}
|
||||
if owner.runtime.input == nil {
|
||||
return errors.New("native bindings are unavailable")
|
||||
}
|
||||
var err error
|
||||
owner.manager, err = owner.runtime.makeManager()
|
||||
return err
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) revoke(id string) {
|
||||
if session := owner.sessions[id]; session != nil {
|
||||
session.Disable()
|
||||
owner.runtime.output.RetireRevoked()
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) replyStartup(id string, err error) {
|
||||
startup, exists := owner.startups[id]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
delete(owner.startups, id)
|
||||
startup.stop()
|
||||
if err == nil {
|
||||
if startup.previous != nil {
|
||||
previous := *startup.previous
|
||||
// Starting the replacement already waited for the old session.
|
||||
owner.removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous}
|
||||
}
|
||||
err = startup.saveError
|
||||
}
|
||||
owner.setSnapshot()
|
||||
startup.call.result <- err
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) failStartup(id, message string) {
|
||||
owner.problems[id] = message
|
||||
startup, exists := owner.startups[id]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
owner.revoke(id)
|
||||
if owner.current != nil && owner.current.TentacleID() == id {
|
||||
owner.current, owner.ready = nil, nil
|
||||
}
|
||||
delete(owner.pending, id)
|
||||
next := tentacle.CloneEntries(owner.entries)
|
||||
for index := range next {
|
||||
if next[index].ID == id {
|
||||
if startup.previous != nil {
|
||||
next[index] = *startup.previous
|
||||
} else {
|
||||
next[index].Enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
err := owner.runtime.registry.Save(next)
|
||||
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
|
||||
if startup.previous != nil {
|
||||
failed := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id })
|
||||
if failed >= 0 {
|
||||
entry := owner.entries[failed]
|
||||
owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]}
|
||||
}
|
||||
owner.runtime.mutex.Lock()
|
||||
disabled := owner.runtime.disabled
|
||||
owner.runtime.mutex.Unlock()
|
||||
owner.pending[id] = startup.previous.Enabled && !disabled && owner.ctx.Err() == nil
|
||||
message += " The previous package was restored."
|
||||
}
|
||||
owner.entries = next
|
||||
}
|
||||
if err != nil {
|
||||
message += " The recovery state could not be saved durably: " + err.Error()
|
||||
}
|
||||
owner.replyStartup(id, errors.New(message))
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) cancelStartups() {
|
||||
for id := range owner.startups {
|
||||
owner.failStartup(id, "Tentacle startup was cancelled before activation.")
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) startPending() {
|
||||
for _, entry := range owner.entries {
|
||||
if !owner.pending[entry.ID] || owner.sessions[entry.ID] != nil {
|
||||
continue
|
||||
}
|
||||
owner.pending[entry.ID] = false
|
||||
err := owner.ensureManager()
|
||||
var session *tentacle.Session
|
||||
if err == nil {
|
||||
session, err = owner.manager.Start(owner.ctx, entry.Spec())
|
||||
}
|
||||
if err != nil {
|
||||
owner.failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.")
|
||||
owner.runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err)
|
||||
owner.runtime.report("A tentacle could not start.")
|
||||
owner.notify()
|
||||
break
|
||||
}
|
||||
owner.sessions[entry.ID] = session
|
||||
owner.runtime.mutex.Lock()
|
||||
owner.runtime.sessions = append(owner.runtime.sessions, session)
|
||||
if owner.runtime.disabled {
|
||||
session.Disable()
|
||||
}
|
||||
owner.runtime.mutex.Unlock()
|
||||
owner.current, owner.ready = session, session.Ready()
|
||||
done := make(chan struct{})
|
||||
// Prune joined watcher handles so retries never accumulate history.
|
||||
owner.watchers = slices.DeleteFunc(owner.watchers, func(done <-chan struct{}) bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
owner.watchers = append(owner.watchers, done)
|
||||
go func() {
|
||||
defer close(done)
|
||||
select {
|
||||
case <-session.Revoked():
|
||||
case <-owner.ctx.Done():
|
||||
}
|
||||
owner.runtime.output.RetireRevoked()
|
||||
owner.runtime.signal_extensions()
|
||||
owner.notify()
|
||||
<-session.Done()
|
||||
owner.notify()
|
||||
}()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func (owner *tentacleLifecycle) activateReady() error {
|
||||
session := owner.current
|
||||
owner.current, owner.ready = nil, nil
|
||||
if state, _ := session.Status(); state != tentacle.Ready {
|
||||
owner.failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.")
|
||||
return nil
|
||||
}
|
||||
if startup, exists := owner.startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil {
|
||||
owner.failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.")
|
||||
return nil
|
||||
}
|
||||
candidate := append(slices.Clone(owner.active), session)
|
||||
if err := owner.runtime.bind(owner.ctx, candidate); err != nil {
|
||||
session.Disable()
|
||||
owner.runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err)
|
||||
owner.runtime.report("A tentacle could not acquire its keyboard shortcuts.")
|
||||
owner.failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.")
|
||||
return nil
|
||||
}
|
||||
// Native binding setup can wait for held keys. Cancellation during
|
||||
// that wait must roll back the new grabs before reporting failure.
|
||||
activationError := owner.ctx.Err()
|
||||
if startup, exists := owner.startups[session.TentacleID()]; exists && activationError == nil {
|
||||
activationError = startup.call.ctx.Err()
|
||||
}
|
||||
if activationError == nil {
|
||||
activationError = session.Activate()
|
||||
}
|
||||
if activationError != nil {
|
||||
session.Disable()
|
||||
if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil {
|
||||
select {
|
||||
case owner.runtime.failures <- err:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
owner.failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.")
|
||||
return nil
|
||||
}
|
||||
owner.active = candidate
|
||||
delete(owner.problems, session.TentacleID())
|
||||
owner.runtime.publishCommands(owner.active)
|
||||
owner.replyStartup(session.TentacleID(), nil)
|
||||
owner.runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity())
|
||||
return nil
|
||||
}
|
||||
@@ -507,69 +507,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
return
|
||||
}
|
||||
snapshotStarted := time.Now()
|
||||
target := returnedMenuItem
|
||||
ownedTarget := false
|
||||
if target == nil {
|
||||
target = adapter.libreOfficeFocusTarget(event, eventType)
|
||||
ownedTarget = target != nil
|
||||
}
|
||||
if target == nil {
|
||||
target, ownedTarget = selectNativeEventTarget(event, eventType)
|
||||
}
|
||||
target, ownedTarget := adapter.resolveNativeEventTarget(event, eventType, returnedMenuItem)
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
if (eventType == FocusEvent || eventType == FocusedStateEvent) &&
|
||||
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM {
|
||||
// Qt menus can change their checked state without a state-change
|
||||
// event. Reopening the menu must capture the current provider state.
|
||||
C.atspi_accessible_clear_cache_single(target)
|
||||
}
|
||||
if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) &&
|
||||
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB {
|
||||
// Firefox can emit selected-state events from the previous tab;
|
||||
// query the current state/name before using either event path.
|
||||
C.atspi_accessible_clear_cache_single(target)
|
||||
}
|
||||
if eventType == WindowDeactivatedEvent {
|
||||
// Match Python's key-grab churn check against fresh window state rather
|
||||
// than an AT-SPI cache entry from before the deactivation event.
|
||||
C.atspi_accessible_clear_cache_single(event.source)
|
||||
}
|
||||
payload := C.cthulhu_event_payload(event)
|
||||
snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live)
|
||||
if completeLive {
|
||||
text := C.cthulhu_live_content(event.source)
|
||||
snapshot.Text = sanitizeSnapshotField(C.GoString(text))
|
||||
snapshot.LiveAtomic = true
|
||||
C.g_free(C.gpointer(payload))
|
||||
payload = text
|
||||
}
|
||||
// Shared Qt actions can report focus through their original, hidden menu
|
||||
// even while another menu emits focus-gained for that very accessible.
|
||||
// The explicit event establishes focus; the queried state may disagree.
|
||||
// Do not transfer this evidence to a redirected descendant or a dead item.
|
||||
if snapshot.ID() == originID && !snapshot.Defunct &&
|
||||
(eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) {
|
||||
switch snapshot.Role {
|
||||
case "menu item", "check menu item", "radio menu item":
|
||||
snapshot.Focused = true
|
||||
}
|
||||
}
|
||||
var inlineMenuID ObjectID
|
||||
if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct &&
|
||||
(snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" ||
|
||||
snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") {
|
||||
if menu := C.cthulhu_inline_menu_container(target); menu != nil {
|
||||
inlineMenuID = nativeObjectID(menu)
|
||||
C.g_object_unref(C.gpointer(menu))
|
||||
if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName {
|
||||
// The event's detail is authoritative even when libatspi still
|
||||
// caches the previous SELECTED state on the first visit.
|
||||
eventType, snapshot.Selected = ActiveDescendantEvent, true
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshNativeEventTarget(event, target, eventType)
|
||||
snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive)
|
||||
eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot)
|
||||
// Chromium can drop ACTIVE from its visible main frame while a tiny
|
||||
// same-process sibling claims it. On X11, the correlated foreground
|
||||
// window is the stronger ownership check performed below. Keep the
|
||||
@@ -579,7 +523,6 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent &&
|
||||
eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && eventType != AnnouncementEvent && !snapshot.AutocompletePopup &&
|
||||
C.cthulhu_in_inactive_window(event.source) != 0 {
|
||||
C.g_free(C.gpointer(payload))
|
||||
if ownedTarget {
|
||||
C.g_object_unref(C.gpointer(target))
|
||||
}
|
||||
@@ -593,7 +536,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
focusEvent := NewEventWithPayload(
|
||||
eventType,
|
||||
snapshot,
|
||||
C.GoString(payload),
|
||||
payload,
|
||||
)
|
||||
if eventType != nativeEventType {
|
||||
focusEvent.NativeType = nativeEventType
|
||||
@@ -636,7 +579,6 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
if eventType == ActiveDescendantEvent && inlineMenuID.Valid() {
|
||||
focusEvent.OriginPath = inlineMenuID.Path
|
||||
}
|
||||
C.g_free(C.gpointer(payload))
|
||||
if ownedTarget {
|
||||
C.g_object_unref(C.gpointer(target))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
/*
|
||||
#include "adapter_native.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
// resolveNativeEventTarget runs on the GLib owner. The returned menu item is
|
||||
// borrowed from the callback; LibreOffice and selected descendants can return
|
||||
// owned references. The caller releases an owned target after event capture.
|
||||
func (adapter *Adapter) resolveNativeEventTarget(event *C.AtspiEvent, eventType string, returnedMenuItem *C.AtspiAccessible) (*C.AtspiAccessible, bool) {
|
||||
target := returnedMenuItem
|
||||
ownedTarget := false
|
||||
if target == nil {
|
||||
target = adapter.libreOfficeFocusTarget(event, eventType)
|
||||
ownedTarget = target != nil
|
||||
}
|
||||
if target == nil {
|
||||
target, ownedTarget = selectNativeEventTarget(event, eventType)
|
||||
}
|
||||
return target, ownedTarget
|
||||
}
|
||||
|
||||
// Refresh only the established provider caches, in query order, before capture.
|
||||
// Both accessibles are borrowed and stay on the GLib owner thread.
|
||||
func refreshNativeEventTarget(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string) {
|
||||
if (eventType == FocusEvent || eventType == FocusedStateEvent) &&
|
||||
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_MENU_ITEM {
|
||||
// Qt menus can change their checked state without a state-change
|
||||
// event. Reopening the menu must capture the current provider state.
|
||||
C.atspi_accessible_clear_cache_single(target)
|
||||
}
|
||||
if (eventType == SelectedStateEvent || eventType == SelectionChangedEvent) &&
|
||||
C.atspi_accessible_get_role(target, nil) == C.ATSPI_ROLE_PAGE_TAB {
|
||||
// Firefox can emit selected-state events from the previous tab;
|
||||
// query the current state/name before using either event path.
|
||||
C.atspi_accessible_clear_cache_single(target)
|
||||
}
|
||||
if eventType == WindowDeactivatedEvent {
|
||||
// Match Python's key-grab churn check against fresh window state rather
|
||||
// than an AT-SPI cache entry from before the deactivation event.
|
||||
C.atspi_accessible_clear_cache_single(event.source)
|
||||
}
|
||||
}
|
||||
|
||||
// captureNativeEventContent borrows event/target and owns all payload buffers.
|
||||
// A completed atomic live region replaces the event payload before copying it.
|
||||
// No native references escape this helper; all calls remain on the GLib owner.
|
||||
func (adapter *Adapter) captureNativeEventContent(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, live nativeLiveProperties, completeLive bool) (Snapshot, ObjectID, string) {
|
||||
payload := C.cthulhu_event_payload(event)
|
||||
defer func() { C.g_free(C.gpointer(payload)) }()
|
||||
snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live)
|
||||
if completeLive {
|
||||
text := C.cthulhu_live_content(event.source)
|
||||
snapshot.Text = sanitizeSnapshotField(C.GoString(text))
|
||||
snapshot.LiveAtomic = true
|
||||
C.g_free(C.gpointer(payload))
|
||||
payload = text
|
||||
}
|
||||
return snapshot, popupFor, C.GoString(payload)
|
||||
}
|
||||
|
||||
// normalizeNativeMenuFocus applies provider evidence to this event's snapshot.
|
||||
// It borrows target and releases the temporary inline-menu reference locally.
|
||||
func normalizeNativeMenuFocus(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, snapshot *Snapshot) (string, ObjectID) {
|
||||
// Shared Qt actions can report focus through their original, hidden menu
|
||||
// even while another menu emits focus-gained for that very accessible.
|
||||
// The explicit event establishes focus; the queried state may disagree.
|
||||
// Do not transfer this evidence to a redirected descendant or a dead item.
|
||||
if snapshot.ID() == originID && !snapshot.Defunct &&
|
||||
(eventType == FocusEvent || eventType == FocusedStateEvent && C.cthulhu_event_detail1(event) == 1) {
|
||||
switch snapshot.Role {
|
||||
case "menu item", "check menu item", "radio menu item":
|
||||
snapshot.Focused = true
|
||||
}
|
||||
}
|
||||
var inlineMenuID ObjectID
|
||||
if eventType == SelectedStateEvent && C.cthulhu_event_detail1(event) == 1 && !snapshot.Defunct &&
|
||||
(snapshot.Role == "button" || snapshot.Role == "push button" || snapshot.Role == "toggle button" ||
|
||||
snapshot.Role == "menu item" || snapshot.Role == "check menu item" || snapshot.Role == "radio menu item") {
|
||||
if menu := C.cthulhu_inline_menu_container(target); menu != nil {
|
||||
inlineMenuID = nativeObjectID(menu)
|
||||
C.g_object_unref(C.gpointer(menu))
|
||||
if inlineMenuID.Valid() && inlineMenuID.BusName == snapshot.BusName {
|
||||
// The event's detail is authoritative even when libatspi still
|
||||
// caches the previous SELECTED state on the first visit.
|
||||
eventType, snapshot.Selected = ActiveDescendantEvent, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return eventType, inlineMenuID
|
||||
}
|
||||
@@ -2,6 +2,22 @@
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include <gtk/gtk.h>
|
||||
// Shared action IDs for the native callbacks and Go preferences dispatcher.
|
||||
// Value 10 is reserved; preserve the existing wire values.
|
||||
enum {
|
||||
PREFS_TENTACLE_REPOSITORY_ADD = 9,
|
||||
PREFS_TENTACLE_TOGGLE = 11,
|
||||
PREFS_TENTACLE_REPOSITORY_REMOVE = 12,
|
||||
PREFS_TENTACLE_RETRY = 13,
|
||||
PREFS_TENTACLE_REMOVE = 14,
|
||||
PREFS_TENTACLE_REFRESH = 15,
|
||||
PREFS_TENTACLE_SELECT = 16,
|
||||
PREFS_TENTACLE_REPOSITORY_SELECT = 17,
|
||||
PREFS_TENTACLE_REPOSITORY_REFRESH = 18,
|
||||
PREFS_TENTACLE_INSTALL = 19,
|
||||
PREFS_TENTACLE_SETTINGS = 20,
|
||||
PREFS_TENTACLE_UPDATE = 21,
|
||||
};
|
||||
int prefs_init(const char *xml);
|
||||
void prefs_set_application(const char *name);
|
||||
void prefs_iterate(void);
|
||||
|
||||
@@ -22,7 +22,7 @@ static void name_toggle_cells(void) {
|
||||
}
|
||||
static void tentacle_selected(GtkTreeSelection *selection, gpointer data) {
|
||||
(void)selection; (void)data;
|
||||
if (!updating) prefs_set_action(16);
|
||||
if (!updating) prefs_set_action(PREFS_TENTACLE_SELECT);
|
||||
}
|
||||
static void tentacle_clicked(GtkButton *button, gpointer data) {
|
||||
(void)button;
|
||||
@@ -36,12 +36,12 @@ static void tentacle_toggled(GtkCellRendererToggle *renderer, gchar *pathString,
|
||||
gtk_tree_path_free(path);
|
||||
// The accepted backend snapshot owns the check state; never check
|
||||
// optimistically before persistence or after an operation has failed.
|
||||
prefs_set_action(11);
|
||||
prefs_set_action(PREFS_TENTACLE_TOGGLE);
|
||||
}
|
||||
static gboolean tentacle_key(GtkWidget *widget, GdkEventKey *event, gpointer data) {
|
||||
(void)widget; (void)data;
|
||||
if (event->keyval != GDK_KEY_space && event->keyval != GDK_KEY_KP_Space) return FALSE;
|
||||
prefs_set_action(11);
|
||||
prefs_set_action(PREFS_TENTACLE_TOGGLE);
|
||||
return TRUE;
|
||||
}
|
||||
static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint index, gpointer data) {
|
||||
@@ -54,7 +54,7 @@ static void repository_switched(GtkNotebook *notebook, GtkWidget *page, guint in
|
||||
gtk_container_add(GTK_CONTAINER(page), scroll);
|
||||
g_object_unref(scroll);
|
||||
}
|
||||
if (!updating) prefs_set_action(17);
|
||||
if (!updating) prefs_set_action(PREFS_TENTACLE_REPOSITORY_SELECT);
|
||||
}
|
||||
void prefs_init_tentacles(void) {
|
||||
updating = FALSE;
|
||||
@@ -74,9 +74,15 @@ void prefs_init_tentacles(void) {
|
||||
g_signal_connect(tree, "key-press-event", G_CALLBACK(tentacle_key), NULL);
|
||||
g_signal_connect(prefs_widget("tentacleRepositories"), "switch-page", G_CALLBACK(repository_switched), NULL);
|
||||
const struct { const char *id; int action; } buttons[] = {
|
||||
{"tentacleNew", 9}, {"tentacleRetry", 13}, {"tentacleRemove", 14},
|
||||
{"tentacleRefresh", 15}, {"tentacleRepositoryRemove", 12}, {"tentacleRepositoryRefresh", 18},
|
||||
{"tentacleInstall", 19}, {"tentacleUpdate", 21}, {"tentacleSettings", 20},
|
||||
{"tentacleNew", PREFS_TENTACLE_REPOSITORY_ADD},
|
||||
{"tentacleRetry", PREFS_TENTACLE_RETRY},
|
||||
{"tentacleRemove", PREFS_TENTACLE_REMOVE},
|
||||
{"tentacleRefresh", PREFS_TENTACLE_REFRESH},
|
||||
{"tentacleRepositoryRemove", PREFS_TENTACLE_REPOSITORY_REMOVE},
|
||||
{"tentacleRepositoryRefresh", PREFS_TENTACLE_REPOSITORY_REFRESH},
|
||||
{"tentacleInstall", PREFS_TENTACLE_INSTALL},
|
||||
{"tentacleUpdate", PREFS_TENTACLE_UPDATE},
|
||||
{"tentacleSettings", PREFS_TENTACLE_SETTINGS},
|
||||
};
|
||||
for (unsigned i = 0; i < G_N_ELEMENTS(buttons); i++)
|
||||
g_signal_connect(prefs_widget(buttons[i].id), "clicked", G_CALLBACK(tentacle_clicked), GINT_TO_POINTER(buttons[i].action));
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package preferences
|
||||
|
||||
/*
|
||||
#include <stdlib.h>
|
||||
#include "native.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unsafe"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error) {
|
||||
if action == C.PREFS_TENTACLE_SELECT {
|
||||
if selected := page.selected(); selected != nil {
|
||||
page.selectedKey = selected.key
|
||||
}
|
||||
page.controls()
|
||||
return nil, nil
|
||||
}
|
||||
if action == C.PREFS_TENTACLE_REPOSITORY_SELECT {
|
||||
page.render()
|
||||
return nil, nil
|
||||
}
|
||||
if action == C.PREFS_TENTACLE_REFRESH {
|
||||
return &tentacle.ManagementRequest{Action: "list"}, nil
|
||||
}
|
||||
if page.snapshot == nil || page.snapshot.Suppressed || page.snapshot.Message != "" {
|
||||
message := "Tentacle management is unavailable for this run."
|
||||
if page.snapshot != nil && page.snapshot.Message != "" {
|
||||
message = page.snapshot.Message
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
if action == C.PREFS_TENTACLE_REPOSITORY_ADD {
|
||||
id := C.CString("tentacleRepositoryURL")
|
||||
origin, err := tentacle.RepositoryURL(C.GoString(C.prefs_entry_get(id)))
|
||||
C.free(unsafe.Pointer(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: "repository-add", URL: origin}, nil
|
||||
}
|
||||
if action == C.PREFS_TENTACLE_REPOSITORY_REMOVE || action == C.PREFS_TENTACLE_REPOSITORY_REFRESH {
|
||||
origin := page.origin()
|
||||
if origin == "" {
|
||||
return nil, errors.New("Select a repository tab first.")
|
||||
}
|
||||
operation := "repository-remove"
|
||||
if action == C.PREFS_TENTACLE_REPOSITORY_REFRESH {
|
||||
operation = "repository-refresh"
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: operation, URL: origin}, nil
|
||||
}
|
||||
selected := page.selected()
|
||||
if selected == nil {
|
||||
return nil, errors.New("Select a tentacle first.")
|
||||
}
|
||||
page.selectedKey = selected.key
|
||||
if action == C.PREFS_TENTACLE_UPDATE {
|
||||
return page.updatePackage(selected)
|
||||
}
|
||||
if !selected.installed {
|
||||
return page.installPackage(selected, action)
|
||||
}
|
||||
operation := ""
|
||||
switch action {
|
||||
case C.PREFS_TENTACLE_TOGGLE:
|
||||
operation = "enable"
|
||||
if selected.status.Entry.Enabled {
|
||||
operation = "disable"
|
||||
}
|
||||
case C.PREFS_TENTACLE_RETRY:
|
||||
operation = "retry"
|
||||
case C.PREFS_TENTACLE_REMOVE:
|
||||
operation = "remove"
|
||||
case C.PREFS_TENTACLE_SETTINGS:
|
||||
operation = "settings"
|
||||
default:
|
||||
return nil, errors.New("Unknown tentacle action.")
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: operation, ID: selected.status.Entry.ID}, nil
|
||||
}
|
||||
|
||||
func (page *tentaclePage) updatePackage(selected *tentacleRow) (*tentacle.ManagementRequest, error) {
|
||||
if !selected.installed {
|
||||
return nil, errors.New("Install this tentacle first.")
|
||||
}
|
||||
entry := selected.status.Entry
|
||||
for _, update := range page.snapshot.Updates {
|
||||
if update.ID != entry.ID {
|
||||
continue
|
||||
}
|
||||
manifest := findTentacleManifest(page.snapshot, update.RepositoryURL, update.RepositoryID, update.LocalID)
|
||||
if manifest == nil {
|
||||
return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.")
|
||||
}
|
||||
message := tentacleUpdateConfirmation(entry, update, *manifest)
|
||||
if !confirmTentaclePackage(message, "Update") {
|
||||
return nil, nil
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil
|
||||
}
|
||||
return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.")
|
||||
}
|
||||
|
||||
func (page *tentaclePage) installPackage(selected *tentacleRow, action int) (*tentacle.ManagementRequest, error) {
|
||||
if action != C.PREFS_TENTACLE_TOGGLE && action != C.PREFS_TENTACLE_INSTALL {
|
||||
return nil, errors.New("Install this tentacle first.")
|
||||
}
|
||||
entry := selected.status.Entry
|
||||
manifest := findTentacleManifest(page.snapshot, entry.Repository, entry.RepositoryID, entry.LocalID)
|
||||
if manifest == nil || len(manifest.Releases) == 0 {
|
||||
return nil, errors.New("No package has been published for this tentacle. Refresh the repository after a release is published.")
|
||||
}
|
||||
operation, label := "install", "Install"
|
||||
if action == C.PREFS_TENTACLE_TOGGLE {
|
||||
operation, label = "install-enable", "Install and enable"
|
||||
}
|
||||
message := tentacleInstallConfirmation(entry, *manifest, label)
|
||||
if !confirmTentaclePackage(message, label) {
|
||||
return nil, nil
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: operation, ID: entry.LocalID, URL: entry.Repository}, nil
|
||||
}
|
||||
|
||||
func confirmTentaclePackage(message, label string) bool {
|
||||
text, button := C.CString(message), C.CString(label)
|
||||
defer C.free(unsafe.Pointer(text))
|
||||
defer C.free(unsafe.Pointer(button))
|
||||
return C.prefs_tentacle_confirm(text, button) != 0
|
||||
}
|
||||
|
||||
// Snapshot repositories have unique identities and local package IDs.
|
||||
func findTentacleManifest(snapshot *tentacle.Snapshot, origin, repositoryID, localID string) *tentacle.Manifest {
|
||||
for _, repository := range snapshot.Repositories {
|
||||
if repository.URL != origin || repository.ID != repositoryID {
|
||||
continue
|
||||
}
|
||||
for index := range repository.Tentacles {
|
||||
if repository.Tentacles[index].ID == localID {
|
||||
return &repository.Tentacles[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func tentacleUpdateCapabilities(entry tentacle.Entry, manifest tentacle.Manifest) string {
|
||||
var previous []string
|
||||
if entry.Presentation {
|
||||
previous = append(previous, "presentation")
|
||||
}
|
||||
if entry.Interaction {
|
||||
previous = append(previous, "interaction")
|
||||
}
|
||||
if entry.ReaderSettings {
|
||||
previous = append(previous, "reader-settings")
|
||||
}
|
||||
if len(previous) == 0 {
|
||||
previous = append(previous, "none")
|
||||
}
|
||||
declared := manifest.Capabilities
|
||||
if len(declared) == 0 {
|
||||
declared = []string{"none"}
|
||||
}
|
||||
return "Current capabilities: " + strings.Join(previous, ", ") +
|
||||
". Repository declares: " + strings.Join(declared, ", ") + "."
|
||||
}
|
||||
|
||||
func tentacleInstallConfirmation(entry tentacle.Entry, manifest tentacle.Manifest, label string) string {
|
||||
permissions := "This tentacle can register reader commands."
|
||||
if slices.Contains(manifest.Capabilities, "presentation") {
|
||||
permissions = "This tentacle can register reader commands and send speech and braille messages."
|
||||
}
|
||||
if slices.Contains(manifest.Capabilities, "interaction") {
|
||||
permissions = "This tentacle can register reader commands, send speech and braille messages, open internal forms, read the focused item and clipboard when invoked, and write clipboard text."
|
||||
}
|
||||
if slices.Contains(manifest.Capabilities, "reader-settings") {
|
||||
permissions += " It can also add preferences pages and quick settings, inspect speech voices, and temporarily change the reader voice configuration."
|
||||
}
|
||||
return fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions)
|
||||
}
|
||||
|
||||
func tentacleUpdateConfirmation(entry tentacle.Entry, update tentacle.AvailableUpdate, manifest tentacle.Manifest) string {
|
||||
permissions := tentacleUpdateCapabilities(entry, manifest)
|
||||
return fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func TestTentacleUpdateConfirmationDescribesPermissionChanges(t *testing.T) {
|
||||
entry := tentacle.Entry{Presentation: true}
|
||||
update := tentacle.AvailableUpdate{Name: "Example", InstalledVersion: "1.0", AvailableVersion: "1.1"}
|
||||
manifest := tentacle.Manifest{Capabilities: []string{"presentation", "interaction", "reader-settings"}}
|
||||
message := tentacleUpdateConfirmation(entry, update, manifest)
|
||||
for _, want := range []string{
|
||||
"Update Example from version 1.0 to version 1.1?",
|
||||
"Current capabilities: presentation.",
|
||||
"Repository declares: presentation, interaction, reader-settings.",
|
||||
"Changes take effect immediately.",
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("confirmation missing %q: %s", want, message)
|
||||
}
|
||||
}
|
||||
message = tentacleUpdateConfirmation(tentacle.Entry{}, update, tentacle.Manifest{})
|
||||
if !strings.Contains(message, "Current capabilities: none. Repository declares: none.") {
|
||||
t.Fatalf("empty capability sets were not described: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTentacleInstallConfirmationDescribesInteractionAndSettings(t *testing.T) {
|
||||
entry := tentacle.Entry{Name: "Example", Version: "1.1", Repository: "https://example.org/tentacles"}
|
||||
manifest := tentacle.Manifest{Capabilities: []string{"presentation", "interaction", "reader-settings"}}
|
||||
message := tentacleInstallConfirmation(entry, manifest, "Install and enable")
|
||||
for _, want := range []string{
|
||||
"Install and enable Example version 1.1 from https://example.org/tentacles?",
|
||||
"read the focused item and clipboard when invoked",
|
||||
"temporarily change the reader voice configuration",
|
||||
"resource limits do not protect your files or network",
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("confirmation missing %q: %s", want, message)
|
||||
}
|
||||
}
|
||||
message = tentacleInstallConfirmation(entry, tentacle.Manifest{}, "Install")
|
||||
if !strings.Contains(message, "This tentacle can register reader commands.") || strings.Contains(message, "write clipboard text") {
|
||||
t.Fatalf("command-only installation described incorrectly: %s", message)
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,7 @@ package preferences
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
@@ -165,171 +162,6 @@ func (page *tentaclePage) populate(snapshot *tentacle.Snapshot) {
|
||||
page.render()
|
||||
}
|
||||
|
||||
func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error) {
|
||||
if action == 16 {
|
||||
if selected := page.selected(); selected != nil {
|
||||
page.selectedKey = selected.key
|
||||
}
|
||||
page.controls()
|
||||
return nil, nil
|
||||
}
|
||||
if action == 17 {
|
||||
page.render()
|
||||
return nil, nil
|
||||
}
|
||||
if action == 15 {
|
||||
return &tentacle.ManagementRequest{Action: "list"}, nil
|
||||
}
|
||||
if page.snapshot == nil || page.snapshot.Suppressed || page.snapshot.Message != "" {
|
||||
message := "Tentacle management is unavailable for this run."
|
||||
if page.snapshot != nil && page.snapshot.Message != "" {
|
||||
message = page.snapshot.Message
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
if action == 9 {
|
||||
id := C.CString("tentacleRepositoryURL")
|
||||
origin, err := tentacle.RepositoryURL(C.GoString(C.prefs_entry_get(id)))
|
||||
C.free(unsafe.Pointer(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: "repository-add", URL: origin}, nil
|
||||
}
|
||||
if action == 12 || action == 18 {
|
||||
origin := page.origin()
|
||||
if origin == "" {
|
||||
return nil, errors.New("Select a repository tab first.")
|
||||
}
|
||||
operation := "repository-remove"
|
||||
if action == 18 {
|
||||
operation = "repository-refresh"
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: operation, URL: origin}, nil
|
||||
}
|
||||
selected := page.selected()
|
||||
if selected == nil {
|
||||
return nil, errors.New("Select a tentacle first.")
|
||||
}
|
||||
page.selectedKey = selected.key
|
||||
if action == 21 {
|
||||
if !selected.installed {
|
||||
return nil, errors.New("Install this tentacle first.")
|
||||
}
|
||||
entry := selected.status.Entry
|
||||
for _, update := range page.snapshot.Updates {
|
||||
if update.ID != entry.ID {
|
||||
continue
|
||||
}
|
||||
permissions := ""
|
||||
foundManifest := false
|
||||
for _, repository := range page.snapshot.Repositories {
|
||||
if repository.URL != update.RepositoryURL || repository.ID != update.RepositoryID {
|
||||
continue
|
||||
}
|
||||
for _, manifest := range repository.Tentacles {
|
||||
if manifest.ID != update.LocalID {
|
||||
continue
|
||||
}
|
||||
foundManifest = true
|
||||
var previous []string
|
||||
if entry.Presentation {
|
||||
previous = append(previous, "presentation")
|
||||
}
|
||||
if entry.Interaction {
|
||||
previous = append(previous, "interaction")
|
||||
}
|
||||
if entry.ReaderSettings {
|
||||
previous = append(previous, "reader-settings")
|
||||
}
|
||||
if len(previous) == 0 {
|
||||
previous = append(previous, "none")
|
||||
}
|
||||
declared := manifest.Capabilities
|
||||
if len(declared) == 0 {
|
||||
declared = []string{"none"}
|
||||
}
|
||||
permissions = "Current capabilities: " + strings.Join(previous, ", ") +
|
||||
". Repository declares: " + strings.Join(declared, ", ") + "."
|
||||
}
|
||||
}
|
||||
if !foundManifest {
|
||||
return nil, errors.New("Updated package manifest is unavailable; refresh the repository before upgrading.")
|
||||
}
|
||||
message := fmt.Sprintf("Update %s from version %s to version %s?\n\n%s\n\nOnly update packages from publishers you trust. Tentacles run with your account's access.\n\nChanges take effect immediately.", update.Name, update.InstalledVersion, update.AvailableVersion, permissions)
|
||||
text, button := C.CString(message), C.CString("Update")
|
||||
accepted := C.prefs_tentacle_confirm(text, button)
|
||||
C.free(unsafe.Pointer(text))
|
||||
C.free(unsafe.Pointer(button))
|
||||
if accepted == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: "upgrade", ID: update.LocalID, URL: update.RepositoryURL}, nil
|
||||
}
|
||||
return nil, errors.New("No newer version is available for the selected tentacle. Refresh its repository to check again.")
|
||||
}
|
||||
if !selected.installed {
|
||||
if action != 11 && action != 19 {
|
||||
return nil, errors.New("Install this tentacle first.")
|
||||
}
|
||||
entry := selected.status.Entry
|
||||
var manifest *tentacle.Manifest
|
||||
for _, repository := range page.snapshot.Repositories {
|
||||
if repository.URL == entry.Repository && repository.ID == entry.RepositoryID {
|
||||
for index := range repository.Tentacles {
|
||||
if repository.Tentacles[index].ID == entry.LocalID {
|
||||
manifest = &repository.Tentacles[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if manifest == nil || len(manifest.Releases) == 0 {
|
||||
return nil, errors.New("No package has been published for this tentacle. Refresh the repository after a release is published.")
|
||||
}
|
||||
permissions := "This tentacle can register reader commands."
|
||||
if slices.Contains(manifest.Capabilities, "presentation") {
|
||||
permissions = "This tentacle can register reader commands and send speech and braille messages."
|
||||
}
|
||||
if slices.Contains(manifest.Capabilities, "interaction") {
|
||||
permissions = "This tentacle can register reader commands, send speech and braille messages, open internal forms, read the focused item and clipboard when invoked, and write clipboard text."
|
||||
}
|
||||
if slices.Contains(manifest.Capabilities, "reader-settings") {
|
||||
permissions += " It can also add preferences pages and quick settings, inspect speech voices, and temporarily change the reader voice configuration."
|
||||
}
|
||||
operation, label := "install", "Install"
|
||||
if action == 11 {
|
||||
operation, label = "install-enable", "Install and enable"
|
||||
}
|
||||
message := fmt.Sprintf("%s %s version %s from %s?\n\n%s\n\nOnly install packages from publishers you trust. Tentacles run with your account's access; resource limits do not protect your files or network.\n\nTentacle management changes take effect immediately.", label, entry.Name, entry.Version, entry.Repository, permissions)
|
||||
text, button := C.CString(message), C.CString(label)
|
||||
accepted := C.prefs_tentacle_confirm(text, button)
|
||||
C.free(unsafe.Pointer(text))
|
||||
C.free(unsafe.Pointer(button))
|
||||
if accepted == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: operation, ID: entry.LocalID, URL: entry.Repository}, nil
|
||||
}
|
||||
operation := ""
|
||||
switch action {
|
||||
case 11:
|
||||
operation = "enable"
|
||||
if selected.status.Entry.Enabled {
|
||||
operation = "disable"
|
||||
}
|
||||
case 13:
|
||||
operation = "retry"
|
||||
case 14:
|
||||
operation = "remove"
|
||||
case 20:
|
||||
operation = "settings"
|
||||
default:
|
||||
return nil, errors.New("Unknown tentacle action.")
|
||||
}
|
||||
return &tentacle.ManagementRequest{Action: operation, ID: selected.status.Entry.ID}, nil
|
||||
}
|
||||
|
||||
func (page *tentaclePage) accepted(request *tentacle.ManagementRequest) {
|
||||
if request.Action == "repository-add" {
|
||||
id, empty := C.CString("tentacleRepositoryURL"), C.CString("")
|
||||
|
||||
@@ -145,7 +145,7 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
||||
session.refreshSpeechVoices()
|
||||
session.refreshKeyBindingLayout()
|
||||
action := C.prefs_action()
|
||||
if action >= 9 && action <= 21 && !busy {
|
||||
if action >= C.PREFS_TENTACLE_REPOSITORY_ADD && action <= C.PREFS_TENTACLE_UPDATE && !busy {
|
||||
request, err := tentacles.action(int(action))
|
||||
if err != nil {
|
||||
status(err.Error(), true)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package wine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// A helperSet is a launch-local snapshot of the complete installed helper.
|
||||
// Publishing and verification use the same bytes and modes that form its digest.
|
||||
type helperSet struct {
|
||||
files []helperFile
|
||||
digest string
|
||||
}
|
||||
|
||||
type helperFile struct {
|
||||
name string
|
||||
data []byte
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
// A pressure-vessel wineserver cannot see the host's /usr. Publish complete,
|
||||
// immutable helper sets in the home-mounted state directory. Never overwrite
|
||||
// an older set: running processes may still have its sidecar mapped.
|
||||
func stage_helper(helper string, environment map[string]string) (string, error) {
|
||||
if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present {
|
||||
return helper, nil
|
||||
}
|
||||
stateHome := os.Getenv("XDG_STATE_HOME")
|
||||
if stateHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stateHome = filepath.Join(home, ".local", "state")
|
||||
}
|
||||
if !filepath.IsAbs(stateHome) {
|
||||
return "", fmt.Errorf("Wine helper state directory must be absolute")
|
||||
}
|
||||
set, err := readHelperSet(helper)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stage, err := set.publish(filepath.Join(stateHome, "cthulhu", "wine-access", "helper"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(stage, filepath.Base(helper)), nil
|
||||
}
|
||||
|
||||
func readHelperSet(helper string) (*helperSet, error) {
|
||||
names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"}
|
||||
set := &helperSet{}
|
||||
digest := sha256.New()
|
||||
for _, name := range names {
|
||||
path := filepath.Join(filepath.Dir(helper), name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read helper set: %w", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := helperFile{name: name, data: data, mode: 0600}
|
||||
if info.Mode().Perm()&0111 != 0 {
|
||||
file.mode = 0700
|
||||
}
|
||||
fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), file.mode)
|
||||
digest.Write(data)
|
||||
set.files = append(set.files, file)
|
||||
}
|
||||
set.digest = fmt.Sprintf("%x", digest.Sum(nil))
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func (set *helperSet) publish(root string) (string, error) {
|
||||
if err := os.MkdirAll(root, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stage := filepath.Join(root, set.digest)
|
||||
if _, err := os.Stat(stage); os.IsNotExist(err) {
|
||||
temporary, err := os.MkdirTemp(root, ".stage-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
for _, file := range set.files {
|
||||
if err := os.WriteFile(filepath.Join(temporary, file.name), file.data, file.mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(temporary, stage); err != nil {
|
||||
if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := set.verify(stage); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stage, nil
|
||||
}
|
||||
|
||||
func (set *helperSet) verify(stage string) error {
|
||||
for _, file := range set.files {
|
||||
path := filepath.Join(stage, file.name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(data, file.data) || info.Mode().Perm() != file.mode {
|
||||
return fmt.Errorf("incomplete or modified staged Wine helper")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package wine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -83,81 +81,3 @@ func (manager *Manager) helper_arguments(controllerOnly bool) []string {
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
// A pressure-vessel wineserver cannot see the host's /usr. Publish complete,
|
||||
// immutable helper sets in the home-mounted state directory. Never overwrite
|
||||
// an older set: running processes may still have its sidecar mapped.
|
||||
func stage_helper(helper string, environment map[string]string) (string, error) {
|
||||
if _, present := environment["PRESSURE_VESSEL_RUNTIME"]; !present {
|
||||
return helper, nil
|
||||
}
|
||||
stateHome := os.Getenv("XDG_STATE_HOME")
|
||||
if stateHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stateHome = filepath.Join(home, ".local", "state")
|
||||
}
|
||||
if !filepath.IsAbs(stateHome) {
|
||||
return "", fmt.Errorf("Wine helper state directory must be absolute")
|
||||
}
|
||||
names := []string{filepath.Base(helper), filepath.Base(helper) + ".so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"}
|
||||
contents := make(map[string][]byte)
|
||||
modes := make(map[string]os.FileMode)
|
||||
digest := sha256.New()
|
||||
for _, name := range names {
|
||||
path := filepath.Join(filepath.Dir(helper), name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read helper set: %w", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
contents[name], modes[name] = data, 0600
|
||||
if info.Mode().Perm()&0111 != 0 {
|
||||
modes[name] = 0700
|
||||
}
|
||||
fmt.Fprintf(digest, "%s\x00%d\x00%d\x00", name, len(data), modes[name])
|
||||
digest.Write(data)
|
||||
}
|
||||
root := filepath.Join(stateHome, "cthulhu", "wine-access", "helper")
|
||||
if err := os.MkdirAll(root, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stage := filepath.Join(root, fmt.Sprintf("%x", digest.Sum(nil)))
|
||||
if _, err := os.Stat(stage); os.IsNotExist(err) {
|
||||
temporary, err := os.MkdirTemp(root, ".stage-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(temporary)
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(temporary, name), contents[name], modes[name]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(temporary, stage); err != nil {
|
||||
if info, statErr := os.Stat(stage); statErr != nil || !info.IsDir() {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
path := filepath.Join(stage, name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !bytes.Equal(data, contents[name]) || info.Mode().Perm() != modes[name] {
|
||||
return "", fmt.Errorf("incomplete or modified staged Wine helper")
|
||||
}
|
||||
}
|
||||
return filepath.Join(stage, filepath.Base(helper)), nil
|
||||
}
|
||||
Reference in new issue
Block a user