diff --git a/internal/app/app.go b/internal/app/app.go index 9113dda..2ded2c5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -305,6 +305,9 @@ func Run(ctx context.Context, options Options) error { tentacles.readerSettings = preferences } preferences.live = live + // Publish the startup active/global profile's Wine values; later global + // applies republish them through the preferences worker. + live.StoreGlobalWine(savedSettings.Wine) live.onApplication = preferences.SetApplication preferences.bindings, _ = commands.(interface { SetKeyBindings(context.Context, []input.BindingOverride) error diff --git a/internal/app/live_settings.go b/internal/app/live_settings.go index 985e902..6b3e5de 100644 --- a/internal/app/live_settings.go +++ b/internal/app/live_settings.go @@ -15,6 +15,7 @@ import ( type liveSettings struct { editFieldFocused func() bool // Installed before workers start; reads cached focus only. current atomic.Pointer[config.Settings] + globalWine atomic.Pointer[config.WineSettings] preferencesPID atomic.Int64 // Owned by the preferences window lifetime; zero when closed. application atomic.Pointer[string] requestedApplication atomic.Pointer[string] @@ -35,3 +36,24 @@ func (live *liveSettings) notify_clipboard() { } } func (live *liveSettings) Load() config.Settings { return *live.current.Load() } + +// Wine values are global-only: one list governs every prefix regardless of +// which application has focus. The preferences worker publishes them here +// only when the active/global profile changes, never when it publishes a +// per-application profile through Store, so application switching cannot +// change the governing Wine list. The Wine manager reads them at each poll +// and helper spawn. +func (live *liveSettings) StoreGlobalWine(settings config.WineSettings) { + settings.ExcludedPrograms = append([]string(nil), settings.ExcludedPrograms...) + live.globalWine.Store(&settings) +} + +func (live *liveSettings) LoadGlobalWine() config.WineSettings { + current := live.globalWine.Load() + if current == nil { + return config.WineSettings{} + } + settings := *current + settings.ExcludedPrograms = append([]string(nil), settings.ExcludedPrograms...) + return settings +} diff --git a/internal/app/preferences.go b/internal/app/preferences.go index 1a0c908..c7fa935 100644 --- a/internal/app/preferences.go +++ b/internal/app/preferences.go @@ -84,6 +84,17 @@ type preferencesWorker struct { readSpeechDefaults func(context.Context, string) (speech.Settings, error) } +// setGlobalSettings records a new active/global profile and republishes +// its global-only Wine values for the Wine manager. Per-application +// settings are cached separately and never pass through here, so focus +// changes cannot change the governing Wine list. +func (worker *preferencesWorker) setGlobalSettings(settings config.Settings) { + worker.globalSettings = settings + if worker.live != nil { + worker.live.StoreGlobalWine(settings.Wine) + } +} + func newPreferencesWorker(logger *slog.Logger, settings config.Settings, updater any) *preferencesWorker { return &preferencesWorker{logger: logger, settings: settings, globalSettings: settings, appSettings: make(map[string]config.Settings), updater: updater, extensionRequests: make(chan readerSettingsCall, 16), extensions: make(map[string]*readerExtension), speechCommands: make(chan input.Command, 16), remoteChanges: make(chan remoteSettingsChange, 16), discover: speech.Discover, readSpeechDefaults: speech.ReadDefaults, open: make(chan string, 1), applicationChanges: make(chan string, 1), done: make(chan struct{})} diff --git a/internal/app/preferences_apply.go b/internal/app/preferences_apply.go index d2a8255..849842c 100644 --- a/internal/app/preferences_apply.go +++ b/internal/app/preferences_apply.go @@ -86,7 +86,7 @@ func (worker *preferencesWorker) applyNext(ctx context.Context, next config.Sett for application, settings := range worker.appSettings { worker.appSettings[application] = config.RebaseApplicationSettings(oldGlobal, next, settings) } - worker.globalSettings = next + worker.setGlobalSettings(next) } message := "Settings applied for this session." if save { diff --git a/internal/app/progress_settings.go b/internal/app/progress_settings.go index 5afd2fc..e907ded 100644 --- a/internal/app/progress_settings.go +++ b/internal/app/progress_settings.go @@ -29,7 +29,7 @@ func (worker *preferencesWorker) cycle_progress_output() { for application, settings := range worker.appSettings { worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, profileNext, settings) } - worker.globalSettings = profileNext + worker.setGlobalSettings(profileNext) } worker.commit_settings(next) worker.announceSpeechSetting(message) diff --git a/internal/app/runtime_remote.go b/internal/app/runtime_remote.go index fe8b699..04cc4f5 100644 --- a/internal/app/runtime_remote.go +++ b/internal/app/runtime_remote.go @@ -43,6 +43,24 @@ type remoteServiceDependencies struct { 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 @@ -130,12 +148,7 @@ 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...) - } + wineManager := newWineManager(deps.logger, deps.live) services = append(services, lifecycle.Service{ Name: "Wine accessibility", Start: wineManager.Start, Stop: wineManager.Stop, }) diff --git a/internal/app/speech_settings.go b/internal/app/speech_settings.go index 035d74a..ca9abd7 100644 --- a/internal/app/speech_settings.go +++ b/internal/app/speech_settings.go @@ -229,7 +229,7 @@ func (worker *preferencesWorker) handleSpeechCommand(ctx context.Context, comman for application, settings := range worker.appSettings { worker.appSettings[application] = config.RebaseApplicationSettings(worker.globalSettings, global, settings) } - worker.globalSettings = global + worker.setGlobalSettings(global) worker.settings = next } if worker.live != nil { diff --git a/internal/app/wine_live_settings_test.go b/internal/app/wine_live_settings_test.go new file mode 100644 index 0000000..1a150ad --- /dev/null +++ b/internal/app/wine_live_settings_test.go @@ -0,0 +1,98 @@ +package app + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "reflect" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" + "git.stormux.org/storm/cthulhu/internal/speech" + "git.stormux.org/storm/cthulhu/internal/wine" +) + +// Wine settings are live and global-only: editing the TOML and applying the +// reloaded global profile changes what the Wine manager will use at its next +// poll or spawn, with no reader restart, while switching focus between +// applications with their own profiles never changes the governing list. +func TestWineManagerReadsLiveGlobalSettingsFromTOML(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.toml") + applications := "[applications]\n" + + "'Example Editor' = 'editor'\n" + + "'Other Editor' = 'other'\n" + + "[profiles.editor.wine]\n" + + "controller-only = true\n" + + "excluded-hook-programs = ['EditorOnly.exe']\n" + + "[profiles.other.wine]\n" + + "excluded-hook-programs = ['OtherOnly.exe']\n" + writeSettings := func(programs string) { + t.Helper() + contents := "schema-version = 2\nactive-profile = 'default'\n" + + "[profiles.default.wine]\n" + + "excluded-hook-programs = [" + programs + "]\n" + applications + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + } + writeSettings("'Game.exe'") + store, err := config.NewStore(path) + if err != nil { + t.Fatal(err) + } + global, err := store.Load() + if err != nil { + t.Fatal(err) + } + worker := newPreferencesWorker(slog.New(slog.DiscardHandler), global, + settingsUpdateFunc(func(context.Context, speech.Settings) error { return nil })) + worker.live = &liveSettings{} + worker.live.Store(global) + worker.live.StoreGlobalWine(global.Wine) + manager := newWineManager(slog.New(slog.DiscardHandler), worker.live) + if manager.SettingsSource == nil { + t.Fatal("Wine manager has no live settings source") + } + governing := func() wine.Settings { return manager.SettingsSource() } + if want := (wine.Settings{ExcludedPrograms: []string{"Game.exe"}}); !reflect.DeepEqual(governing(), want) { + t.Fatalf("governing Wine settings = %+v, want %+v", governing(), want) + } + + // Edit the TOML, reload it, and apply the loaded global profile through + // the same path preferences Apply uses. The same manager instance must + // now govern with the addition: no reader or manager restart happened. + ctx := context.Background() + writeSettings("'Game.exe', 'New Game.exe'") + reloaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + if response := worker.applyNext(ctx, reloaded, false, false); !response.Accepted { + t.Fatalf("reloaded TOML settings were not applied: %+v", response) + } + want := wine.Settings{ExcludedPrograms: []string{"Game.exe", "New Game.exe"}} + if !reflect.DeepEqual(governing(), want) { + t.Fatalf("governing Wine settings after live addition = %+v, want %+v", governing(), want) + } + + // Moving focus to an application whose profile carries different Wine + // values changes the reader's effective settings, but must never change + // which Wine list governs a prefix. + worker.switchApplication(ctx, "Example Editor") + if effective := worker.live.Load().Wine; !effective.ControllerOnly || + !reflect.DeepEqual(effective.ExcludedPrograms, []string{"EditorOnly.exe"}) { + t.Fatalf("application switch did not change effective Wine settings: %+v", effective) + } + if !reflect.DeepEqual(governing(), want) { + t.Fatalf("application profile changed governing Wine settings: %+v, want %+v", governing(), want) + } + worker.switchApplication(ctx, "Other Editor") + if effective := worker.live.Load().Wine; effective.ControllerOnly || + !reflect.DeepEqual(effective.ExcludedPrograms, []string{"OtherOnly.exe"}) { + t.Fatalf("second application switch did not change effective Wine settings: %+v", effective) + } + if !reflect.DeepEqual(governing(), want) { + t.Fatalf("second application profile changed governing Wine settings: %+v, want %+v", governing(), want) + } +} diff --git a/internal/wine/diagnostics_test.go b/internal/wine/diagnostics_test.go index 981a08f..db55ddc 100644 --- a/internal/wine/diagnostics_test.go +++ b/internal/wine/diagnostics_test.go @@ -92,7 +92,7 @@ func TestManagerCapturesActualHelperDiagnosticsAndBoundsInheritedStderr(t *testi manager := &Manager{helper: helper, logger: slog.New(handler)} child := &process{} started := time.Now() - if err := manager.launch(child, prefix{path: root}, started); err != nil { + if err := manager.launch(child, prefix{path: root}, started, Settings{}); err != nil { t.Fatal(err) } select { @@ -124,7 +124,7 @@ func TestManagerExpectedStopIsNotAHelperFailure(t *testing.T) { handler := &channelLogHandler{records: make(chan slog.Record, 32)} manager := &Manager{helper: helper, logger: slog.New(handler)} child := &process{} - if err := manager.launch(child, prefix{path: t.TempDir()}, time.Now()); err != nil { + if err := manager.launch(child, prefix{path: t.TempDir()}, time.Now(), Settings{}); err != nil { t.Fatal(err) } stop(child) @@ -208,11 +208,11 @@ func TestHelperStartRecordMirrorsProgramsAndExtra(t *testing.T) { t.Fatal(err) } handler := &channelLogHandler{records: make(chan slog.Record, 32)} - manager := &Manager{helper: helper, logger: slog.New(handler), - ExcludedPrograms: []string{"Game.EXE", "game.exe", "Another.exe"}} + manager := &Manager{helper: helper, logger: slog.New(handler)} child := &process{} source := prefix{path: root, programs: map[string]bool{"launcher.exe": true, "game.exe": true}} - if err := manager.launch(child, source, time.Now()); err != nil { + settings := Settings{ExcludedPrograms: []string{"Game.EXE", "game.exe", "Another.exe"}} + if err := manager.launch(child, source, time.Now(), settings); err != nil { t.Fatal(err) } defer stop(child) diff --git a/internal/wine/live_settings_test.go b/internal/wine/live_settings_test.go new file mode 100644 index 0000000..6dff32a --- /dev/null +++ b/internal/wine/live_settings_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package wine + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The manager must read its settings source at each poll and helper spawn, +// never a snapshot taken when it was constructed. A live addition for a +// currently discovered program restarts the running helper once in the same +// mode, and removals never downgrade a running helper. +func TestManagerReadsSettingsSourceAtEachPoll(t *testing.T) { + t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "") + root := t.TempDir() + procRoot := filepath.Join(root, "proc") + loader := filepath.Join(root, "wine") + write_helper_file(t, loader, "loader") + base := filepath.Join(procRoot, "101") + write_helper_file(t, filepath.Join(base, "cmdline"), "launcher.exe\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) + } + helper := filepath.Join(root, "helper") + write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n") + + current := Settings{} + reads := 0 + manager := New(nil) + manager.helper, manager.procRoot = helper, procRoot + manager.SettingsSource = func() Settings { + reads++ + return current + } + 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("helper did not start normally") + } + if len(child.exclusions) != 0 { + t.Fatalf("initial exclusions = %v", child.exclusions) + } + if reads == 0 { + t.Fatal("poll did not read the settings source") + } + pid := child.cmd.Process.Pid + + // A live addition naming a currently discovered program reaches the + // running helper at the next poll: one restart, same mode, flag added. + readsBefore := reads + current = Settings{ExcludedPrograms: []string{"Launcher.exe"}} + manager.poll(active) + if reads <= readsBefore { + t.Fatal("later poll did not re-read the settings source") + } + if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid { + t.Fatal("live addition did not restart the helper in the same mode") + } + if !child.exclusions["launcher.exe"] { + t.Fatal("restarted helper lacks the live exclusion flag") + } + if got := strings.Join(child.cmd.Args[1:], " "); got != "--exclude-hook-program launcher.exe" { + t.Fatalf("restarted helper arguments = %q", got) + } + pid = child.cmd.Process.Pid + manager.poll(active) + if child.cmd.Process.Pid != pid { + t.Fatal("helper restarted more than once for one live addition") + } + + // Removing the listing never restarts or downgrades the running helper. + current = Settings{} + manager.poll(active) + if child.cmd.Process.Pid != pid { + t.Fatal("helper restarted when a live listing was removed") + } + + // Turning the global switch on live upgrades the helper wholesale; + // clearing it live never downgrades the running helper. + current = Settings{ControllerOnly: true} + manager.poll(active) + if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid { + t.Fatal("live global switch did not upgrade the helper wholesale") + } + pid = child.cmd.Process.Pid + current = Settings{} + manager.poll(active) + if !child.controllerOnly || child.cmd.Process.Pid != pid { + t.Fatal("wholesale helper downgraded after the live switch was cleared") + } +} diff --git a/internal/wine/manager.go b/internal/wine/manager.go index 5213264..9db4ed6 100644 --- a/internal/wine/manager.go +++ b/internal/wine/manager.go @@ -46,14 +46,26 @@ type process struct { readyExclusions atomic.Int64 } -type Manager struct { - logger *slog.Logger - helper string - procRoot string - cancel context.CancelFunc - done chan struct{} +// Settings is one snapshot of the reader's Wine values. The manager never +// keeps its own copy across polls: each poll and helper spawn works from a +// fresh snapshot, so settings applied after startup take effect with no +// reader restart. +type Settings struct { ControllerOnly bool ExcludedPrograms []string +} + +type Manager struct { + logger *slog.Logger + helper string + procRoot string + cancel context.CancelFunc + done chan struct{} + // SettingsSource supplies the live Wine settings. It is installed + // before Start and read only by the poll goroutine, once per poll; + // spawns made by that poll use that same snapshot. A nil source + // yields empty settings. + SettingsSource func() Settings // unconfirmed remembers helper binaries whose ready record did not // confirm the exclusions sent, keyed by launched executable path. // Later spawns of such a binary go wholesale directly. @@ -165,8 +177,19 @@ func (manager *Manager) run(ctx context.Context) { } } +func (manager *Manager) current_settings() Settings { + if manager.SettingsSource == nil { + return Settings{} + } + return manager.SettingsSource() +} + func (manager *Manager) poll(active map[string]*process) { now := time.Now() + // Read the live settings once per poll. Restart decisions and every + // spawn in this poll use this snapshot, so a value applied between + // polls governs the next poll and spawn without a reader restart. + settings := manager.current_settings() discovered := discover(manager.procRoot, uint32(os.Getuid())) for path, source := range discovered { child := active[path] @@ -186,7 +209,7 @@ func (manager *Manager) poll(active map[string]*process) { child.next = now.Add(backoff(child.failures)) child.cmd = nil default: - if manager.restart_running_helper(child, source) { + if manager.restart_running_helper(child, source, settings) { stop(child) } else { continue @@ -196,7 +219,7 @@ func (manager *Manager) poll(active map[string]*process) { if now.Before(child.next) { continue } - if err := manager.launch(child, source, now); err != nil { + if err := manager.launch(child, source, now, settings); err != nil { child.failures++ child.next = now.Add(backoff(child.failures)) manager.logger.Warn("start Wine accessibility helper", "error", err) @@ -214,11 +237,11 @@ func (manager *Manager) poll(active map[string]*process) { // gain protection it lacks. Restarts only ever add protection: a wholesale // helper never restarts here, and removing a listing or clearing the // global switch never downgrades a running helper. -func (manager *Manager) restart_running_helper(child *process, source prefix) bool { +func (manager *Manager) restart_running_helper(child *process, source prefix, settings Settings) bool { if child.controllerOnly { return false } - if manager.wholesale_controller_only() || manager.helper_unconfirmed(child.launchedHelper) { + if wholesale_controller_only(settings) || manager.helper_unconfirmed(child.launchedHelper) { return true } if child.readySeen.Load() && int(child.readyExclusions.Load()) != len(child.exclusions) { @@ -230,7 +253,7 @@ func (manager *Manager) restart_running_helper(child *process, source prefix) bo // A newly listed program that is currently discovered but lacks its // flag restarts the helper once, in the same mode, now carrying it. // Listings for programs that are not running wait for the next spawn. - for _, name := range manager.exclusion_names() { + for _, name := range exclusion_names(settings) { if source.programs[name] && !child.exclusions[name] { return true } @@ -245,14 +268,14 @@ func backoff(failures int) time.Duration { return time.Duration(1< 255 || strings.TrimSpace(name) != name || strings.ContainsAny(name, "/\\") || strings.ContainsFunc(name, func(char rune) bool { return char < ' ' }) { @@ -94,9 +94,9 @@ func (manager *Manager) exclusion_names() []string { return names } -func (manager *Manager) helper_arguments(controllerOnly bool) []string { +func helper_arguments(controllerOnly bool, settings Settings) []string { var arguments []string - for _, name := range manager.exclusion_names() { + for _, name := range exclusion_names(settings) { arguments = append(arguments, "--exclude-hook-program", name) } if controllerOnly { diff --git a/internal/wine/proton_test.go b/internal/wine/proton_test.go index 60c2308..33233ed 100644 --- a/internal/wine/proton_test.go +++ b/internal/wine/proton_test.go @@ -88,24 +88,24 @@ func TestListedProgramDoesNotForceWholesaleMode(t *testing.T) { // A listed program is gated natively by the helper. Its presence must // never add the prefix-wide --no-dialog-reader, or ordinary programs in // the same prefix lose dialog reading. - manager := &Manager{ExcludedPrograms: []string{"Game.EXE", "game.exe"}} - if manager.wholesale_controller_only() { + listed := Settings{ExcludedPrograms: []string{"Game.EXE", "game.exe"}} + if wholesale_controller_only(listed) { t.Fatal("listed programs selected wholesale mode") } - arguments := manager.helper_arguments(manager.wholesale_controller_only()) + arguments := helper_arguments(wholesale_controller_only(listed), listed) if len(arguments) != 2 || arguments[0] != "--exclude-hook-program" || arguments[1] != "game.exe" { t.Fatalf("mixed-prefix arguments = %v", arguments) } - global := &Manager{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}} - if !global.wholesale_controller_only() { + global := Settings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}} + if !wholesale_controller_only(global) { t.Fatal("global controller-only switch ignored") } - arguments = global.helper_arguments(true) + arguments = helper_arguments(true, global) if arguments[len(arguments)-1] != "--no-dialog-reader" { t.Fatalf("global arguments = %v", arguments) } t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1") - if !(&Manager{}).wholesale_controller_only() { + if !wholesale_controller_only(Settings{}) { t.Fatal("environment override ignored") } } @@ -134,7 +134,7 @@ func TestLaunchForwardsProtonSyncAndKeepsReaderBus(t *testing.T) { 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 { + if err := manager.launch(child, source, time.Now(), Settings{}); err != nil { t.Fatal(err) } select { @@ -172,9 +172,10 @@ func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) { makeProcess("101", "launcher.exe") helper := filepath.Join(root, "helper") write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n") + current := Settings{ExcludedPrograms: []string{"GAME.exe"}} manager := New(nil) manager.helper, manager.procRoot = helper, procRoot - manager.ExcludedPrograms = []string{"GAME.exe"} + manager.SettingsSource = func() Settings { return current } active := map[string]*process{} defer func() { for _, child := range active { @@ -215,14 +216,14 @@ func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) { } // A listing for a program that is not running waits for the next // natural spawn: no restart for prospective protection. - manager.ExcludedPrograms = []string{"GAME.exe", "Absent.exe"} + current.ExcludedPrograms = []string{"GAME.exe", "Absent.exe"} manager.poll(active) if child.cmd.Process.Pid != originalPID { t.Fatal("helper restarted for a listed program that is not running") } // A newly listed program that is currently discovered but lacks its // flag restarts the helper once, in the same mode, carrying the flag. - manager.ExcludedPrograms = []string{"GAME.exe", "Notepad.exe"} + current.ExcludedPrograms = []string{"GAME.exe", "Notepad.exe"} manager.poll(active) if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID { t.Fatal("newly listed running program did not restart the helper in place") @@ -236,20 +237,20 @@ func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) { t.Fatal("helper restarted more than once for one listing") } // Removing a listing never downgrades or restarts a running helper. - manager.ExcludedPrograms = []string{"GAME.exe"} + current.ExcludedPrograms = []string{"GAME.exe"} manager.poll(active) if child.cmd.Process.Pid != pid { t.Fatal("helper restarted when a listing was removed") } // The global switch still upgrades a running helper to wholesale, and // clearing it never downgrades the running helper. - manager.ControllerOnly = true + current.ControllerOnly = true manager.poll(active) if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid { t.Fatal("global switch did not upgrade the helper to wholesale") } pid = child.cmd.Process.Pid - manager.ControllerOnly = false + current.ControllerOnly = false manager.poll(active) if !child.controllerOnly || child.cmd.Process.Pid != pid { t.Fatal("wholesale helper downgraded after the global switch cleared") @@ -276,7 +277,9 @@ func TestUnconfirmedHelperRestartsWholesaleOnceAndIsRemembered(t *testing.T) { write_helper_file(t, helper, script) manager := New(nil) manager.helper, manager.procRoot = helper, procRoot - manager.ExcludedPrograms = []string{"Game.exe"} + manager.SettingsSource = func() Settings { + return Settings{ExcludedPrograms: []string{"Game.exe"}} + } active := map[string]*process{} defer func() { for _, child := range active {