Files
cthulhu/internal/app/runtime_remote.go
hjozwiak 9a10a15b68 Read Wine settings live at poll and spawn
Phase 2 of the Wine/Proton stage (decisions 3, 4, 16, 17, 19 in docs/testing/wine-proton.md): replace the Wine manager's startup snapshot with a live settings source read once per poll, so accepted global settings take effect with no reader restart. The source publishes only the active/global profile's Wine values; per-application profile switching never changes the governing list. A newly listed running program restarts its helper once in the same mode; removals and clearing the global switch never downgrade a running helper.
2026-11-02 01:15:33 -05:00

157 lines
5.7 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package app
import (
"context"
"log/slog"
"strconv"
"time"
"git.stormux.org/storm/cthulhu/internal/lifecycle"
"git.stormux.org/storm/cthulhu/internal/nvda2cthulhu"
"git.stormux.org/storm/cthulhu/internal/remote"
"git.stormux.org/storm/cthulhu/internal/wine"
)
// remoteServiceDependencies are held by the service closures for the lifetime
// of the runner. The input and adapter counters are read only on snapshot calls.
type remoteServiceDependencies struct {
logger *slog.Logger
stopRun context.CancelFunc
adapter Adapter
inputListener InputListener
speech *speechWorker
braille *brailleWorker
live *liveSettings
preferences *preferencesWorker
onPreferences func()
}
// newWineManager wires the Wine manager to the reader's live settings. The
// manager reads this source at each poll and helper spawn instead of a
// startup snapshot, so preferences Apply and equivalent global-settings
// paths take effect with no reader restart. The source returns only the
// active/global profile's Wine values: per-application profile switching
// publishes through liveSettings.Store and never reaches this source.
func newWineManager(logger *slog.Logger, live *liveSettings) *wine.Manager {
manager := wine.New(logger)
manager.SettingsSource = func() wine.Settings {
if live == nil {
return wine.Settings{}
}
settings := live.LoadGlobalWine()
return wine.Settings{ControllerOnly: settings.ControllerOnly, ExcludedPrograms: settings.ExcludedPrograms}
}
return manager
}
func newRemoteRuntimeServices(deps remoteServiceDependencies) []lifecycle.Service {
remoteHandler := remoteSpeech{braille: lockFeedbackBraille{deps.braille}}
remoteHandler.live = deps.live
remoteHandler.preferences = deps.preferences
remoteHandler.showPreferences = deps.onPreferences
remoteHandler.quit = func() { time.AfterFunc(100*time.Millisecond, deps.stopRun) }
remoteHandler.runtimeSnapshot = func() string {
snapshot := "CTHULHU RUNTIME STATE SNAPSHOT\n[event_manager]\noverflow: " + strconv.FormatUint(deps.adapter.Overflow(), 10)
if deps.inputListener != nil {
snapshot += "\n[input_event_manager]\noverflow: " + strconv.FormatUint(deps.inputListener.Overflow(), 10)
}
return snapshot
}
if deps.speech != nil {
remoteHandler.speech = deps.speech
}
wineEvents := newWineEventPresenter(func(message string) bool {
return remoteHandler.presentMessage(message, "wine-accessible-event")
})
wineEvents.logger = deps.logger
wineEvents.foreground, _ = deps.adapter.(foregroundMonitor)
wineEvents.titles, _ = deps.adapter.(windowTitleSource)
if deps.live != nil {
nativeEditField := deps.live.editFieldFocused
deps.live.editFieldFocused = func() bool {
return wineEvents.EditFieldFocused() || nativeEditField != nil && nativeEditField()
}
}
connectWineWindowTitles(wineEvents, deps.live, deps.adapter)
remoteHandler.wine = wineEvents
if deps.live != nil {
remoteHandler.wineReview = deps.live.wineReview
}
remoteService := remote.New(deps.logger, remoteHandler)
services := []lifecycle.Service{{
Name: "self-voice D-Bus",
Start: func(ctx context.Context) error {
if err := remoteService.Start(ctx); err != nil {
deps.logger.Warn("Cthulhu D-Bus service unavailable", "error", err)
}
return nil
},
Stop: func(ctx context.Context) error {
wineEvents.Close()
if err := remoteService.Stop(ctx); err != nil {
deps.logger.Warn("Cthulhu D-Bus service shutdown", "error", err)
}
return nil
},
}}
if remoteHandler.wineReview != nil {
registry := remoteHandler.wineReview
// Reverse shutdown cancels captures before the borrowed service bus closes.
services = append(services, lifecycle.Service{Name: "Wine flat review", Start: func(ctx context.Context) error {
if err := registry.Start(ctx); err != nil {
deps.logger.Warn("Wine flat review unavailable", "error", err)
}
return nil
}, Stop: registry.Stop})
}
orcaService := remote.NewOrca(deps.logger, remoteHandler)
services = append(services, lifecycle.Service{
Name: "Orca compatibility D-Bus",
Start: func(ctx context.Context) error {
if err := orcaService.Start(ctx); err != nil {
deps.logger.Warn("Orca compatibility D-Bus service unavailable", "error", err)
}
return nil
},
Stop: func(ctx context.Context) error {
if err := orcaService.Stop(ctx); err != nil {
deps.logger.Warn("Orca compatibility D-Bus service shutdown", "error", err)
}
return nil
},
})
bridgeServer := nvda2cthulhu.New(deps.logger, remoteHandler)
services = append(services, lifecycle.Service{
Name: "NVDA to Cthulhu bridge",
Start: func(ctx context.Context) error {
if err := bridgeServer.Start(ctx); err != nil {
deps.logger.Warn("NVDA to Cthulhu bridge unavailable", "error", err)
}
return nil
},
Stop: bridgeServer.Stop,
})
wineManager := newWineManager(deps.logger, deps.live)
services = append(services, lifecycle.Service{
Name: "Wine accessibility", Start: wineManager.Start, Stop: wineManager.Stop,
})
return services
}