From 57980225ad69ab5f7a8c5235c926809d4771e1a7 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 12 May 2026 02:06:07 -0400 Subject: [PATCH] Implemented local settings save in ~/.local/share/stormux. Also, I think I got interrupt on any keypress working better when using -x. --- src/fenrir | 2 +- .../commands/commands/save_settings.py | 5 +- .../core/dynamicKeyboardLayoutMenu.py | 3 +- src/fenrirscreenreader/core/remoteManager.py | 6 - .../core/settingsManager.py | 115 ++++++++++++++---- src/fenrirscreenreader/fenrirVersion.py | 2 +- .../screenDriver/ptyDriver.py | 15 +++ tests/unit/test_pty_terminal_sequences.py | 57 +++++++++ tests/unit/test_settings_validation.py | 92 ++++++++++++++ 9 files changed, 262 insertions(+), 35 deletions(-) diff --git a/src/fenrir b/src/fenrir index c6fc9aa8..cf2420e5 100755 --- a/src/fenrir +++ b/src/fenrir @@ -41,7 +41,7 @@ def create_argument_parser(): argumentParser.add_argument( '-s', '--setting', metavar='SETTING-FILE', - default='/etc/fenrir/settings/settings.conf', + default=None, help='Path to custom settings file' ) argumentParser.add_argument( diff --git a/src/fenrirscreenreader/commands/commands/save_settings.py b/src/fenrirscreenreader/commands/commands/save_settings.py index f34efad8..782231c5 100644 --- a/src/fenrirscreenreader/commands/commands/save_settings.py +++ b/src/fenrirscreenreader/commands/commands/save_settings.py @@ -22,10 +22,7 @@ class command: return _("Saves your current Fenrir settings so they are the default.") def run(self): - settings_file = self.env["runtime"][ - "SettingsManager" - ].get_settings_file() - self.env["runtime"]["SettingsManager"].save_settings(settings_file) + self.env["runtime"]["SettingsManager"].save_settings() self.env["runtime"]["OutputManager"].present_text( _("Settings saved."), interrupt=True ) diff --git a/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py b/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py index 50c876ac..100d35d9 100644 --- a/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py +++ b/src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py @@ -48,8 +48,7 @@ class DynamicKeyboardLayoutCommand: ) # Save to the actual config file - configFilePath = settingsManager.get_settings_file() - settingsManager.save_settings(configFilePath) + settingsManager.save_settings() self.env["runtime"]["OutputManager"].present_text( f"Keyboard layout set to {self.layoutName}. Please restart Fenrir for this change to take effect." diff --git a/src/fenrirscreenreader/core/remoteManager.py b/src/fenrirscreenreader/core/remoteManager.py index 37fa8c30..24150216 100644 --- a/src/fenrirscreenreader/core/remoteManager.py +++ b/src/fenrirscreenreader/core/remoteManager.py @@ -464,12 +464,6 @@ class RemoteManager: ) def save_settings(self, setting_config_path=None): - if not setting_config_path: - setting_config_path = self.env["runtime"][ - "SettingsManager" - ].get_settings_file() - if setting_config_path == "": - return self.env["runtime"]["SettingsManager"].save_settings( setting_config_path ) diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 8105bc46..f7e6c9a2 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -42,11 +42,18 @@ fenrir_path = os.path.dirname(currentdir) class SettingsManager: + system_settings_root = "/etc/fenrirscreenreader/" + system_settings_file = "/etc/fenrirscreenreader/settings/settings.conf" + user_settings_file = ( + "~/.local/share/stormux/fenrirscreenreader/settings/settings.conf" + ) + def __init__(self): self.settings = settings_data self.settingArgDict = {} self.bindingsBackup = None self.settings_file = "" + self.save_settings_path = "" def initialize(self, environment): self.env = environment @@ -104,12 +111,57 @@ class SettingsManager: return self.settings_file def set_settings_file(self, settings_file): - if not os.path.exists(settings_file): - return - if not os.access(settings_file, os.R_OK): + if os.path.exists(settings_file) and not os.access( + settings_file, os.R_OK + ): return self.settings_file = settings_file + def get_user_settings_file(self): + return os.path.expanduser(self.user_settings_file) + + def get_system_settings_file(self): + return self.system_settings_file + + def is_user_settings_mode(self): + return os.geteuid() != 0 + + def get_default_save_settings_file(self): + if self.is_user_settings_mode(): + return self.get_user_settings_file() + return self.get_system_settings_file() + + def get_bundled_settings_root(self): + return os.path.abspath(os.path.join(fenrir_path, "../../config/")) + "/" + + def get_resource_settings_root(self): + if os.path.exists(self.system_settings_root): + return self.system_settings_root + bundled_settings_root = self.get_bundled_settings_root() + if os.path.exists(bundled_settings_root): + return bundled_settings_root + return "" + + def resolve_settings_file(self, requested_settings_file=None): + if requested_settings_file and os.path.exists(requested_settings_file): + return requested_settings_file + + if self.is_user_settings_mode() and os.path.exists( + self.get_user_settings_file() + ): + return self.get_user_settings_file() + + if os.path.exists(self.get_system_settings_file()): + return self.get_system_settings_file() + + bundled_settings_file = os.path.join( + self.get_bundled_settings_root(), "settings/settings.conf" + ) + if os.path.exists(bundled_settings_file): + return bundled_settings_file + + return "" + def load_settings(self, setting_config_path): if not os.path.exists(setting_config_path): return False @@ -128,7 +180,29 @@ class SettingsManager: self.set_settings_file(setting_config_path) return True - def save_settings(self, setting_config_path): + def save_settings(self, setting_config_path=None): + if setting_config_path is None: + if self.save_settings_path: + setting_config_path = self.save_settings_path + else: + setting_config_path = self.get_default_save_settings_file() + + # Ensure directory exists for user-local settings + if setting_config_path and setting_config_path.startswith( + os.path.expanduser("~/.local/") + ): + config_dir = os.path.dirname(setting_config_path) + if not os.path.exists(config_dir): + try: + os.makedirs(config_dir, mode=0o755, exist_ok=True) + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + "save_settings: failed to create directory " + + config_dir + + ": " + + str(e), + debug.DebugLevel.ERROR, + ) # set opt dict here # save file try: @@ -458,25 +532,24 @@ class SettingsManager: def init_fenrir_config( self, cliArgs, fenrir_manager=None, environment=environment.environment ): - settings_root = "/etc/fenrirscreenreader/" - settings_file = cliArgs.setting + self.cliArgs = cliArgs + + settings_root = self.get_resource_settings_root() + if not settings_root: + return None + sound_root = "/usr/share/sounds/fenrirscreenreader/" - # get fenrir settings root - if not os.path.exists(settings_root): - if os.path.exists(fenrir_path + "/../../config/"): - settings_root = fenrir_path + "/../../config/" - else: - return None - # get settings file - if settings_file is None or not os.path.exists(settings_file): - if os.path.exists(settings_root + "/settings/settings.conf"): - settings_file = settings_root + "/settings/settings.conf" - else: - return None - # get sound themes root if not os.path.exists(sound_root): - if os.path.exists(fenrir_path + "/../../config/sound/"): - sound_root = fenrir_path + "/../../config/sound/" + bundled_sound_root = os.path.join( + self.get_bundled_settings_root(), "sound/" + ) + if os.path.exists(bundled_sound_root): + sound_root = bundled_sound_root + + self.save_settings_path = self.get_default_save_settings_file() + settings_file = self.resolve_settings_file(cliArgs.setting) + if not settings_file: + return None environment["runtime"]["SettingsManager"] = self environment["runtime"]["SettingsManager"].initialize(environment) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 64462039..3d1a410e 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.05.10" +version = "2026.05.12" code_name = "testing" diff --git a/src/fenrirscreenreader/screenDriver/ptyDriver.py b/src/fenrirscreenreader/screenDriver/ptyDriver.py index aa44c601..ac3212c8 100644 --- a/src/fenrirscreenreader/screenDriver/ptyDriver.py +++ b/src/fenrirscreenreader/screenDriver/ptyDriver.py @@ -288,6 +288,20 @@ class driver(screenDriver): msg_bytes = bytes(msg_bytes, "UTF-8") os.write(screen, msg_bytes) + def interrupt_output_on_stdin_input(self, msg_bytes): + if not msg_bytes: + return + settings_manager = self.env["runtime"]["SettingsManager"] + if not settings_manager.get_setting_as_bool( + "keyboard", "interrupt_on_key_press" + ): + return + if settings_manager.get_setting( + "keyboard", "interrupt_on_key_press_filter" + ).strip(): + return + self.env["runtime"]["OutputManager"].interrupt_output() + def get_session_information(self): self.env["screen"]["autoIgnoreScreens"] = [] self.env["general"]["prev_user"] = getpass.getuser() @@ -420,6 +434,7 @@ class driver(screenDriver): ) break try: + self.interrupt_output_on_stdin_input(msg_bytes) self.inject_text_to_screen(msg_bytes) except Exception as e: self.env["runtime"][ diff --git a/tests/unit/test_pty_terminal_sequences.py b/tests/unit/test_pty_terminal_sequences.py index 6ad7fbb5..7bed7b51 100644 --- a/tests/unit/test_pty_terminal_sequences.py +++ b/tests/unit/test_pty_terminal_sequences.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import Mock from fenrirscreenreader.screenDriver.ptyDriver import PTYConstants from fenrirscreenreader.screenDriver.ptyDriver import Terminal @@ -52,3 +53,59 @@ def test_optional_float_setting_uses_default_when_missing(): ) == PTYConstants.OUTPUT_READ_TIMEOUT ) + + +@pytest.mark.unit +def test_pty_stdin_input_interrupts_output_when_all_keys_interrupt_enabled(): + pty_driver = PtyDriver() + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = True + settings_manager.get_setting.return_value = "" + output_manager = Mock() + pty_driver.env = { + "runtime": { + "SettingsManager": settings_manager, + "OutputManager": output_manager, + } + } + + pty_driver.interrupt_output_on_stdin_input(b"a") + + output_manager.interrupt_output.assert_called_once_with() + + +@pytest.mark.unit +def test_pty_stdin_input_honors_interrupt_disabled(): + pty_driver = PtyDriver() + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = False + output_manager = Mock() + pty_driver.env = { + "runtime": { + "SettingsManager": settings_manager, + "OutputManager": output_manager, + } + } + + pty_driver.interrupt_output_on_stdin_input(b"a") + + output_manager.interrupt_output.assert_not_called() + + +@pytest.mark.unit +def test_pty_stdin_input_leaves_filtered_interrupts_to_key_events(): + pty_driver = PtyDriver() + settings_manager = Mock() + settings_manager.get_setting_as_bool.return_value = True + settings_manager.get_setting.return_value = "KEY_ENTER" + output_manager = Mock() + pty_driver.env = { + "runtime": { + "SettingsManager": settings_manager, + "OutputManager": output_manager, + } + } + + pty_driver.interrupt_output_on_stdin_input(b"a") + + output_manager.interrupt_output.assert_not_called() diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py index e5beeda7..5e65115c 100644 --- a/tests/unit/test_settings_validation.py +++ b/tests/unit/test_settings_validation.py @@ -8,10 +8,12 @@ for all configurable settings that could cause crashes or accessibility issues. import pytest import sys from pathlib import Path +from unittest.mock import Mock # Import the settings manager from fenrirscreenreader.core.settingsData import settings_data from fenrirscreenreader.core.settingsManager import SettingsManager +from fenrirscreenreader.commands.commands import save_settings @pytest.mark.unit @@ -198,3 +200,93 @@ class TestValidationSkipsUnknownSettings: def test_focus_settings_define_tui_toggle(): """Focus settings should include the TUI toggle used by on-screen handlers.""" assert settings_data["focus"]["tui"] is False + + +@pytest.mark.unit +@pytest.mark.settings +class TestSettingsPathSelection: + """Test root/user settings load and save path selection.""" + + def setup_method(self): + self.manager = SettingsManager() + + def configure_paths(self, tmp_path): + system_root = tmp_path / "etc" / "fenrirscreenreader" + system_file = system_root / "settings" / "settings.conf" + user_file = ( + tmp_path + / "home" + / "Username" + / ".local" + / "share" + / "stormux" + / "fenrirscreenreader" + / "settings" + / "settings.conf" + ) + self.manager.system_settings_root = str(system_root) + "/" + self.manager.system_settings_file = str(system_file) + self.manager.user_settings_file = str(user_file) + return system_file, user_file + + def test_non_root_loads_user_settings_when_present( + self, tmp_path, monkeypatch + ): + system_file, user_file = self.configure_paths(tmp_path) + system_file.parent.mkdir(parents=True) + system_file.write_text("[general]\n", encoding="utf-8") + user_file.parent.mkdir(parents=True) + user_file.write_text("[general]\n", encoding="utf-8") + monkeypatch.setattr("os.geteuid", lambda: 1000) + + assert self.manager.resolve_settings_file() == str(user_file) + + def test_non_root_falls_back_to_system_settings( + self, tmp_path, monkeypatch + ): + system_file, _user_file = self.configure_paths(tmp_path) + system_file.parent.mkdir(parents=True) + system_file.write_text("[general]\n", encoding="utf-8") + monkeypatch.setattr("os.geteuid", lambda: 1000) + + assert self.manager.resolve_settings_file() == str(system_file) + + def test_root_uses_system_settings_even_when_user_settings_exists( + self, tmp_path, monkeypatch + ): + system_file, user_file = self.configure_paths(tmp_path) + system_file.parent.mkdir(parents=True) + system_file.write_text("[general]\n", encoding="utf-8") + user_file.parent.mkdir(parents=True) + user_file.write_text("[general]\n", encoding="utf-8") + monkeypatch.setattr("os.geteuid", lambda: 0) + + assert self.manager.resolve_settings_file() == str(system_file) + + def test_save_default_path_follows_effective_user( + self, tmp_path, monkeypatch + ): + system_file, user_file = self.configure_paths(tmp_path) + + monkeypatch.setattr("os.geteuid", lambda: 1000) + assert self.manager.get_default_save_settings_file() == str(user_file) + + monkeypatch.setattr("os.geteuid", lambda: 0) + assert self.manager.get_default_save_settings_file() == str(system_file) + + def test_save_settings_command_uses_default_save_path(self): + manager = Mock() + output_manager = Mock() + command = save_settings.command() + command.initialize( + { + "runtime": { + "SettingsManager": manager, + "OutputManager": output_manager, + } + } + ) + + command.run() + + manager.save_settings.assert_called_once_with()