Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512b4413f2 | ||
|
|
2f8fb66803 | ||
|
|
d0fdce7285 | ||
|
|
7591f65b58 | ||
|
|
f2cc47c8a0 | ||
|
|
27867a48cb | ||
|
|
03cf063d60 |
No files matched your search
@@ -14,3 +14,5 @@ __pycache__/
|
||||
/distro-packages/**/*.pkg.tar.zst
|
||||
/distro-packages/**/*.pkg.tar.xz
|
||||
/distro-packages/Arch-Linux/cthulhu-git/*/
|
||||
|
||||
/cmd/cthulhu/cthulhu
|
||||
@@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go.
|
||||
- [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification.
|
||||
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
|
||||
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
|
||||
- [XTerm handoff](docs/testing/xterm-handoff.md): terminal reader keys, lock state, speech interruption, and verification.
|
||||
- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification.
|
||||
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
|
||||
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
|
||||
@@ -15,6 +16,7 @@ Cthulhu is a Linux screen reader written in Go.
|
||||
- [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification.
|
||||
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
|
||||
- [Tentacle reader settings API](docs/tentacle-reader-settings.md): contributed preferences pages, quick settings, speech overrides, and verification.
|
||||
- [Tentacle updates](docs/testing/tentacle-updates.md): package recovery, version comparison, and update verification.
|
||||
|
||||
AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions).
|
||||
|
||||
@@ -314,6 +316,8 @@ allows a successful virtual reading move.
|
||||
## Wine Access
|
||||
|
||||
Install the separate `wine-access` helper for Wine application accessibility.
|
||||
See [Steam/Proton and controller-only verification](docs/testing/wine-proton.md)
|
||||
for loader support, container staging, hook exclusions, and live regression checks.
|
||||
Cthulhu checks `CTHULHU_WINE_ACCESS_HELPER`, then
|
||||
`~/.local/libexec/wine-access/wine-access.exe`,
|
||||
`/usr/local/libexec/wine-access/wine-access.exe`,
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
|
||||
pkgname=cthulhu
|
||||
pkgver=2026.10.31
|
||||
pkgrel=3
|
||||
pkgrel=4
|
||||
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
|
||||
url="https://git.stormux.org/storm/cthulhu"
|
||||
arch=(x86_64 aarch64 armv7h)
|
||||
|
||||
@@ -9,7 +9,7 @@ the same, with a Braille flash through the system-role output queue.
|
||||
The motivating provider is the Niri compositor (25.08+), which exposes its
|
||||
own UI through AccessKit. A capture from Niri 26.04 on 2026-10-31 shows a
|
||||
workspace switch emits `object:property-change:accessible-name` and then
|
||||
`object:announcement` (polite) with text such as `Workspace 2 Mistworld` on
|
||||
`object:announcement` (polite) with text such as `Workspace 2 Example` on
|
||||
a label in the `niri` application, about 10 ms before the newly focused
|
||||
window activates. Niri alternates live-node identities (and may append a
|
||||
space) so repeated messages still emit; Cthulhu trims the payload and
|
||||
@@ -18,7 +18,8 @@ dedupes one provider's repeat inside 300 ms.
|
||||
Announcements bypass background-event filtering and foreground epochs: the
|
||||
provider explicitly requested speech and may never own focus. They do not
|
||||
move focus, review, or reading state. Screen-lock and secure-input gates
|
||||
still suppress them. Assertive announcements interrupt; polite ones append.
|
||||
still suppress them. Both polite and assertive announcements queue under Cthulhu's input-driven
|
||||
interruption policy. Politeness metadata is preserved by the adapter.
|
||||
Payload text is bounded by the adapter's existing snapshot sanitization.
|
||||
|
||||
## Automated checks
|
||||
@@ -53,8 +54,9 @@ Report automated, audible-speech, and physical-Braille results separately.
|
||||
2. Switch between named, unnamed, and the trailing empty workspace with
|
||||
Niri's own binds. Each switch speaks the workspace label once, before
|
||||
the newly focused window, and focus presentation still follows.
|
||||
3. Burst-switch several times quickly. Only settled announcements speak;
|
||||
no duplicates from the paired accessible-name change.
|
||||
3. Burst-switch several times quickly. Announcements queue in arrival order;
|
||||
fresh keyboard input may cancel earlier output. No duplicates should come
|
||||
from the paired accessible-name change.
|
||||
4. Cycle the AltTab switcher and open the exit dialog (`Super+Shift+E`)
|
||||
and the hotkeys list (`Super+Shift+/`). Selected windows and dialog
|
||||
text are announced as with Orca on the same session.
|
||||
|
||||
@@ -47,3 +47,31 @@ check pitch and volume with the quick speech keys, then save and reopen the
|
||||
settings. Values such as 4.5 must remain 4.5 in announcements and TOML rather
|
||||
than exposing floating-point tails. Check all voice types and custom key echo;
|
||||
untouched inherited values must remain unset.
|
||||
|
||||
## Preferences focus with differing window bounds
|
||||
|
||||
On Slint/i3, GTK can report a frame at (0,0), sized 1916×994, while X11
|
||||
reports its client at (2,18), sized 1912×974. Rejecting that frame by geometry
|
||||
silences control announcements even though Tab still moves focus. The reader
|
||||
registers the PID of its own preferences helper until the helper exits. That
|
||||
helper can bypass the geometry comparison when X11 identifies the same
|
||||
foreground process and GTK reports an active, showing, non-iconified window.
|
||||
Other applications retain the normal foreground correlation checks.
|
||||
|
||||
The adapter dropdown regression also checks that preferences activation and
|
||||
focused-control events are accepted:
|
||||
|
||||
```sh
|
||||
go test ./internal/atspi ./internal/app -count=1
|
||||
CTHULHU_BINARY=/tmp/cthulhu-speech-preferences CTHULHU_PREFS_ADAPTER_ONLY=1 python3 tests/native_preferences.py
|
||||
```
|
||||
|
||||
On the affected Slint desktop, use the rebuilt reader to open preferences.
|
||||
Tab and Shift+Tab through controls, switch notebook pages with Ctrl+PageUp and
|
||||
Ctrl+PageDown, open a dropdown, change its selection, and close it with Escape.
|
||||
Confirm each control's name and value is announced. Apply a change, close and
|
||||
reopen preferences, and confirm it persists. Switch to another application
|
||||
while preferences remains open: speech and Braille must follow that application,
|
||||
then resume preferences announcements when returning. Repeat after closing and
|
||||
reopening preferences and after changing window size. Private X11 tests do not
|
||||
establish physical-key, audible-speech or Braille acceptance on Slint.
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
// Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits
|
||||
@@ -40,10 +41,10 @@ func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
|
||||
adapter.events <- announcementEvent("Workspace 2 Example", 1)
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != "Workspace 2 Mistworld" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
|
||||
if request.Text != "Workspace 2 Example" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
|
||||
!request.Append || !request.BypassFocusGate {
|
||||
t.Fatalf("announcement request = %+v", request)
|
||||
}
|
||||
@@ -52,14 +53,14 @@ func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementAssertiveInterrupts(t *testing.T) {
|
||||
func TestAnnouncementAssertiveQueues(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Config parse error", 2)
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != "Config parse error" || request.Append {
|
||||
if request.Text != "Config parse error" || !request.Append {
|
||||
t.Fatalf("assertive request = %+v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
@@ -71,10 +72,10 @@ func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
|
||||
adapter.events <- announcementEvent("Workspace 2 Example", 1)
|
||||
// Niri can append a space to force re-emission of identical text.
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld ", 1)
|
||||
adapter.events <- announcementEvent("Workspace 1 Generic Shit", 1)
|
||||
adapter.events <- announcementEvent("Workspace 2 Example ", 1)
|
||||
adapter.events <- announcementEvent("Workspace 1 Example", 1)
|
||||
|
||||
var got []string
|
||||
for range 2 {
|
||||
@@ -90,7 +91,7 @@ func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
|
||||
t.Fatalf("duplicate announcement spoken: %+v (all: %v)", request, got)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
if got[0] != "Workspace 2 Mistworld" || got[1] != "Workspace 1 Generic Shit" {
|
||||
if got[0] != "Workspace 2 Example" || got[1] != "Workspace 1 Example" {
|
||||
t.Fatalf("announcements = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -106,3 +107,37 @@ func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) {
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementBrailleAndLockGate(t *testing.T) {
|
||||
live := &liveSettings{}
|
||||
live.Store(config.DefaultSettings())
|
||||
speech := &remoteSpeechQueue{}
|
||||
display := &flashRecorder{}
|
||||
gate := newFocusGate(nil)
|
||||
handler := &accessibilityEventHandler{
|
||||
state: &eventLoopState{}, focusGate: gate,
|
||||
speech: gatedSpeech{gate: gate, speech: outputQueue{speech: speech, braille: display, live: live}},
|
||||
}
|
||||
event := announcementEvent("Workspace 2 Example", 1)
|
||||
handler.presentAnnouncement(event)
|
||||
if len(display.messages) != 1 || display.messages[0] != event.Payload {
|
||||
t.Fatalf("Braille flashes = %v", display.messages)
|
||||
}
|
||||
gate.secure.Store(true)
|
||||
handler.handle(announcementEvent("Locked workspace", 1))
|
||||
if len(display.messages) != 1 {
|
||||
t.Fatal("locked announcement flashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdenticalAnnouncementFromAnotherProviderIsPreserved(t *testing.T) {
|
||||
speech := &remoteSpeechQueue{}
|
||||
handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech}
|
||||
event := announcementEvent("Example message", 1)
|
||||
handler.presentAnnouncement(event)
|
||||
event.Source.BusName = ":1.21"
|
||||
handler.presentAnnouncement(event)
|
||||
if len(speech.requests) != 2 {
|
||||
t.Fatalf("requests = %d, want 2", len(speech.requests))
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,7 @@ func Run(ctx context.Context, options Options) error {
|
||||
preferences.speechWorker = speechMessages
|
||||
preferences.braille = brailleOutput
|
||||
preferences.documentReview = objectReview
|
||||
preferences.preferencesProcess, _ = adapter.(interface{ SetPreferencesProcess(int) })
|
||||
objectReview.SetDocumentBrailleEnabled(settings.EnableBraille)
|
||||
if updater, ok := sounds.(soundSettingsUpdater); ok {
|
||||
preferences.sound = updater
|
||||
|
||||
@@ -12,12 +12,9 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// AtspiLive assertive value carried in an announcement event's Detail1.
|
||||
const announcementAssertive = 2
|
||||
|
||||
// presentAnnouncement speaks an object:announcement payload, mirroring Orca:
|
||||
// the provider's text is presented verbatim, assertive announcements
|
||||
// interrupt, and polite ones append. Announcements commonly come from
|
||||
// the provider's text is presented verbatim. All provider announcements queue
|
||||
// under the reader's input-driven interruption policy. Announcements commonly come from
|
||||
// providers that never own focus (a compositor announcing a workspace
|
||||
// switch, for example), so no focus or foreground ownership is required and
|
||||
// none is taken. The caller's lock gate has already run; the speech output
|
||||
@@ -40,7 +37,7 @@ func (handler *accessibilityEventHandler) presentAnnouncement(event atspi.Event)
|
||||
Text: text,
|
||||
Role: "system",
|
||||
Cause: atspi.AnnouncementEvent,
|
||||
Append: event.Detail1 != announcementAssertive,
|
||||
Append: true,
|
||||
BypassFocusGate: true,
|
||||
})
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (speaker gatedSpeech) InterruptInternalUI() {
|
||||
}
|
||||
|
||||
func (speaker gatedSpeech) InterruptForUser() {
|
||||
if speaker.gate.Locked() {
|
||||
if speaker.gate.Locked() || speaker.gate.Suspended() {
|
||||
return
|
||||
}
|
||||
speaker.gate.dismiss_confirmation()
|
||||
@@ -139,7 +139,7 @@ func (speaker gatedSpeech) InterruptForUser() {
|
||||
}
|
||||
|
||||
func (speaker gatedSpeech) InterruptForInput(event input.Event) bool {
|
||||
if speaker.gate.InternalUIActive() {
|
||||
if speaker.gate.InternalUIActive() || speaker.gate.Suspended() {
|
||||
return false
|
||||
}
|
||||
if speaker.gate.Locked() {
|
||||
|
||||
@@ -44,7 +44,7 @@ func processInputEvents(
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if guarded, ok := speech.(gatedSpeech); ok && guarded.gate.Locked() {
|
||||
if guarded, ok := speech.(gatedSpeech); ok && (guarded.gate.Locked() || guarded.gate.Suspended()) {
|
||||
echo.Reset()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type preferencesWorker struct {
|
||||
refreshHistory func()
|
||||
sound soundSettingsUpdater
|
||||
geometry soundGeometryCaptureConfigurer
|
||||
preferencesProcess interface{ SetPreferencesProcess(int) }
|
||||
textAttributes textAttributeCaptureConfigurer
|
||||
bindings interface {
|
||||
SetKeyBindings(context.Context, []input.BindingOverride) error
|
||||
|
||||
@@ -69,6 +69,10 @@ func (worker *preferencesWorker) window(ctx context.Context) (result error) {
|
||||
worker.live.preferencesPID.Store(int64(command.Process.Pid))
|
||||
defer worker.live.preferencesPID.Store(0)
|
||||
}
|
||||
if worker.preferencesProcess != nil {
|
||||
worker.preferencesProcess.SetPreferencesProcess(command.Process.Pid)
|
||||
defer worker.preferencesProcess.SetPreferencesProcess(0)
|
||||
}
|
||||
defer func() {
|
||||
stop()
|
||||
in.Close()
|
||||
|
||||
@@ -34,7 +34,7 @@ func (worker *preferencesWorker) manage_tentacle(ctx context.Context, request pr
|
||||
return response
|
||||
}
|
||||
timeout := 6 * time.Second
|
||||
if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") {
|
||||
if strings.HasPrefix(request.Tentacle.Action, "repository-") || strings.HasPrefix(request.Tentacle.Action, "install") || request.Tentacle.Action == "upgrade" {
|
||||
timeout = 50 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
@@ -118,7 +118,7 @@ func (worker commandRuntime) run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
case state := <-commands.LockStates():
|
||||
if focusGate.Locked() {
|
||||
if focusGate.Locked() || focusGate.Suspended() {
|
||||
continue
|
||||
}
|
||||
if speechMessages != nil {
|
||||
|
||||
@@ -131,6 +131,11 @@ func newRemoteRuntimeServices(deps remoteServiceDependencies) []lifecycle.Servic
|
||||
Stop: bridgeServer.Stop,
|
||||
})
|
||||
wineManager := wine.New(deps.logger)
|
||||
if deps.live != nil {
|
||||
settings := deps.live.Load()
|
||||
wineManager.ControllerOnly = settings.Wine.ControllerOnly
|
||||
wineManager.ExcludedPrograms = append([]string(nil), settings.Wine.ExcludedPrograms...)
|
||||
}
|
||||
services = append(services, lifecycle.Service{
|
||||
Name: "Wine accessibility", Start: wineManager.Start, Stop: wineManager.Stop,
|
||||
})
|
||||
|
||||
@@ -43,7 +43,12 @@ func install_fixture(t *testing.T) (tentacle.Repository, []byte) {
|
||||
marker := filepath.Join(t.TempDir(), "called")
|
||||
manifest := fmt.Sprintf("format-version=1\nid='echo'\nname='Echo'\nversion='0.1'\nruntime='native'\nplatforms=['linux']\nentry-point=['bin/echo','-test.run=^TestTentacleReaderPeer$','--','reader-peer',%q]\napis=['commands']\ncapabilities=[]\n[protocol]\nmajor=1\nminor=0\n", marker)
|
||||
var data bytes.Buffer
|
||||
compressed := gzip.NewWriter(&data)
|
||||
// These fixtures test package installation, not compression ratios. This
|
||||
// also keeps repeated update fixtures cheap under the race detector.
|
||||
compressed, err := gzip.NewWriterLevel(&data, gzip.BestSpeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := tar.NewWriter(compressed)
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
|
||||
@@ -30,6 +30,13 @@ 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 {
|
||||
@@ -43,6 +50,7 @@ func (runtime *tentacleRuntime) Snapshot() tentacle.Snapshot {
|
||||
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 {
|
||||
@@ -124,7 +132,7 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
if disabled {
|
||||
return tentacle.ErrDisabled
|
||||
}
|
||||
if request.Action == "install" || request.Action == "install-enable" {
|
||||
if request.Action == "install" || request.Action == "install-enable" || request.Action == "upgrade" {
|
||||
if request.Entry != nil {
|
||||
return errors.New("unexpected tentacle entry in installation request")
|
||||
}
|
||||
@@ -138,9 +146,37 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
}
|
||||
repository := snapshot.Repositories[index]
|
||||
id := tentacle.PackageID(repository, request.ID)
|
||||
if slices.ContainsFunc(snapshot.Entries, func(value tentacle.EntryStatus) bool { return value.Entry.ID == id }) {
|
||||
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()
|
||||
@@ -162,9 +198,15 @@ func (runtime *tentacleRuntime) Manage(ctx context.Context, request tentacle.Man
|
||||
return err
|
||||
}
|
||||
defer prepared.Discard()
|
||||
prepared.Entry.Enabled = request.Action == "install-enable"
|
||||
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: "add", ID: prepared.Entry.ID, Entry: &prepared.Entry}
|
||||
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 != "" {
|
||||
@@ -240,6 +282,7 @@ func change_tentacle_entries(entries []tentacle.Entry, request tentacle.Manageme
|
||||
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":
|
||||
@@ -268,7 +311,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
sessions := make(map[string]*tentacle.Session)
|
||||
problems := make(map[string]string)
|
||||
startups := make(map[string]tentacleStartupCall)
|
||||
removedPackages := make(map[string]tentacle.Entry)
|
||||
removedPackages := make(map[string]tentacleRetiredPackage)
|
||||
for _, entry := range entries {
|
||||
pending[entry.ID] = entry.Enabled
|
||||
}
|
||||
@@ -315,6 +358,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
}
|
||||
result.Entries = append(result.Entries, status)
|
||||
}
|
||||
result.Updates = tentacle.AvailableUpdates(result)
|
||||
runtime.mutex.Lock()
|
||||
runtime.snapshot = result
|
||||
runtime.mutex.Unlock()
|
||||
@@ -338,7 +382,8 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
if manager != nil {
|
||||
managerError = manager.Shutdown(cleanup)
|
||||
}
|
||||
for _, entry := range removedPackages {
|
||||
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)
|
||||
@@ -382,6 +427,11 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
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()
|
||||
@@ -389,7 +439,8 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
}
|
||||
failStartup := func(id, message string) {
|
||||
problems[id] = message
|
||||
if _, exists := startups[id]; !exists {
|
||||
startup, exists := startups[id]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
revoke(id)
|
||||
@@ -400,15 +451,31 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
next := tentacle.CloneEntries(entries)
|
||||
for index := range next {
|
||||
if next[index].ID == id {
|
||||
next[index].Enabled = false
|
||||
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 disabled state could not be saved durably: " + err.Error()
|
||||
message += " The recovery state could not be saved durably: " + err.Error()
|
||||
}
|
||||
replyStartup(id, errors.New(message))
|
||||
}
|
||||
@@ -467,15 +534,26 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
for id, entry := range removedPackages {
|
||||
if sessions[id] != 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, id)
|
||||
delete(removedPackages, directory)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !pending[entry.ID] || sessions[entry.ID] != nil {
|
||||
@@ -572,6 +650,9 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
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
|
||||
@@ -591,15 +672,28 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
}
|
||||
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" {
|
||||
if call.request.Action == "remove" || call.request.Action == "update" {
|
||||
for _, entry := range entries {
|
||||
if entry.ID == id && entry.PackageDirectory != "" {
|
||||
removedPackages[id] = entry
|
||||
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)
|
||||
@@ -619,7 +713,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
runtime.publishCommands(stillActive)
|
||||
setSnapshot()
|
||||
if pending[id] {
|
||||
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify)}
|
||||
startups[id] = tentacleStartupCall{call: call, saveError: err, stop: context.AfterFunc(call.ctx, notify), previous: previous}
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -665,6 +759,7 @@ func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
|
||||
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())
|
||||
|
||||
@@ -404,3 +404,25 @@ func TestManagedTentacleCancellationDuringBindingsRollsBack(t *testing.T) {
|
||||
t.Fatalf("incorrect persisted rollback: %+v %v", disk, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Updating an entry must preserve the user's enabled preference.
|
||||
func TestUpdatePreservesEnabledState(t *testing.T) {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
current := tentacle.Entry{ID: "example", Program: []string{"/bin/true"}, Enabled: enabled, Version: "0.1.3"}
|
||||
replacement := tentacle.Entry{ID: "example", Program: []string{"/bin/true"}, Enabled: !enabled, Version: "0.1.4"}
|
||||
next, err := change_tentacle_entries(
|
||||
[]tentacle.Entry{current},
|
||||
tentacle.ManagementRequest{
|
||||
Action: "update",
|
||||
ID: current.ID,
|
||||
Entry: &replacement,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(next) != 1 || next[0].Enabled != enabled || next[0].Version != "0.1.4" {
|
||||
t.Fatalf("enabled=%v: unexpected result: %+v", enabled, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func TestUpgradePreparationFailurePreservesInstallation(t *testing.T) {
|
||||
repository, data := install_fixture(t)
|
||||
|
||||
set_install_transport(t, func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewReader(data)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
|
||||
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
|
||||
owner, _ := managed_tentacle_fixture(t, registryPath, false)
|
||||
|
||||
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
if err := owner.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manage := func(action string) error {
|
||||
return owner.Manage(context.Background(), tentacle.ManagementRequest{
|
||||
Action: action,
|
||||
URL: repository.URL,
|
||||
ID: func() string {
|
||||
if action == "install" || action == "upgrade" {
|
||||
return "echo"
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := manage("repository-add"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manage("install"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id := tentacle.PackageID(repository, "echo")
|
||||
wait_managed_state(t, owner, id, "disabled")
|
||||
|
||||
before := owner.Snapshot().Entries[0].Entry
|
||||
|
||||
repository.Revision = strings.Repeat("b", 40)
|
||||
repository.Tentacles[0].Version = "0.2"
|
||||
|
||||
if err := manage("repository-refresh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner.preparePackage = func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) {
|
||||
return nil, errors.New("simulated download failure")
|
||||
}
|
||||
|
||||
if err := manage("upgrade"); err == nil {
|
||||
t.Fatal("upgrade unexpectedly succeeded")
|
||||
}
|
||||
|
||||
after := owner.Snapshot().Entries[0].Entry
|
||||
|
||||
if after.Version != before.Version ||
|
||||
after.Enabled != before.Enabled ||
|
||||
after.PackageDirectory != before.PackageDirectory {
|
||||
t.Fatalf("failed upgrade changed installation: before=%+v after=%+v", before, after)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(before.PackageDirectory); err != nil {
|
||||
t.Fatalf("failed upgrade removed installed package: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func prepare_upgrade_fixture(t *testing.T, mode string) (*tentacleRuntime, *tentacleBindingRecorder, tentacle.Repository, tentacle.Entry) {
|
||||
t.Helper()
|
||||
repository, data := install_fixture(t)
|
||||
|
||||
set_install_transport(t, func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
ContentLength: int64(len(data)),
|
||||
Body: io.NopCloser(bytes.NewReader(data)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
|
||||
registryPath := filepath.Join(t.TempDir(), "tentacles.toml")
|
||||
owner, bindings := managed_tentacle_fixture(t, registryPath, false)
|
||||
|
||||
owner.fetchRepository = func(context.Context, string) (tentacle.Repository, error) {
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
if err := owner.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
|
||||
Action: "repository-add", URL: repository.URL,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
|
||||
Action: "install-enable", ID: "echo", URL: repository.URL,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id := tentacle.PackageID(repository, "echo")
|
||||
wait_managed_state(t, owner, id, "active")
|
||||
before := owner.Snapshot().Entries[0].Entry
|
||||
|
||||
// Rebuild the downloaded archive with a matching version 0.2 manifest.
|
||||
var updated bytes.Buffer
|
||||
gzipWriter, err := gzip.NewWriterLevel(&updated, gzip.BestSpeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
tarReader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := tar.NewReader(tarReader)
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if header.Name == "tentacle.toml" {
|
||||
content = bytes.Replace(content, []byte("version='0.1'"), []byte("version='0.2'"), 1)
|
||||
if mode == "stall" {
|
||||
content = bytes.Replace(content, []byte(before.Program[len(before.Program)-1]), []byte(filepath.Join(t.TempDir(), "stall")), 1)
|
||||
}
|
||||
}
|
||||
if header.Name == "bin/echo" && mode == "fail" {
|
||||
content, err = os.ReadFile("/bin/false")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
copyHeader := *header
|
||||
copyHeader.Size = int64(len(content))
|
||||
if err := tarWriter.WriteHeader(©Header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tarReader.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updatedData := updated.Bytes()
|
||||
digest := sha256.Sum256(updatedData)
|
||||
repository.Revision = strings.Repeat("b", 40)
|
||||
repository.Tentacles[0].Version = "0.2"
|
||||
repository.Tentacles[0].Releases = []tentacle.Release{{
|
||||
Platform: "linux",
|
||||
Architecture: runtime.GOARCH,
|
||||
URL: "https://example.org/release-0.2.tar.gz",
|
||||
SHA256: fmt.Sprintf("%x", digest),
|
||||
}}
|
||||
data = updatedData
|
||||
|
||||
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
|
||||
Action: "repository-refresh", URL: repository.URL,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return owner, bindings, repository, before
|
||||
}
|
||||
|
||||
func TestUpgradeReplacesRunningPackageAndCleansOldFiles(t *testing.T) {
|
||||
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
|
||||
id := before.ID
|
||||
|
||||
if err := owner.Manage(context.Background(), tentacle.ManagementRequest{
|
||||
Action: "upgrade", ID: "echo", URL: repository.URL,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wait_managed_state(t, owner, id, "active")
|
||||
after := owner.Snapshot().Entries[0].Entry
|
||||
|
||||
if !after.Enabled {
|
||||
t.Fatal("upgrade disabled previously enabled tentacle")
|
||||
}
|
||||
if after.PackageDirectory == before.PackageDirectory {
|
||||
t.Fatal("upgrade reused old package directory")
|
||||
}
|
||||
|
||||
waitTentacle(t, func() bool {
|
||||
_, err := os.Stat(before.PackageDirectory)
|
||||
return os.IsNotExist(err)
|
||||
})
|
||||
|
||||
if _, err := os.Stat(after.PackageDirectory); err != nil {
|
||||
t.Fatalf("replacement package missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeFailureAndCancellationRestorePreviousPackage(t *testing.T) {
|
||||
for _, operation := range []string{"fail", "cancel", "disable", "remove", "shutdown"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
mode := "stall"
|
||||
if operation == "fail" {
|
||||
mode = "fail"
|
||||
}
|
||||
owner, bindings, repository, before := prepare_upgrade_fixture(t, mode)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
|
||||
}()
|
||||
if operation != "fail" {
|
||||
waitTentacle(t, func() bool {
|
||||
snapshot := owner.Snapshot()
|
||||
return len(snapshot.Entries) == 1 && snapshot.Entries[0].Entry.Version == "0.2" && snapshot.Entries[0].State == "starting"
|
||||
})
|
||||
if _, err := os.Stat(before.PackageDirectory); err != nil {
|
||||
t.Fatalf("old package lost during startup: %v", err)
|
||||
}
|
||||
switch operation {
|
||||
case "cancel":
|
||||
cancel()
|
||||
case "disable", "remove":
|
||||
manage_tentacle(t, owner, operation, before.ID, nil)
|
||||
case "shutdown":
|
||||
if err := owner.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil {
|
||||
t.Fatal("failed or cancelled update reported success")
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("update did not finish")
|
||||
}
|
||||
if operation == "fail" || operation == "cancel" {
|
||||
wait_managed_state(t, owner, before.ID, "active")
|
||||
owner.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
|
||||
waitTentacle(t, func() bool { _, err := os.Stat(before.Program[len(before.Program)-1]); return err == nil })
|
||||
}
|
||||
if operation == "disable" {
|
||||
wait_managed_state(t, owner, before.ID, "disabled")
|
||||
}
|
||||
if operation == "shutdown" {
|
||||
restarted, _ := managed_tentacle_fixture(t, owner.registry.Path, false)
|
||||
if err := restarted.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait_managed_state(t, restarted, before.ID, "active")
|
||||
}
|
||||
disk, err := (&tentacle.Registry{Path: owner.registry.Path}).Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation == "remove" {
|
||||
if len(disk) != 0 {
|
||||
t.Fatal(disk)
|
||||
}
|
||||
} else {
|
||||
expected := before
|
||||
if operation == "disable" {
|
||||
expected.Enabled = false
|
||||
}
|
||||
if len(disk) != 1 || !reflect.DeepEqual(disk[0], expected) {
|
||||
t.Fatalf("previous installation not restored: %+v", disk)
|
||||
}
|
||||
if _, err := os.Stat(before.PackageDirectory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
waitTentacle(t, func() bool {
|
||||
packages, err := filepath.Glob(filepath.Join(tentacle.PackageRoot(owner.registry.Path), "package-*"))
|
||||
expected := 1
|
||||
if operation == "remove" {
|
||||
expected = 0
|
||||
}
|
||||
return err == nil && len(packages) == expected
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradePreparationCancellationLeavesRunningPackage(t *testing.T) {
|
||||
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
|
||||
preparing := make(chan struct{})
|
||||
owner.preparePackage = func(ctx context.Context, _ string, _ tentacle.Repository, _ string) (*tentacle.PreparedPackage, error) {
|
||||
close(preparing)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
|
||||
}()
|
||||
select {
|
||||
case <-preparing:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("preparation did not start")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil {
|
||||
t.Fatal("cancelled preparation succeeded")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("preparation did not cancel")
|
||||
}
|
||||
status := owner.Snapshot().Entries[0]
|
||||
if status.State != "active" || !reflect.DeepEqual(status.Entry, before) {
|
||||
t.Fatalf("cancelled preparation changed running package: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeUsesEnablementAtCommit(t *testing.T) {
|
||||
owner, _, repository, before := prepare_upgrade_fixture(t, "called")
|
||||
preparing, release := make(chan struct{}), make(chan struct{})
|
||||
owner.preparePackage = func(ctx context.Context, root string, repository tentacle.Repository, id string) (*tentacle.PreparedPackage, error) {
|
||||
close(preparing)
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return tentacle.PreparePackage(ctx, root, repository, id)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- owner.Manage(ctx, tentacle.ManagementRequest{Action: "upgrade", ID: "echo", URL: repository.URL})
|
||||
}()
|
||||
select {
|
||||
case <-preparing:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("preparation did not start")
|
||||
}
|
||||
manage_tentacle(t, owner, "disable", before.ID, nil)
|
||||
close(release)
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("upgrade did not finish")
|
||||
}
|
||||
wait_managed_state(t, owner, before.ID, "disabled")
|
||||
status := owner.Snapshot().Entries[0]
|
||||
if status.Entry.Version != "0.2" || status.Entry.Enabled {
|
||||
t.Fatal(status)
|
||||
}
|
||||
waitTentacle(t, func() bool { _, err := os.Stat(before.PackageDirectory); return os.IsNotExist(err) })
|
||||
}
|
||||
@@ -260,3 +260,69 @@ func TestDelegatedInputSuppressesInterruptAndEcho(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Match speechWorker's forwarding interfaces: a bare fake's Interrupt method
|
||||
// alone cannot catch a bypass through InterruptForInput or InterruptForUser.
|
||||
type forwardedInputSpeech struct{ *fakeSpeechQueue }
|
||||
|
||||
func (speech *forwardedInputSpeech) InterruptForInput(input.Event) bool {
|
||||
speech.Interrupt()
|
||||
return true
|
||||
}
|
||||
func (speech *forwardedInputSpeech) InterruptForUser() { speech.Interrupt() }
|
||||
|
||||
func TestDelegatedInputDoesNotForwardSpeechInterruption(t *testing.T) {
|
||||
for _, suspended := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
|
||||
speech := &forwardedInputSpeech{&fakeSpeechQueue{interrupt: make(chan struct{}, 8)}}
|
||||
gate := newFocusGate(nil)
|
||||
gate.SetSuspended(suspended)
|
||||
guarded := gatedSpeech{speech: speech, gate: gate}
|
||||
if got := guarded.InterruptForInput(input.Event{Pressed: true}); got == suspended {
|
||||
t.Fatalf("interruption admitted = %t, suspended = %t", got, suspended)
|
||||
}
|
||||
guarded.InterruptForUser()
|
||||
want := 2
|
||||
if suspended {
|
||||
want = 0
|
||||
}
|
||||
if len(speech.interrupt) != want {
|
||||
t.Fatalf("interruptions = %d, want %d", len(speech.interrupt), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXTermHandoffSuppressesLockStateFeedback(t *testing.T) {
|
||||
for _, suspended := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
||||
gate := newFocusGate(nil)
|
||||
gate.SetSuspended(suspended)
|
||||
commands := &fakeCommands{lockStates: make(chan input.LockState)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, focusGate: gate, speechOutput: speech}
|
||||
go func() { done <- worker.run(ctx); close(done) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
for _, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} {
|
||||
select {
|
||||
case commands.lockStates <- input.LockState{Name: name, Locked: true}:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("lock state was not consumed")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 3
|
||||
if suspended {
|
||||
want = 0
|
||||
}
|
||||
if len(speech.requests) != want {
|
||||
t.Fatalf("lock-state requests = %d, want %d", len(speech.requests), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ type Adapter struct {
|
||||
foregroundReader *xorg.ForegroundReader // GLib thread only
|
||||
foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client
|
||||
foreground atomic.Pointer[ForegroundState]
|
||||
preferencesPID atomic.Int64 // Owned preferences helper; zero when closed.
|
||||
echoFocus atomic.Pointer[echoFocus]
|
||||
eventListener *C.AtspiEventListener // GLib owner; released with subscriptions.
|
||||
foregroundChanges chan struct{}
|
||||
|
||||
@@ -129,6 +129,14 @@ func (adapter *Adapter) matchForeground(window *C.AtspiAccessible, id ObjectID,
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Tiling window managers can report GTK's frame and X11's client at
|
||||
// different origins and sizes. Our own active preferences child is known
|
||||
// by its launched PID; require X11 to confirm that process is foreground.
|
||||
if preferences_foreground_matches(state, pid, adapter.preferencesPID.Load()) {
|
||||
adapter.foregroundWindow = id
|
||||
adapter.foregroundRecovery = 0
|
||||
return true
|
||||
}
|
||||
component := C.atspi_accessible_get_component_iface(window)
|
||||
if component == nil {
|
||||
if check != nil {
|
||||
|
||||
@@ -67,8 +67,12 @@ func TestNativeAnnouncementEventDelivered(t *testing.T) {
|
||||
|
||||
fmt.Fprintln(input, "announce polite Workspace 2 Fixture")
|
||||
polite := wait("Workspace 2 Fixture")
|
||||
t.Logf("polite announcement detail1=%d role=%s", polite.Detail1, polite.Source.Role)
|
||||
if polite.Detail1 != 1 {
|
||||
t.Fatalf("polite detail1 = %d, want 1", polite.Detail1)
|
||||
}
|
||||
fmt.Fprintln(input, "announce assertive Fixture error")
|
||||
assertive := wait("Fixture error")
|
||||
t.Logf("assertive announcement detail1=%d role=%s", assertive.Detail1, assertive.Source.Role)
|
||||
if assertive.Detail1 != 2 {
|
||||
t.Fatalf("assertive detail1 = %d, want 2", assertive.Detail1)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,17 @@ type ForegroundState struct {
|
||||
Epoch uint64
|
||||
}
|
||||
|
||||
// SetPreferencesProcess scopes geometry-independent foreground matching to the
|
||||
// UI child launched by this reader. Clear it when that child exits.
|
||||
func (adapter *Adapter) SetPreferencesProcess(pid int) {
|
||||
adapter.preferencesPID.Store(int64(pid))
|
||||
}
|
||||
|
||||
func preferences_foreground_matches(client xorg.Foreground, pid int, preferencesPID int64) bool {
|
||||
return client.Known && client.Window != 0 && preferencesPID > 0 &&
|
||||
int64(pid) == preferencesPID && client.PID == pid
|
||||
}
|
||||
|
||||
func foregroundWindowMatches(client xorg.Foreground, pid int, bounds Rectangle) bool {
|
||||
if client.PID > 0 && pid > 0 && client.PID != pid {
|
||||
return false
|
||||
|
||||
@@ -44,6 +44,37 @@ func TestForegroundWindowCorrelationSteamBigPictureScale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesForegroundIdentity(t *testing.T) {
|
||||
// Slint/i3 reports GTK's frame and the X11 client with different borders.
|
||||
client := xorg.Foreground{Known: true, Window: 45, PID: 123,
|
||||
X: 2, Y: 18, Width: 1912, Height: 974, OuterX: 2, OuterY: 18, OuterWidth: 1912, OuterHeight: 974}
|
||||
if foregroundWindowMatches(client, 123, Rectangle{Width: 1916, Height: 994}) {
|
||||
t.Fatal("fixture must reproduce the reported bounds mismatch")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
client xorg.Foreground
|
||||
pid int
|
||||
owned int64
|
||||
want bool
|
||||
}{
|
||||
{"owned foreground helper", client, 123, 123, true},
|
||||
{"helper closed", client, 123, 0, false},
|
||||
{"another reader's helper", client, 123, 124, false},
|
||||
{"other provider", client, 124, 123, false},
|
||||
{"helper in background", xorg.Foreground{Known: true, Window: 46, PID: 124}, 123, 123, false},
|
||||
{"missing foreground PID", xorg.Foreground{Known: true, Window: 45}, 123, 123, false},
|
||||
{"empty workspace", xorg.Foreground{Known: true, PID: 123}, 123, 123, false},
|
||||
{"unknown foreground", xorg.Foreground{Window: 45, PID: 123}, 123, 123, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := preferences_foreground_matches(test.client, test.pid, test.owned); got != test.want {
|
||||
t.Fatalf("match = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForegroundMenuPopupCorrelation(t *testing.T) {
|
||||
client := xorg.Foreground{Known: true, Window: 45, PID: 123}
|
||||
main := NewObjectID(":1.40", "/main")
|
||||
|
||||
@@ -77,6 +77,8 @@ func TestNativePreferencesDropdownEscape(t *testing.T) {
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.SetPreferencesProcess(command.Process.Pid)
|
||||
defer adapter.SetPreferencesProcess(0)
|
||||
helperDone := make(chan error, 1)
|
||||
go func() { helperDone <- command.Wait() }()
|
||||
helperJoined := false
|
||||
@@ -118,6 +120,9 @@ func TestNativePreferencesDropdownEscape(t *testing.T) {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
t.Logf("%s %s %q capture=%s", event.Type, event.Source.Role, event.Source.Name, event.SnapshotDuration)
|
||||
if (event.Type == WindowActivatedEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && event.ForegroundMismatch {
|
||||
t.Fatalf("owned preferences focus rejected: %+v", event)
|
||||
}
|
||||
if event.Type == FocusedStateEvent && event.Detail1 == 1 {
|
||||
// Match the running reader's browser-mode classification.
|
||||
// Without these fresh ancestry queries, stale cached focus
|
||||
|
||||
@@ -58,7 +58,8 @@ func RebaseApplicationSettings(oldGlobal, newGlobal, application Settings) Setti
|
||||
func profileFromSettings(settings Settings) writableProfile {
|
||||
settings = RoundSpeechValues(settings)
|
||||
return writableProfile{
|
||||
OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)},
|
||||
Wine: writableWine{ControllerOnly: boolPointer(settings.Wine.ControllerOnly), ExcludedPrograms: &settings.Wine.ExcludedPrograms},
|
||||
OCR: writableOCR{Language: stringPointer(settings.OCR.Language), Layout: intPointer(settings.OCR.Layout), Scale: intPointer(settings.OCR.Scale), Grayscale: boolPointer(settings.OCR.Grayscale), Invert: boolPointer(settings.OCR.Invert), BlackWhite: boolPointer(settings.OCR.BlackWhite), Threshold: intPointer(settings.OCR.Threshold), AutoRefresh: boolPointer(settings.OCR.AutoRefresh), RefreshInterval: intPointer(settings.OCR.RefreshInterval), ConfirmClick: boolPointer(settings.OCR.ConfirmClick), CopyFirstResult: boolPointer(settings.OCR.CopyFirstResult), AutoReadAll: boolPointer(settings.OCR.AutoReadAll)},
|
||||
ProgressBar: writableProgressBar{
|
||||
OutputMode: stringPointer(settings.ProgressBar.OutputMode),
|
||||
ReportBackground: boolPointer(settings.ProgressBar.ReportBackground),
|
||||
@@ -169,6 +170,12 @@ func profileFromSettings(settings Settings) writableProfile {
|
||||
}
|
||||
|
||||
func applyProfile(settings *Settings, profile writableProfile) {
|
||||
if profile.Wine.ControllerOnly != nil {
|
||||
settings.Wine.ControllerOnly = *profile.Wine.ControllerOnly
|
||||
}
|
||||
if profile.Wine.ExcludedPrograms != nil {
|
||||
settings.Wine.ExcludedPrograms = append([]string(nil), (*profile.Wine.ExcludedPrograms)...)
|
||||
}
|
||||
apply_profile_ocr(&settings.OCR, profile.OCR)
|
||||
// Keep the established patch order; absent fields leave prior values intact.
|
||||
apply_profile_formats(settings, profile)
|
||||
|
||||
@@ -32,7 +32,13 @@ type writableDocument struct {
|
||||
|
||||
// Profiles are patches over built-in defaults. The writer emits a complete
|
||||
// active profile so saved behavior remains stable if defaults later change.
|
||||
type writableWine struct {
|
||||
ControllerOnly *bool `toml:"controller-only,omitempty"`
|
||||
ExcludedPrograms *[]string `toml:"excluded-hook-programs,omitempty"`
|
||||
}
|
||||
|
||||
type writableProfile struct {
|
||||
Wine writableWine `toml:"wine"`
|
||||
OCR writableOCR `toml:"ocr"`
|
||||
ProgressBar writableProgressBar `toml:"progress-bar"`
|
||||
Presentation writablePresentation `toml:"presentation"`
|
||||
|
||||
@@ -112,7 +112,13 @@ func DefaultProgressBarSettings() ProgressBarSettings {
|
||||
return ProgressBarSettings{OutputMode: "beep", BeepPercentageInterval: 1, SpeechPercentageInterval: 10}
|
||||
}
|
||||
|
||||
type WineSettings struct {
|
||||
ControllerOnly bool
|
||||
ExcludedPrograms []string
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Wine WineSettings
|
||||
StructuralNavigationTriggersFocusMode bool
|
||||
|
||||
OCR OCRSettings
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
)
|
||||
|
||||
func cloneSettings(settings Settings) Settings {
|
||||
settings.Wine.ExcludedPrograms = append([]string(nil), settings.Wine.ExcludedPrograms...)
|
||||
settings.SpeechInterruption.Keys = append([]string(nil), settings.SpeechInterruption.Keys...)
|
||||
settings.KeyBindings = input.CloneBindingOverrides(settings.KeyBindings)
|
||||
settings.CthulhuModifierKeys = append([]string(nil), settings.CthulhuModifierKeys...)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/datetime"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
@@ -33,6 +34,20 @@ import (
|
||||
func Validate(settings Settings) error { return validate(settings) }
|
||||
|
||||
func validate(settings Settings) error {
|
||||
if len(settings.Wine.ExcludedPrograms) > 256 {
|
||||
return errors.New("too many Wine hook exclusions")
|
||||
}
|
||||
exclusionUnits := 0
|
||||
for _, name := range settings.Wine.ExcludedPrograms {
|
||||
exclusionUnits += len(utf16.Encode([]rune(name))) + 1
|
||||
if name == "" || len(name) > 255 || strings.TrimSpace(name) != name || strings.ContainsAny(name, "\x00\r\n/\\") {
|
||||
return errors.New("Wine hook exclusions must be executable basenames")
|
||||
}
|
||||
}
|
||||
|
||||
if exclusionUnits >= 32766 {
|
||||
return errors.New("Wine hook exclusion list is too long")
|
||||
}
|
||||
if err := settings.SpeechInterruption.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWineSettingsRoundTripAndValidation(t *testing.T) {
|
||||
settings := DefaultSettings()
|
||||
settings.Wine = WineSettings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe", "Another Game.exe"}}
|
||||
path := filepath.Join(t.TempDir(), "settings.toml")
|
||||
store, err := NewStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Save(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.Wine, settings.Wine) {
|
||||
t.Fatalf("Wine settings = %+v", loaded.Wine)
|
||||
}
|
||||
cloned := cloneSettings(settings)
|
||||
cloned.Wine.ExcludedPrograms[0] = "Changed.exe"
|
||||
if settings.Wine.ExcludedPrograms[0] != "Game.exe" {
|
||||
t.Fatal("clone aliases exclusions")
|
||||
}
|
||||
for _, name := range []string{"", "../game.exe", `C:\game.exe`, "Game.exe\rOther.exe", "game.exe\n"} {
|
||||
settings.Wine.ExcludedPrograms = []string{name}
|
||||
if err := Validate(settings); err == nil {
|
||||
t.Fatalf("accepted exclusion %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,6 +381,13 @@ static void grab_command_keys(CommandDevice *device) {
|
||||
}
|
||||
}
|
||||
int set_command_keys(CommandDevice *device, int enabled) {
|
||||
// Another reader may have changed Caps Lock while command input was
|
||||
// delegated. Adopt that state before treating Caps as our modifier again.
|
||||
if (enabled && !device->commands && device->capsCode) {
|
||||
XkbStateRec state;
|
||||
if (XkbGetState(device->display, XkbUseCoreKbd, &state) != Success) return -7;
|
||||
device->capsLocked = (state.locked_mods & LockMask) != 0;
|
||||
}
|
||||
pthread_mutex_lock(&error_mutex);
|
||||
XSync(device->display,False);
|
||||
error_display=device->display; grab_error=0;
|
||||
@@ -491,6 +498,7 @@ CommandDevice *open_commands(int useInsert, int useKeypad, int useCaps, int *err
|
||||
*error=set_command_keys(device,1);
|
||||
if (*error) { XCloseDisplay(device->display); free(device); return NULL; }
|
||||
if (device->capsCode) {
|
||||
device->capsLocked=0;
|
||||
XkbLockModifiers(device->display,XkbUseCoreKbd,LockMask,0);
|
||||
XSync(device->display,False);
|
||||
}
|
||||
@@ -502,7 +510,7 @@ int command_lock_state(CommandDevice *device) {
|
||||
int locks=0;
|
||||
for (int i=0;i<3;i++) if (indicators & device->lockIndicators[i]) locks |= 1<<i;
|
||||
// Ignore the transient XKB lock action on a grabbed reader modifier.
|
||||
if (device->capsCode) locks=(locks & ~1) | device->capsLocked;
|
||||
if (device->commands && device->capsCode) locks=(locks & ~1) | device->capsLocked;
|
||||
return locks;
|
||||
}
|
||||
void toggle_command_caps_lock(CommandDevice *device) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,7 +24,7 @@ func TestNativeSuspendedQueuedKey(t *testing.T) {
|
||||
if out, err := exec.CommandContext(ctx, "cc", "-I.", "../../../tests/xorg_suspension_probe.c", "commands_native.c", "-o", probe, "-lX11", "-lXtst", "-lxkbcommon", "-lpthread").CombinedOutput(); err != nil {
|
||||
t.Fatalf("compile native probe: %v: %s", err, out)
|
||||
}
|
||||
for _, mode := range []string{"queued", "replay", "passive"} {
|
||||
for _, mode := range []string{"queued", "replay", "passive", "caps-handoff"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
if out, err := exec.CommandContext(ctx, probe, mode).CombinedOutput(); err != nil {
|
||||
t.Fatalf("native suspension: %v: %s", err, out)
|
||||
@@ -39,12 +40,13 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
|
||||
t.Skip("requires isolated X11 display")
|
||||
}
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
learn, held bool
|
||||
name string
|
||||
learn, held, caps bool
|
||||
}{
|
||||
{name: "delayed_focus"},
|
||||
{name: "held_command", held: true},
|
||||
{name: "learn_mode", learn: true},
|
||||
{name: "caps_modifier", caps: true},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
@@ -86,7 +88,13 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
|
||||
}
|
||||
return state.Keys
|
||||
}
|
||||
listener := NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
|
||||
modifiers, layout := []string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout
|
||||
reviewChord, reviewCommand := "KP_Begin", input.ReviewCurrentWord
|
||||
if scenario.caps {
|
||||
modifiers, layout = []string{"Caps_Lock"}, input.LaptopKeyboardLayout
|
||||
reviewChord, reviewCommand = "Caps_Lock+i", input.ReviewCurrentLine
|
||||
}
|
||||
listener := NewCommands(modifiers, layout)
|
||||
if err := listener.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -140,9 +148,9 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
|
||||
defer exec.Command("xdotool", "keyup", "KP_Begin").Run()
|
||||
key("keydown", "KP_Begin")
|
||||
} else {
|
||||
key("key", "KP_Begin")
|
||||
key("key", reviewChord)
|
||||
}
|
||||
command(input.ReviewCurrentWord)
|
||||
command(reviewCommand)
|
||||
if !scenario.held {
|
||||
wait(func() bool { return !listener.CommandGrabActive() })
|
||||
}
|
||||
@@ -168,10 +176,14 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
|
||||
time.Sleep(2 * xtermFocusCheckInterval)
|
||||
}
|
||||
action(map[string]string{"action": "clear"})
|
||||
key("key", "KP_Begin")
|
||||
key("key", reviewChord)
|
||||
keys := action(map[string]string{"action": "state"})
|
||||
if len(keys) != 2 || keys[0].Symbol != 0xff9d || !keys[0].Pressed || keys[1].Symbol != 0xff9d || keys[1].Pressed {
|
||||
t.Fatalf("xterm keypad press/release = %+v; unknown=%t, grabs=%t, active=%t", keys, unknown, listener.CommandGrabsEnabled(), listener.CommandGrabActive())
|
||||
expected := []deliveredKey{{Pressed: true, Symbol: 0xff9d}, {Pressed: false, Symbol: 0xff9d}}
|
||||
if scenario.caps {
|
||||
expected = []deliveredKey{{Pressed: true, Symbol: 0xffe5}, {Pressed: true, Symbol: 0x69}, {Pressed: false, Symbol: 0xffe5}, {Pressed: false, Symbol: 0x69}}
|
||||
}
|
||||
if !slices.Equal(keys, expected) {
|
||||
t.Fatalf("xterm key press/release = %+v, want %+v; unknown=%t, grabs=%t, active=%t", keys, expected, unknown, listener.CommandGrabsEnabled(), listener.CommandGrabActive())
|
||||
}
|
||||
select {
|
||||
case got := <-listener.Commands():
|
||||
@@ -183,8 +195,8 @@ func TestNativeXTermHandoffKeepsKeypadReleased(t *testing.T) {
|
||||
action(map[string]string{"action": "activate"})
|
||||
focus(false)
|
||||
wait(listener.CommandGrabsEnabled)
|
||||
key("key", "KP_Begin")
|
||||
command(input.ReviewCurrentWord)
|
||||
key("key", reviewChord)
|
||||
command(reviewCommand)
|
||||
wait(func() bool { return !listener.CommandGrabActive() })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ void prefs_init_tentacles(void) {
|
||||
const struct { const char *id; int action; } buttons[] = {
|
||||
{"tentacleNew", 9}, {"tentacleRetry", 13}, {"tentacleRemove", 14},
|
||||
{"tentacleRefresh", 15}, {"tentacleRepositoryRemove", 12}, {"tentacleRepositoryRefresh", 18},
|
||||
{"tentacleInstall", 19}, {"tentacleSettings", 20},
|
||||
{"tentacleInstall", 19}, {"tentacleUpdate", 21}, {"tentacleSettings", 20},
|
||||
};
|
||||
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));
|
||||
@@ -124,6 +124,7 @@ void prefs_tentacle_controls(int available, int installed, int enabled, int repo
|
||||
gtk_widget_set_sensitive(prefs_widget("tentacleSettings"), available && installed);
|
||||
gtk_widget_set_sensitive(prefs_widget("tentacleRemove"), available && installed);
|
||||
gtk_widget_set_sensitive(prefs_widget("tentacleInstall"), available && !installed && prefs_tentacle_selected() >= 0);
|
||||
gtk_widget_set_sensitive(prefs_widget("tentacleUpdate"), available && installed && prefs_tentacle_selected() >= 0);
|
||||
gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRefresh"), available && repository);
|
||||
gtk_widget_set_sensitive(prefs_widget("tentacleRepositoryRemove"), available && repository);
|
||||
}
|
||||
@@ -131,7 +132,9 @@ int prefs_tentacle_confirm(const char *message, const char *label) {
|
||||
GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(prefs_widget("window")),
|
||||
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, GTK_MESSAGE_QUESTION,
|
||||
GTK_BUTTONS_NONE, "%s", message);
|
||||
gtk_window_set_title(GTK_WINDOW(dialog), "Install tentacle");
|
||||
gchar *title = g_strdup_printf("%s tentacle", label);
|
||||
gtk_window_set_title(GTK_WINDOW(dialog), title);
|
||||
g_free(title);
|
||||
gtk_dialog_add_button(GTK_DIALOG(dialog), "Cancel", GTK_RESPONSE_CANCEL);
|
||||
gtk_dialog_add_button(GTK_DIALOG(dialog), label, GTK_RESPONSE_ACCEPT);
|
||||
gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL);
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
</object></child>
|
||||
<child><object class="GtkButtonBox"><property name="layout-style">start</property><property name="spacing">6</property>
|
||||
<child><object class="GtkButton" id="tentacleInstall"><property name="label">Install</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkButton" id="tentacleUpdate"><property name="label">Update</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkButton" id="tentacleSettings"><property name="label">Settings</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkButton" id="tentacleRetry"><property name="label">Retry</property><property name="can-focus">True</property></object></child>
|
||||
<child><object class="GtkButton" id="tentacleRemove"><property name="label">Remove tentacle</property><property name="can-focus">True</property></object></child>
|
||||
|
||||
@@ -212,6 +212,62 @@ func (page *tentaclePage) action(action int) (*tentacle.ManagementRequest, error
|
||||
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.")
|
||||
|
||||
@@ -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 <= 20 && !busy {
|
||||
if action >= 9 && action <= 21 && !busy {
|
||||
request, err := tentacles.action(int(action))
|
||||
if err != nil {
|
||||
status(err.Error(), true)
|
||||
@@ -164,6 +164,9 @@ func Run(ctx context.Context, input io.Reader, output io.Writer) error {
|
||||
if request.Action == "install" || request.Action == "install-enable" {
|
||||
status("Downloading and checking the tentacle package. Close preferences to cancel before it is saved.", true)
|
||||
}
|
||||
if request.Action == "upgrade" {
|
||||
status("Downloading and checking the tentacle update. Close preferences to cancel.", true)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -24,4 +24,5 @@ type Snapshot struct {
|
||||
Message string
|
||||
Suppressed bool
|
||||
Repositories []Repository
|
||||
Updates []AvailableUpdate
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AvailableUpdate describes a newer repository release for an installed tentacle.
|
||||
type AvailableUpdate struct {
|
||||
ID string
|
||||
Name string
|
||||
InstalledVersion string
|
||||
AvailableVersion string
|
||||
RepositoryURL string
|
||||
RepositoryID string
|
||||
LocalID string
|
||||
}
|
||||
|
||||
// packageVersion accepts dotted numeric releases (including existing short
|
||||
// versions such as 0.1), with optional SemVer prerelease and build identifiers.
|
||||
type packageVersion struct {
|
||||
numbers []string
|
||||
prerelease []string
|
||||
}
|
||||
|
||||
func numeric_identifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func version_identifiers(value string, prerelease bool) ([]string, error) {
|
||||
identifiers := strings.Split(value, ".")
|
||||
for _, identifier := range identifiers {
|
||||
if identifier == "" {
|
||||
return nil, fmt.Errorf("empty version identifier")
|
||||
}
|
||||
for _, ch := range identifier {
|
||||
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '-') {
|
||||
return nil, fmt.Errorf("invalid version identifier %q", identifier)
|
||||
}
|
||||
}
|
||||
if prerelease && numeric_identifier(identifier) && len(identifier) > 1 && identifier[0] == '0' {
|
||||
return nil, fmt.Errorf("prerelease number has a leading zero")
|
||||
}
|
||||
}
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
func parse_package_version(value string) (packageVersion, error) {
|
||||
var result packageVersion
|
||||
core := strings.TrimPrefix(strings.TrimSpace(value), "v")
|
||||
if before, build, found := strings.Cut(core, "+"); found {
|
||||
if _, err := version_identifiers(build, false); err != nil {
|
||||
return result, err
|
||||
}
|
||||
core = before
|
||||
}
|
||||
if before, prerelease, found := strings.Cut(core, "-"); found {
|
||||
var err error
|
||||
result.prerelease, err = version_identifiers(prerelease, true)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
core = before
|
||||
}
|
||||
result.numbers = strings.Split(core, ".")
|
||||
for _, number := range result.numbers {
|
||||
if !numeric_identifier(number) {
|
||||
return result, fmt.Errorf("invalid numeric version %q", value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Compare decimal strings without an integer-size limit.
|
||||
func compare_version_number(first, second string) int {
|
||||
first, second = strings.TrimLeft(first, "0"), strings.TrimLeft(second, "0")
|
||||
if len(first) < len(second) {
|
||||
return -1
|
||||
}
|
||||
if len(first) > len(second) {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(first, second)
|
||||
}
|
||||
|
||||
// IsNewerVersion compares release and prerelease precedence. Build metadata
|
||||
// does not change precedence; missing numeric components are treated as zero.
|
||||
func IsNewerVersion(available, installed string) (bool, error) {
|
||||
first, err := parse_package_version(available)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
second, err := parse_package_version(installed)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for index := 0; index < len(first.numbers) || index < len(second.numbers); index++ {
|
||||
firstNumber, secondNumber := "0", "0"
|
||||
if index < len(first.numbers) {
|
||||
firstNumber = first.numbers[index]
|
||||
}
|
||||
if index < len(second.numbers) {
|
||||
secondNumber = second.numbers[index]
|
||||
}
|
||||
if compared := compare_version_number(firstNumber, secondNumber); compared != 0 {
|
||||
return compared > 0, nil
|
||||
}
|
||||
}
|
||||
if len(first.prerelease) == 0 || len(second.prerelease) == 0 {
|
||||
return len(first.prerelease) == 0 && len(second.prerelease) != 0, nil
|
||||
}
|
||||
for index := 0; index < len(first.prerelease) && index < len(second.prerelease); index++ {
|
||||
firstID, secondID := first.prerelease[index], second.prerelease[index]
|
||||
firstNumeric, secondNumeric := numeric_identifier(firstID), numeric_identifier(secondID)
|
||||
compared := strings.Compare(firstID, secondID)
|
||||
if firstNumeric && secondNumeric {
|
||||
compared = compare_version_number(firstID, secondID)
|
||||
}
|
||||
if firstNumeric != secondNumeric {
|
||||
return !firstNumeric, nil
|
||||
}
|
||||
if compared != 0 {
|
||||
return compared > 0, nil
|
||||
}
|
||||
}
|
||||
return len(first.prerelease) > len(second.prerelease), nil
|
||||
}
|
||||
|
||||
// AvailableUpdates finds newer versions of installed repository tentacles.
|
||||
func AvailableUpdates(snapshot Snapshot) []AvailableUpdate {
|
||||
var updates []AvailableUpdate
|
||||
for _, status := range snapshot.Entries {
|
||||
entry := status.Entry
|
||||
if entry.Repository == "" || entry.Version == "" {
|
||||
continue
|
||||
}
|
||||
for _, repository := range snapshot.Repositories {
|
||||
if repository.URL != entry.Repository || repository.ID != entry.RepositoryID {
|
||||
continue
|
||||
}
|
||||
for _, manifest := range repository.Tentacles {
|
||||
if manifest.ID != entry.LocalID {
|
||||
continue
|
||||
}
|
||||
newer, err := IsNewerVersion(manifest.Version, entry.Version)
|
||||
if err != nil || !newer {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, AvailableUpdate{
|
||||
ID: entry.ID,
|
||||
Name: manifest.Name,
|
||||
InstalledVersion: entry.Version,
|
||||
AvailableVersion: manifest.Version,
|
||||
RepositoryURL: repository.URL,
|
||||
RepositoryID: repository.ID,
|
||||
LocalID: manifest.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package tentacle
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVersionPrecedence(t *testing.T) {
|
||||
ordered := []string{"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0", "1.1", "2.0"}
|
||||
for firstIndex, first := range ordered {
|
||||
for secondIndex, second := range ordered {
|
||||
newer, err := IsNewerVersion(first, second)
|
||||
if err != nil || newer != (firstIndex > secondIndex) {
|
||||
t.Fatalf("%s > %s: %v, %v", first, second, newer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pair := range [][2]string{{"v0.1", "0.1.0"}, {"1.0.0+build.2", "1.0.0+build.1"}, {"2026.11.01", "2026.11.1"}} {
|
||||
for _, versions := range [][2]string{pair, {pair[1], pair[0]}} {
|
||||
if newer, err := IsNewerVersion(versions[0], versions[1]); err != nil || newer {
|
||||
t.Fatalf("equal precedence: %v: %v %v", versions, newer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if newer, err := IsNewerVersion("999999999999999999999999999", "2"); err != nil || !newer {
|
||||
t.Fatalf("large numeric component: %v %v", newer, err)
|
||||
}
|
||||
for _, invalid := range []string{"", "nightly", "1..0", "1.-2", "1.0-", "1.0-01", "1.0+", "1.0+foo+bar", "1.0-a_1"} {
|
||||
if _, err := IsNewerVersion(invalid, "1.0"); err == nil {
|
||||
t.Errorf("accepted invalid version %q", invalid)
|
||||
}
|
||||
if _, err := IsNewerVersion("2.0", invalid); err == nil {
|
||||
t.Errorf("accepted invalid installed version %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableUpdatesRequiresExactSource(t *testing.T) {
|
||||
repository := Repository{URL: "https://example.org/tentacles", ID: "example.source", Tentacles: []Manifest{{ID: "echo", Name: "Echo", Version: "1.0"}}}
|
||||
entry := Entry{Repository: repository.URL, RepositoryID: repository.ID, LocalID: "echo", Version: "1.0-rc.1"}
|
||||
entry.ID = PackageID(repository, entry.LocalID)
|
||||
snapshot := Snapshot{Entries: []EntryStatus{{Entry: entry}}, Repositories: []Repository{repository}}
|
||||
updates := AvailableUpdates(snapshot)
|
||||
if len(updates) != 1 || updates[0].ID != entry.ID || updates[0].AvailableVersion != "1.0" {
|
||||
t.Fatalf("missing release update: %+v", updates)
|
||||
}
|
||||
for _, changed := range []string{"url", "repository-id", "local-id", "version"} {
|
||||
candidate := snapshot
|
||||
candidate.Repositories = CloneRepositories(snapshot.Repositories)
|
||||
switch changed {
|
||||
case "url":
|
||||
candidate.Repositories[0].URL = "https://other.example.org/tentacles"
|
||||
case "repository-id":
|
||||
candidate.Repositories[0].ID = "example.replacement"
|
||||
case "local-id":
|
||||
candidate.Repositories[0].Tentacles[0].ID = "different"
|
||||
case "version":
|
||||
candidate.Repositories[0].Tentacles[0].Version = entry.Version
|
||||
}
|
||||
if updates := AvailableUpdates(candidate); len(updates) != 0 {
|
||||
t.Errorf("%s offered unrelated or equal release: %+v", changed, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-28
@@ -21,24 +21,28 @@ const managedKey = "CTHULHU_WINE_ACCESS_MANAGED"
|
||||
type prefix struct {
|
||||
path, loader string
|
||||
environment map[string]string
|
||||
programs map[string]bool
|
||||
}
|
||||
|
||||
type process struct {
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
started time.Time
|
||||
seen time.Time
|
||||
failures int
|
||||
next time.Time
|
||||
stopping atomic.Bool
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
started time.Time
|
||||
seen time.Time
|
||||
failures int
|
||||
next time.Time
|
||||
stopping atomic.Bool
|
||||
controllerOnly bool
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
logger *slog.Logger
|
||||
helper string
|
||||
procRoot string
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
logger *slog.Logger
|
||||
helper string
|
||||
procRoot string
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
ControllerOnly bool
|
||||
ExcludedPrograms []string
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger) *Manager {
|
||||
@@ -151,7 +155,11 @@ func (manager *Manager) poll(active map[string]*process) {
|
||||
child.next = now.Add(backoff(child.failures))
|
||||
child.cmd = nil
|
||||
default:
|
||||
continue
|
||||
if manager.controller_only(source) && !child.controllerOnly {
|
||||
stop(child)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if now.Before(child.next) {
|
||||
@@ -179,7 +187,13 @@ func backoff(failures int) time.Duration {
|
||||
}
|
||||
|
||||
func (manager *Manager) launch(child *process, source prefix, now time.Time) error {
|
||||
cmd := exec.Command(manager.helper)
|
||||
helper, err := stage_helper(manager.helper, source.environment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
controllerOnly := child.controllerOnly || manager.controller_only(source)
|
||||
arguments := manager.helper_arguments(controllerOnly)
|
||||
cmd := exec.Command(helper, arguments...)
|
||||
diagnostics := newHelperDiagnostics()
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
@@ -187,11 +201,17 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
||||
// Bound stderr draining if a Wine descendant retains the inherited pipe.
|
||||
cmd.WaitDelay = 200 * time.Millisecond
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
environment := os.Environ()
|
||||
environment := make([]string, 0, len(os.Environ())+8)
|
||||
for _, entry := range os.Environ() {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if key != "WINEESYNC" && key != "WINEFSYNC" {
|
||||
environment = append(environment, entry)
|
||||
}
|
||||
}
|
||||
// Hooks need the application's display, but D-Bus requests go to this
|
||||
// reader's session. Applications launched from another session bus must
|
||||
// not redirect the helper away from the service supervising it.
|
||||
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"} {
|
||||
for _, key := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "WINEESYNC", "WINEFSYNC"} {
|
||||
if value := source.environment[key]; value != "" {
|
||||
environment = append(environment, key+"="+value)
|
||||
}
|
||||
@@ -206,6 +226,7 @@ func (manager *Manager) launch(child *process, source prefix, now time.Time) err
|
||||
}
|
||||
child.cmd, child.done, child.started = cmd, make(chan struct{}), now
|
||||
child.stopping.Store(false)
|
||||
child.controllerOnly = controllerOnly
|
||||
logger := manager.logger.With("helper_pid", cmd.Process.Pid)
|
||||
logger.Info("Wine accessibility helper started")
|
||||
logger.Debug("Wine accessibility helper launch configuration", "executable", cmd.Path,
|
||||
@@ -317,17 +338,13 @@ func discover(root string, uid uint32) map[string]prefix {
|
||||
if !filepath.IsAbs(path) {
|
||||
continue
|
||||
}
|
||||
loader := environment["WINELOADER"]
|
||||
if !executable(loader) {
|
||||
loader = exe
|
||||
if strings.HasSuffix(name, "-preloader") {
|
||||
loader = strings.TrimSuffix(exe, "-preloader")
|
||||
}
|
||||
if !executable(loader) {
|
||||
loader = ""
|
||||
}
|
||||
loader := wine_loader(exe, environment["WINELOADER"])
|
||||
programs := map[string]bool{program_name(string(commandLine)): true}
|
||||
if previous, ok := result[path]; ok {
|
||||
previous.programs[program_name(string(commandLine))] = true
|
||||
continue
|
||||
}
|
||||
result[path] = prefix{path: path, loader: loader, environment: environment}
|
||||
result[path] = prefix{path: path, loader: loader, environment: environment, programs: programs}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -341,9 +358,13 @@ func applicationProcess(commandLine string) bool {
|
||||
if program == "" {
|
||||
return false
|
||||
}
|
||||
program = filepath.Base(strings.ReplaceAll(program, "\\", "/"))
|
||||
normalized := strings.ReplaceAll(program, "\\", "/")
|
||||
if strings.HasSuffix(normalized, "/windows/system32/steam.exe") || strings.HasSuffix(normalized, "/windows/syswow64/steam.exe") {
|
||||
return false
|
||||
}
|
||||
program = filepath.Base(normalized)
|
||||
switch program {
|
||||
case "", "services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe":
|
||||
case "", "xalia.exe", "conhost.exe", "tabtip.exe", "start.exe", "services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe":
|
||||
return false
|
||||
case "explorer.exe":
|
||||
// Ordinary Explorer windows are applications; its desktop host is not.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package wine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func program_name(commandLine string) string {
|
||||
name := strings.SplitN(commandLine, "\x00", 2)[0]
|
||||
return filepath.Base(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(name)), "\\", "/"))
|
||||
}
|
||||
|
||||
func wine_loader(exe, configured string) string {
|
||||
if executable(configured) {
|
||||
// Protontricks' proxy requires its own runtime environment. Join the
|
||||
// existing server with the actual Proton loader instead.
|
||||
file, err := os.Open(configured)
|
||||
if err == nil {
|
||||
data := make([]byte, 8192)
|
||||
count, _ := file.Read(data)
|
||||
file.Close()
|
||||
if !bytes.Contains(data[:count], []byte("PROTONTRICKS_STEAM_RUNTIME")) {
|
||||
return configured
|
||||
}
|
||||
}
|
||||
}
|
||||
directory := filepath.Dir(exe)
|
||||
for _, name := range []string{strings.TrimSuffix(filepath.Base(exe), "-preloader"), "wine"} {
|
||||
candidate := filepath.Join(directory, name)
|
||||
if executable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for range 5 {
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory || parent == string(filepath.Separator) {
|
||||
break
|
||||
}
|
||||
directory = parent
|
||||
for _, name := range []string{"wine", "wine64"} {
|
||||
candidate := filepath.Join(directory, "bin", name)
|
||||
if executable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (manager *Manager) controller_only(source prefix) bool {
|
||||
if manager.ControllerOnly || os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "" && os.Getenv("WINE_ACCESS_NO_DIALOG_READER") != "0" {
|
||||
return true
|
||||
}
|
||||
for _, name := range manager.ExcludedPrograms {
|
||||
if source.programs[strings.ToLower(name)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (manager *Manager) helper_arguments(controllerOnly bool) []string {
|
||||
var arguments []string
|
||||
names := append([]string(nil), manager.ExcludedPrograms...)
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
// Validate again at the process boundary; never pass paths or separators
|
||||
// into the native hook exclusion list.
|
||||
if name != "" && !strings.ContainsAny(name, "\x00\r\n/\\") {
|
||||
arguments = append(arguments, "--exclude-hook-program", strings.ToLower(name))
|
||||
}
|
||||
}
|
||||
if controllerOnly {
|
||||
arguments = append(arguments, "--no-dialog-reader")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package wine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func write_helper_file(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtonLoaderBypassesProxy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
loader := filepath.Join(root, "Proton", "files", "bin", "wine")
|
||||
preloader := filepath.Join(root, "Proton", "files", "lib", "wine", "i386-unix", "wine64-preloader")
|
||||
proxy := filepath.Join(root, "proxy", "wine")
|
||||
write_helper_file(t, loader, "loader")
|
||||
write_helper_file(t, preloader, "preloader")
|
||||
write_helper_file(t, proxy, "#!/bin/sh\n# PROTONTRICKS_STEAM_RUNTIME\n")
|
||||
for _, configured := range []string{"", proxy, loader} {
|
||||
if got := wine_loader(preloader, configured); got != loader {
|
||||
t.Fatalf("loader = %q, want %q", got, loader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerHelperPublishesImmutableCompleteSets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state"))
|
||||
helper := filepath.Join(root, "installed", "wine-access.exe")
|
||||
names := []string{"wine-access.exe", "wine-access.exe.so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"}
|
||||
for _, name := range names {
|
||||
write_helper_file(t, filepath.Join(filepath.Dir(helper), name), name)
|
||||
}
|
||||
environment := map[string]string{"PRESSURE_VESSEL_RUNTIME": "runtime"}
|
||||
original, err := stage_helper(helper, environment)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if original == helper {
|
||||
t.Fatal("helper was not staged")
|
||||
}
|
||||
for _, name := range names {
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(original), name))
|
||||
if err != nil || string(data) != name {
|
||||
t.Fatalf("incomplete set: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
write_helper_file(t, helper+".so", "new sidecar")
|
||||
updated, err := stage_helper(helper, environment)
|
||||
if err != nil || updated == original {
|
||||
t.Fatalf("updated set = %q, %v", updated, err)
|
||||
}
|
||||
data, _ := os.ReadFile(original + ".so")
|
||||
if string(data) != "wine-access.exe.so" {
|
||||
t.Fatal("old sidecar overwritten")
|
||||
}
|
||||
write_helper_file(t, updated+".so", "tampered")
|
||||
if _, err := stage_helper(helper, environment); err == nil {
|
||||
t.Fatal("accepted modified staging")
|
||||
}
|
||||
if err := os.Remove(helper + ".so"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stage_helper(helper, environment); err == nil {
|
||||
t.Fatal("accepted incomplete source set")
|
||||
}
|
||||
if got, err := stage_helper(helper, nil); err != nil || got != helper {
|
||||
t.Fatalf("ordinary Wine = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerOnlyExclusionsReachLauncherBeforeGameStarts(t *testing.T) {
|
||||
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
||||
manager := &Manager{ExcludedPrograms: []string{"Game.EXE"}}
|
||||
launcher := prefix{programs: map[string]bool{"launcher.exe": true}}
|
||||
arguments := manager.helper_arguments(manager.controller_only(launcher))
|
||||
if len(arguments) != 2 || arguments[0] != "--exclude-hook-program" || arguments[1] != "game.exe" {
|
||||
t.Fatalf("launcher arguments = %v", arguments)
|
||||
}
|
||||
launcher.programs["game.exe"] = true
|
||||
if !manager.controller_only(launcher) {
|
||||
t.Fatal("game did not select controller-only")
|
||||
}
|
||||
arguments = manager.helper_arguments(true)
|
||||
if arguments[len(arguments)-1] != "--no-dialog-reader" {
|
||||
t.Fatalf("game arguments = %v", arguments)
|
||||
}
|
||||
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1")
|
||||
if !(&Manager{}).controller_only(prefix{}) {
|
||||
t.Fatal("environment override ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtonInfrastructureDoesNotKeepHelperAlive(t *testing.T) {
|
||||
for _, program := range []string{`C:\windows\system32\steam.exe`, `C:\windows\syswow64\steam.exe`, "xalia.exe", "conhost.exe"} {
|
||||
if applicationProcess(program) {
|
||||
t.Fatalf("infrastructure counted as app: %s", program)
|
||||
}
|
||||
}
|
||||
for _, program := range []string{`C:\Program Files\Steam\steam.exe`, "Game.exe", "explorer.exe"} {
|
||||
if !applicationProcess(program) {
|
||||
t.Fatalf("app ignored: %s", program)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchForwardsProtonSyncAndKeepsReaderBus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
output := filepath.Join(root, "environment")
|
||||
helper := filepath.Join(root, "helper")
|
||||
write_helper_file(t, helper, "#!/bin/sh\nenv > \"$TEST_WINE_ENV\"\n")
|
||||
t.Setenv("TEST_WINE_ENV", output)
|
||||
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/reader/bus")
|
||||
manager := New(nil)
|
||||
manager.helper = helper
|
||||
child := &process{}
|
||||
source := prefix{path: root, environment: map[string]string{"WINEESYNC": "1", "WINEFSYNC": "1", "XAUTHORITY": "/fixture/auth", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/pressure-vessel/bus"}}
|
||||
if err := manager.launch(child, source, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-child.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
stop(child)
|
||||
t.Fatal("helper did not exit")
|
||||
}
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"WINEESYNC=1\n", "WINEFSYNC=1\n", "XAUTHORITY=/fixture/auth\n", "DBUS_SESSION_BUS_ADDRESS=unix:path=/reader/bus\n"} {
|
||||
if !bytes.Contains(data, []byte(value)) {
|
||||
t.Fatalf("missing %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAggregatesGamesAndEscalatesHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
procRoot := filepath.Join(root, "proc")
|
||||
loader := filepath.Join(root, "wine")
|
||||
write_helper_file(t, loader, "loader")
|
||||
makeProcess := func(pid, program string) {
|
||||
t.Helper()
|
||||
base := filepath.Join(procRoot, pid)
|
||||
write_helper_file(t, filepath.Join(base, "cmdline"), program+"\x00")
|
||||
write_helper_file(t, filepath.Join(base, "environ"), "WINEPREFIX="+root+"\x00")
|
||||
if err := os.Symlink(loader, filepath.Join(base, "exe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
makeProcess("101", "launcher.exe")
|
||||
helper := filepath.Join(root, "helper")
|
||||
write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n")
|
||||
manager := New(nil)
|
||||
manager.helper, manager.procRoot = helper, procRoot
|
||||
manager.ExcludedPrograms = []string{"GAME.exe"}
|
||||
active := map[string]*process{}
|
||||
defer func() {
|
||||
for _, child := range active {
|
||||
stop(child)
|
||||
}
|
||||
}()
|
||||
manager.poll(active)
|
||||
child := active[root]
|
||||
if child == nil || child.cmd == nil || child.controllerOnly {
|
||||
t.Fatal("launcher helper did not start normally")
|
||||
}
|
||||
originalPID := child.cmd.Process.Pid
|
||||
makeProcess("102", "Game.exe")
|
||||
discovered := discover(procRoot, uint32(os.Getuid()))
|
||||
if len(discovered[root].programs) != 2 {
|
||||
t.Fatalf("programs = %v", discovered[root].programs)
|
||||
}
|
||||
manager.poll(active)
|
||||
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID {
|
||||
t.Fatal("helper did not switch to controller-only")
|
||||
}
|
||||
pid := child.cmd.Process.Pid
|
||||
if err := os.RemoveAll(filepath.Join(procRoot, "102")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager.poll(active)
|
||||
if !child.controllerOnly || child.cmd.Process.Pid != pid {
|
||||
t.Fatal("controller-only helper downgraded after game disappeared")
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,9 @@ def inside():
|
||||
EchoVoice={"Family": {"Name": "Spanish test voice", "Lang": "es"}})
|
||||
if os.environ.get("CTHULHU_PREFS_TENTACLES_ONLY") == "1":
|
||||
initial["Tentacles"] = {"Entries": [], "Suppressed": False}
|
||||
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
|
||||
from native_tentacle_updates import update_snapshot
|
||||
initial["Tentacles"] = update_snapshot()
|
||||
if os.environ.get("CTHULHU_PREFS_TENTACLE_SETTINGS_ONLY") == "1":
|
||||
initial["Tentacles"] = {"Entries": [{"Entry": {"ID": "probe", "Name": "Settings probe", "Enabled": True},
|
||||
"State": "active", "Shortcuts": ["Desktop: Cthulhu+Ctrl+Shift+W (Probe menu)"]}], "Suppressed": False}
|
||||
@@ -458,6 +461,11 @@ def inside():
|
||||
check_page(pyatspi, process, window, nodes, named, walk, focus, key,
|
||||
request, send, initial, application)
|
||||
return
|
||||
if os.environ.get("CTHULHU_PREFS_TENTACLE_UPDATES_ONLY") == "1":
|
||||
from native_tentacle_updates import check_page
|
||||
check_page(pyatspi, process, window, named, walk, focus, key,
|
||||
request, send, initial)
|
||||
return
|
||||
if os.environ.get("CTHULHU_PREFS_OCR_ONLY") == "1":
|
||||
tabs = next(n for n in nodes if n.getRole() == pyatspi.ROLE_PAGE_TAB_LIST)
|
||||
assert tabs.querySelection().selectChild(8), "cannot select OCR tab"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Private GTK update confirmation and keyboard workflow; no installed packages."""
|
||||
import select
|
||||
|
||||
from native_x11 import wait_for
|
||||
|
||||
|
||||
def update_snapshot():
|
||||
repositoryUrl = "https://example.org/tentacles"
|
||||
return {
|
||||
"Entries": [{"Entry": {
|
||||
"ID": "probe", "LocalID": "echo", "Name": "Update probe",
|
||||
"Repository": repositoryUrl, "RepositoryID": "example.source",
|
||||
"Version": "1.0", "Enabled": True, "Presentation": True,
|
||||
}, "State": "active"}],
|
||||
"Repositories": [{"URL": repositoryUrl, "ID": "example.source", "Name": "Example",
|
||||
"Tentacles": [{"ID": "echo", "Name": "Update probe", "Version": "1.1",
|
||||
"Capabilities": ["presentation", "interaction"]}]}],
|
||||
"Updates": [{"ID": "probe", "LocalID": "echo", "Name": "Update probe",
|
||||
"InstalledVersion": "1.0", "AvailableVersion": "1.1",
|
||||
"RepositoryURL": repositoryUrl, "RepositoryID": "example.source"}],
|
||||
}
|
||||
|
||||
|
||||
def check_page(pyatspi, process, window, named, walk, focus, key, request, send, initial):
|
||||
tab = next(node for node in walk(window)
|
||||
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_PAGE_TAB)
|
||||
assert tab.parent.querySelection().selectChild(tab.getIndexInParent()), "cannot select Tentacles tab"
|
||||
table = next(node for node in walk(window)
|
||||
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE)
|
||||
focus(table)
|
||||
key("Home")
|
||||
update = named("Update")
|
||||
wait_for(lambda: update.getState().contains(pyatspi.STATE_SENSITIVE), "Update did not enable")
|
||||
# Tab from the table skips disabled Install and reaches Update.
|
||||
key("Tab")
|
||||
wait_for(lambda: update.getState().contains(pyatspi.STATE_FOCUSED), "Tab did not reach Update")
|
||||
key("Tab")
|
||||
wait_for(lambda: named("Settings").getState().contains(pyatspi.STATE_FOCUSED), "Update trapped Tab")
|
||||
key("shift+Tab")
|
||||
wait_for(lambda: update.getState().contains(pyatspi.STATE_FOCUSED), "Shift+Tab did not reach Update")
|
||||
|
||||
def confirmation():
|
||||
for app in pyatspi.Registry.getDesktop(0):
|
||||
for node in app:
|
||||
if node and node.getRole() in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT):
|
||||
if any("Current capabilities:" in child.name for child in walk(node)):
|
||||
return node
|
||||
return None
|
||||
|
||||
key("space")
|
||||
dialog = wait_for(confirmation, "keyboard activation did not open confirmation")
|
||||
message = " ".join(node.name for node in walk(dialog))
|
||||
assert "version 1.0 to version 1.1" in message, message
|
||||
assert "Current capabilities: presentation." in message, message
|
||||
assert "Repository declares: presentation, interaction." in message, message
|
||||
cancel = next(node for node in walk(dialog) if node.name == "Cancel")
|
||||
focus(cancel)
|
||||
key("space")
|
||||
wait_for(lambda: not confirmation(), "Cancel did not close confirmation")
|
||||
assert not select.select([process.stdout], [], [], 0.2)[0], "Cancel sent a management request"
|
||||
|
||||
# The native button action uses the same path as keyboard activation.
|
||||
update.queryAction().doAction(0)
|
||||
dialog = wait_for(confirmation, "button action did not open confirmation")
|
||||
accept = next(node for node in walk(dialog)
|
||||
if node.name == "Update" and node.getRole() == pyatspi.ROLE_PUSH_BUTTON)
|
||||
focus(accept)
|
||||
key("Return")
|
||||
value = request()
|
||||
assert value["Tentacle"] == {"Action": "upgrade", "ID": "echo", "Entry": None,
|
||||
"URL": "https://example.org/tentacles"}, value["Tentacle"]
|
||||
send(dict(initial, Message="Update accepted."))
|
||||
wait_for(lambda: update.getState().contains(pyatspi.STATE_SENSITIVE), "response left controls busy")
|
||||
focus(named("Cancel"))
|
||||
key("space")
|
||||
assert process.wait(timeout=5) == 0
|
||||
print("PASS: Update Tab/Shift+Tab, keyboard/button activation, versions/capabilities, Cancel, upgrade request")
|
||||
@@ -2,15 +2,33 @@
|
||||
// but remains in Xlib's queue when the Go owner gives the keyboard back.
|
||||
#include "commands_native.h"
|
||||
#include <X11/keysym.h>
|
||||
#include <X11/XKBlib.h>
|
||||
#include <X11/extensions/XTest.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int error = 0, failed = 0;
|
||||
CommandDevice *device = open_commands(1, 0, 0, &error);
|
||||
int capsHandoff = argc == 2 && strcmp(argv[1], "caps-handoff") == 0;
|
||||
CommandDevice *device = open_commands(1, 0, capsHandoff, &error);
|
||||
if (!device) { fprintf(stderr, "open: %d\n", error); return 1; }
|
||||
if (argc == 2 && strcmp(argv[1], "queued") == 0) {
|
||||
if (capsHandoff) {
|
||||
set_command_keys(device, 0);
|
||||
// A terminal reader changes the real lock while Cthulhu is suspended.
|
||||
XkbLockModifiers(device->display, XkbUseCoreKbd, LockMask, LockMask);
|
||||
XSync(device->display, False);
|
||||
if (!(command_lock_state(device) & 1)) {
|
||||
fprintf(stderr, "suspended lock read used stale reader state\n");
|
||||
failed = 1;
|
||||
}
|
||||
set_command_keys(device, 1);
|
||||
if (!device->capsLocked) {
|
||||
fprintf(stderr, "resumed reader did not adopt external Caps Lock state\n");
|
||||
failed = 1;
|
||||
}
|
||||
XkbLockModifiers(device->display, XkbUseCoreKbd, LockMask, 0);
|
||||
XSync(device->display, False);
|
||||
} else if (argc == 2 && strcmp(argv[1], "queued") == 0) {
|
||||
if (XGrabKeyboard(device->display, device->root, False,
|
||||
GrabModeAsync, GrabModeAsync, CurrentTime) != GrabSuccess) return 1;
|
||||
device->active = 1;
|
||||
|
||||
Reference in new issue
Block a user