From 8638bca1d5fb5fdb813ac7b7f749afbef4a48959 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 7 May 2026 23:24:54 -0400 Subject: [PATCH] Improve socket handling for -x spawned fenrir instances. --- README.md | 47 ++-- RELEASE_CHECKLIST.md | 8 +- check-dependencies.py | 22 +- config/keyboard/Readme.md | 7 +- config/keyboard/pty.conf | 89 ------- config/keyboard/pty2.conf | 46 ---- config/sound/default/PTYBypass.wav | Bin 10088 -> 0 bytes config/sound/default/soundicons.conf | 2 - config/sound/template/soundicons.conf | 2 - docs/development.txt | 8 +- docs/fenrir.1 | 42 ++-- docs/fenrir.adoc | 25 +- docs/user.md | 19 +- docs/user.txt | 3 +- locale/ru/LC_MESSAGES/fenrir.po | 12 - requirements.txt | 1 - setup.py | 1 - src/fenrir | 21 +- .../commands/onByteInput/10000-shut_up.py | 55 ----- .../onByteInput/15000-enable_temp_speech.py | 41 ---- .../commands/onByteInput/__init__.py | 0 .../onCursorChange/15000-char_echo.py | 17 +- ...resent_char_if_cursor_change_horizontal.py | 9 +- .../onCursorChange/55000-tab_completion.py | 17 +- .../commands/onHeartBeat/76000-time.py | 64 +++++ .../commands/onScreenUpdate/60000-history.py | 19 +- .../KEY/fenrir/screen/select_driver.py | 2 +- .../KEY/nano/Help/about_nano.py | 7 +- .../vmenu-profiles/KEY/nano/file/save.py | 7 +- .../commands/vmenu-profiles/template.py | 8 +- src/fenrirscreenreader/core/byteManager.py | 224 ------------------ src/fenrirscreenreader/core/eventData.py | 3 +- src/fenrirscreenreader/core/eventManager.py | 2 - src/fenrirscreenreader/core/fenrirManager.py | 28 +-- src/fenrirscreenreader/core/generalData.py | 2 - src/fenrirscreenreader/core/inputDriver.py | 1 - src/fenrirscreenreader/core/inputManager.py | 8 - .../core/remoteInstanceRegistry.py | 82 +++++++ src/fenrirscreenreader/core/remoteManager.py | 124 ++++++++++ .../core/settingsManager.py | 116 +++------ src/fenrirscreenreader/core/vmenuManager.py | 8 +- .../inputDriver/debugDriver.py | 1 - .../inputDriver/evdevDriver.py | 1 - .../inputDriver/ptyDriver.py | 215 ----------------- .../inputDriver/x11Driver.py | 1 - .../remoteDriver/unixDriver.py | 200 ++++++++++++---- .../screenDriver/ptyDriver.py | 37 +-- tests/integration/test_remote_control.py | 62 +++++ tests/unit/test_pty_terminal_sequences.py | 22 ++ tests/unit/test_settings_validation.py | 7 +- tests/unit/test_time_command.py | 83 +++++++ tests/unit/test_x11_terminal_mode.py | 26 +- tools/fenrir.pot | 12 - 53 files changed, 794 insertions(+), 1072 deletions(-) delete mode 100644 config/keyboard/pty.conf delete mode 100644 config/keyboard/pty2.conf delete mode 100644 config/sound/default/PTYBypass.wav delete mode 100644 src/fenrirscreenreader/commands/onByteInput/10000-shut_up.py delete mode 100644 src/fenrirscreenreader/commands/onByteInput/15000-enable_temp_speech.py delete mode 100644 src/fenrirscreenreader/commands/onByteInput/__init__.py delete mode 100644 src/fenrirscreenreader/core/byteManager.py create mode 100644 src/fenrirscreenreader/core/remoteInstanceRegistry.py delete mode 100644 src/fenrirscreenreader/inputDriver/ptyDriver.py create mode 100644 tests/unit/test_pty_terminal_sequences.py create mode 100644 tests/unit/test_time_command.py diff --git a/README.md b/README.md index f2d1dc6c..af4f2f42 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This software is licensed under the LGPL v3. ## Key Features -- **Multiple Interface Support**: Works in Linux TTY, and terminal emulators +- **Linux Console Support**: Works as a Linux TTY screen reader, with optional X11 terminal mode - **Flexible Driver System**: Modular architecture with multiple drivers for speech, sound, input, and screen - **Review Mode**: Navigate and review screen content without moving the edit cursor - **Table Navigation**: Advanced table mode with column headers, cell-by-cell navigation, and boundary feedback @@ -29,10 +29,8 @@ Fenrir is a Linux screen reader. Linux is the only officially supported platform **Other platforms (macOS, BSD, Windows):** Pull requests adding support for other operating systems may be accepted provided they do not break Linux functionality. However, no special care will be taken to preserve functionality on secondary platforms. If changes to Fenrir break support on a non-Linux OS, it is the responsibility of third-party contributors to submit fixes. -- Linux (ptyDriver, vcsaDriver, evdevDriver) - Full support -- macOS (ptyDriver) - Community-maintained, no guarantees -- BSD (ptyDriver) - Community-maintained, no guarantees -- Windows (ptyDriver) - Community-maintained, no guarantees +- Linux TTY (`vcsaDriver`, `evdevDriver`) - Full support +- X11 terminal emulators (`-x`, `ptyDriver`, `x11Driver`) - Supported ## Core Requirements @@ -52,11 +50,8 @@ Fenrir is a Linux screen reader. Linux is the only officially supported platform - ReadWrite permission: - /dev/input - /dev/uinput -2. **ptyDriver** - Terminal emulation input driver (cross-platform) - - python-pyte -3. **atspiDriver** - AT-SPI input driver for desktop environments - - python-pyatspi2 - +2. **x11Driver** - X11 terminal-scoped input driver for `fenrir -x` + - python-xlib ### Remote Drivers: 1. **unixDriver** - Unix socket remote control (default) - socat (for command-line interaction) @@ -73,7 +68,7 @@ Fenrir is a Linux screen reader. Linux is the only officially supported platform - /dev/tty[1-64] - /dev/vcsa[1-64] - read logind DBUS -2. **ptyDriver** - Terminal emulation driver (cross-platform) +2. **ptyDriver** - Terminal emulation screen driver for `fenrir -x` - python-pyte @@ -134,8 +129,8 @@ Settings are located in: By default Fenrir uses: - **Sound driver**: genericDriver (via sox) - **Speech driver**: speechdDriver (via speech-dispatcher) -- **Input driver**: evdevDriver (Linux) or ptyDriver (other platforms) -- **Screen driver**: vcsaDriver (Linux TTY) or ptyDriver (terminal emulation) +- **Input driver**: evdevDriver for Linux TTY, x11Driver for `fenrir -x` +- **Screen driver**: vcsaDriver for Linux TTY, ptyDriver for `fenrir -x` ## Audio Configuration @@ -266,7 +261,8 @@ enable_command_remote=True # allow command execution ### Remote Drivers 1. **unixDriver** (recommended): Uses Unix domain sockets - - Socket location: `/tmp/fenrirscreenreader-deamon.sock` (TTY mode) or `/tmp/fenrirscreenreader-.sock` + - Socket location: `/tmp/fenrirscreenreader-deamon.sock` for the standard control socket + - `fenrir -x` instances also create private sockets: `/tmp/fenrirscreenreader-.sock` - More secure, local-only access - Works with `socat` @@ -279,6 +275,19 @@ enable_command_remote=True # allow command execution The `socat` command provides the easiest way to send commands to Fenrir: +#### Instance Discovery +```bash +# List registered Fenrir instances and their socket paths +echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock +``` + +In X terminal mode (`fenrir -x`), multiple Fenrir instances can run at the same +time. Each instance has a private socket, and one instance may also own the +standard control socket. Use `ls` or `command ls` on the standard socket to find +the private socket for a specific instance. Untargeted commands sent through a +shared or broadcast path are claimed by one instance so duplicate instances do +not all perform the same action. + #### Basic Speech Control ```bash # Interrupt current speech @@ -406,6 +415,9 @@ setting [parameters] **Application Commands:** - `command quitapplication` - Quit Fenrir +**Instance Commands:** +- `ls` / `list` / `command ls` / `command list` - List registered Fenrir instances and their socket paths + #### Available Settings **Settings Commands:** @@ -711,8 +723,6 @@ fenrir [OPTIONS] - `-o, --options SECTION#SETTING=VALUE;..` - Override settings file options - `-d, --debug` - Enable debug mode - `-p, --print` - Print debug messages to screen -- `-e, --emulated-pty` - Use PTY emulation with escape sequences for input (enables desktop/X/Wayland usage) -- `-E, --emulated-evdev` - Use PTY emulation with evdev for input (single instance) - `-x, --x11` - Use PTY emulation with X11 keyboard input scoped to the terminal window - `--x11-window-id WINDOWID` - X11 window id to use for `--x11` terminal mode - `-F, --force-all-screens` - Force Fenrir to respond on all screens, ignoring ignore_screen setting @@ -723,9 +733,6 @@ fenrir [OPTIONS] # Run in foreground with debug output sudo fenrir -f -d -# Use PTY emulation for desktop use -sudo fenrir -e - # Use PTY emulation with X11 terminal-scoped keybindings fenrir -x @@ -753,6 +760,8 @@ X11 terminal mode uses the normal keyboard layout files, including desktop and l This mode requires `python-xlib`. +For users who want a dedicated PTY/terminal screen reader instead of Fenrir's Linux console focus, see TDSR: https://github.com/tspivey/tdsr + ## Localization Translation files are located in the `locale/` directory. To install translations: diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 8980faa4..55c669f3 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -88,11 +88,11 @@ ls -la config/punctuation/default.conf # Test basic functionality (ask user to run) sudo ./src/fenrir --help -# Test in emulation mode (safer for desktop environments) -sudo ./src/fenrir -e --version +# Test version output +./src/fenrir --version -# Quick functionality test (3-5 seconds) -sudo timeout 5 ./src/fenrir -e -f || echo "Timeout reached (expected)" +# Quick console functionality test (3-5 seconds, ask user to run on a TTY) +sudo timeout 5 ./src/fenrir -f || echo "Timeout reached (expected)" ``` **Expected Result**: No immediate crashes, basic help/version output works diff --git a/check-dependencies.py b/check-dependencies.py index 8edb70f5..dc62a78a 100755 --- a/check-dependencies.py +++ b/check-dependencies.py @@ -50,7 +50,13 @@ def check_dependency(dep: Dependency) -> bool: dependencyList = [ # Core dependencies Dependency('FenrirCore', 'core', 'core', - pythonImports=['daemonize', 'enchant', 'pyperclip', 'setproctitle']), + pythonImports=[ + 'daemonize', + 'enchant', + 'pyperclip', + 'setproctitle', + 'xdg' + ]), # Screen drivers Dependency('DummyScreen', 'screen', 'dummyDriver'), @@ -58,16 +64,13 @@ dependencyList = [ pythonImports=['dbus'], devicePaths=['/dev/vcsa']), Dependency('PTY', 'screen', 'ptyDriver', - pythonImports=['pyte', 'xdg']), + pythonImports=['pyte']), # Input drivers Dependency('DummyInput', 'input', 'dummyDriver'), Dependency('DebugInput', 'input', 'debugDriver'), Dependency('Evdev', 'input', 'evdevDriver', pythonImports=['evdev', 'evdev.InputDevice', 'evdev.UInput', 'pyudev']), - Dependency('PTYInput', 'input', 'ptyDriver', - pythonImports=['pyte']), - # Sound drivers Dependency('DummySound', 'sound', 'dummyDriver'), Dependency('DebugSound', 'sound', 'debugDriver'), @@ -82,11 +85,7 @@ dependencyList = [ Dependency('Speechd', 'speech', 'speechdDriver', pythonImports=['speechd']), Dependency('GenericSpeech', 'speech', 'genericDriver', - checkCommands=['espeak-ng']), - - # Additional dependencies - Dependency('Pexpect', 'core', 'pexpectDriver', - pythonImports=['pexpect']) + checkCommands=['espeak-ng']) ] defaultModules = { @@ -94,8 +93,7 @@ defaultModules = { 'VCSA', 'Evdev', 'GenericSpeech', - 'GenericSound', - 'Pexpect' + 'GenericSound' } def check_all_dependencies(): diff --git a/config/keyboard/Readme.md b/config/keyboard/Readme.md index 83cc6805..67aab55d 100644 --- a/config/keyboard/Readme.md +++ b/config/keyboard/Readme.md @@ -7,10 +7,6 @@ This directory contains keyboard layout files for Fenrir screen reader. - **desktop.conf** - Desktop layout using numeric keypad (recommended) - **laptop.conf** - Laptop layout for keyboards without numeric keypad -- **nvda-desktop.conf** - NVDA-compatible desktop layout -- **nvda-laptop.conf** - NVDA-compatible laptop layout -- **pty.conf** - PTY emulation layout for terminal use -- **pty2.conf** - Alternative PTY emulation layout ## Key Features @@ -36,7 +32,7 @@ To change keyboard layout, edit `/etc/fenrir/settings/settings.conf`: ```ini [keyboard] -keyboardLayout=desktop # or laptop, nvda-desktop, nvda-laptop, pty, pty2 +keyboardLayout=desktop # or laptop ``` ## Available Key Constants @@ -582,4 +578,3 @@ BTN_TRIGGER_HAPPY37 BTN_TRIGGER_HAPPY38 BTN_TRIGGER_HAPPY39 BTN_TRIGGER_HAPPY40 - diff --git a/config/keyboard/pty.conf b/config/keyboard/pty.conf deleted file mode 100644 index 0f2b0a60..00000000 --- a/config/keyboard/pty.conf +++ /dev/null @@ -1,89 +0,0 @@ -# This file contains terminal escape sequences as shortcut -# It is used for PTY screen / Input driver (Terminal emulation) -# ^[ is used as escape - -# f1 - fenrir help -^[OP=toggle_tutorial_mode -# double tap control+end read attributes -2,^[[1;5F=attribute_cursor -#=toggle_has_attribute -# escape - stop speech -^[=shut_up -# context menu key - stop speech -^[[29~=shut_up -# alt+shift+down - review to bottom -^[[1;4B=review_bottom -# alt+shift_up - review to top -^[[1;4A=review_top -# alt+down - review current line -^[[1;3B=review_curr_line -# alt+left - review previous line -^[[1;3D=review_prev_line -# alt+right - review next line -^[[1;3C=review_next_line -# alt+shift+left - beginning of line -^[[1;4D=review_line_begin -# alt+shift+right - end of line -^[[1;4C=review_line_end -# control+down - review current word -^[[1;5B=review_curr_word -# control+left - review previous word -^[[1;5D=review_prev_word -# control+right - review next word -^[[1;5C=review_next_word -# shift+down - review current character -^[[1;2B=review_curr_char -# shift+left - review previous character -^[[1;2D=review_prev_char -# shift+right - review next character -^[[1;2C=review_next_char -# control+shift+down - current character phonetic -^[[1;6B=curr_char_phonetic -# control+shift+left - previous character phonetic -^[[1;6D=prev_char_phonetic -# control+shift+right - next character phonetic -^[[1;6C=next_char_phonetic -# f2 - toggle sound -^[OQ=toggle_sound -# f3 - toggle highlight tracking -^[OR=toggle_highlight_tracking -alt+f12 - quit fenrir -^[[24;3~=quit_fenrir -# alt+f12 - time -^[[24;3~=time -# 2,alt+f12 - date -2,^[[24;3~=date -# alt+[ - previous clipboard -^[[=prev_clipboard -# alt+] - next clipboard -^[]=next_clipboard -# control+f6 - Read current clipboard -^[[17;5~=curr_clipboard -# f6 - copy to clipboard -^[[17~=copy_marked_to_clipboard -# shift+f6 - clear clipboard -^[[17;2~=clear_clipboard -# f7 - paste clipboard -^[[18~=paste_clipboard -# alt+f8 - export clipboard to X -^[[19;3~=export_clipboard_to_x -# control+f8 - import clipboard from X -^[[19;5~=import_clipboard_from_x -# alt+f9 - export clipboard to file -^[[20;3~=export_clipboard_to_file -# control+f9 - import clipboard from file -^[[20;5~=import_clipboard_from_file -# shift+f5 - remove marks -^[[15;2~=remove_marks -# f5 - set mark -^[[15~=set_mark -# f8 - Last utterance to clipboard -^[[19~=copy_last_echo_to_clipboard -# lat+\ Toggle auto announcement of indentation -^[\=toggle_auto_indent -# alt+end - temperarily disable speech -^[[1;3F=temp_disable_speech -# control+end - toggle auto read -^[[1;5F=toggle_auto_read -# F12 - cycle keyboard layout -^[[24~=cycle_keyboard_layout diff --git a/config/keyboard/pty2.conf b/config/keyboard/pty2.conf deleted file mode 100644 index bedf774f..00000000 --- a/config/keyboard/pty2.conf +++ /dev/null @@ -1,46 +0,0 @@ -# This file contains terminal escape sequences as shortcut -# It is used for PTY screen / Input driver (Terminal emulation) -# ^[ is used as escape - -^[h=toggle_tutorial_mode -^[/=shut_up -^[[D=shut_up -^[O=review_bottom -^[U=review_top -#^[[1;3B=review_curr_line -^[i=review_curr_line -^[u=review_prev_line -^[o=review_next_line -^[J=review_line_begin -^[L=review_line_end -^[j=review_line_first_char -^[L=review_line_last_char -^[k=review_curr_word -^[j=review_prev_word -^[l=review_next_word -^[,=review_curr_char -^[m=review_prev_char -^[.=review_next_char -^[<=curr_char_phonetic -^[M=prev_char_phonetic -^[>=next_char_phonetic -^[OR=toggle_sound -^[OS=toggle_speech -^[8=toggle_highlight_tracking -^[Q=quit_fenrir -^[t=time -^[T=date -^[[5~=prev_clipboard -^[[6~=next_clipboard -^[C=curr_clipboard -^[c=copy_marked_to_clipboard -^[v=paste_clipboard -^[[15~=import_clipboard_from_file -^[X=remove_marks -^[x=set_mark -^[\=toggle_auto_indent -^[B=copy_last_echo_to_clipboard -# alt+f8 - export clipboard to X -# ^[[19;3~=export_clipboard_to_x -# # control+f8 - import clipboard from X -# ^[[19;5~=import_clipboard_from_x diff --git a/config/sound/default/PTYBypass.wav b/config/sound/default/PTYBypass.wav deleted file mode 100644 index d0eb66992cf05a190e71331891c687931b35c399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10088 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 diff --git a/config/sound/default/soundicons.conf b/config/sound/default/soundicons.conf index e95caa7b..ce09f4e4 100644 --- a/config/sound/default/soundicons.conf +++ b/config/sound/default/soundicons.conf @@ -1,8 +1,6 @@ # Screen Reader Turned On or Off ScreenReaderOn='ScreenReaderOn.wav' ScreenReaderOff='ScreenReaderOff.wav' -# PTY bypass -PTYBypass='PTYBypass.wav' # Cancel the current command Cancel='Cancel.wav' # Accept command diff --git a/config/sound/template/soundicons.conf b/config/sound/template/soundicons.conf index 5bc7ac8a..afc7d1a7 100644 --- a/config/sound/template/soundicons.conf +++ b/config/sound/template/soundicons.conf @@ -1,8 +1,6 @@ # Screen Reader Turned On or Off ScreenReaderOn='' ScreenReaderOff='' -# PTY bypass -PTYBypass='' # Cancel the current command Cancel='' # Accept command diff --git a/docs/development.txt b/docs/development.txt index 66b4cb51..c6aab307 100644 --- a/docs/development.txt +++ b/docs/development.txt @@ -22,7 +22,7 @@ src/fenrirscreenreader/ │ ├── onKeyInput/ # Key input hooks │ └── help/ # Tutorial system ├── drivers/ # Driver implementations -│ ├── inputDriver/ # Input drivers (evdev, pty, atspi) +│ ├── inputDriver/ # Input drivers (evdev, x11) │ ├── screenDriver/ # Screen drivers (vcsa, pty) │ ├── speechDriver/ # Speech drivers (speechd, generic) │ └── soundDriver/ # Sound drivers (generic, gstreamer) @@ -36,8 +36,7 @@ Fenrir uses a pluggable driver architecture: 1. **Input Drivers**: Capture keyboard input - evdevDriver: Linux evdev (recommended) - - ptyDriver: Terminal emulation - - atspiDriver: AT-SPI for desktop + - x11Driver: X11 terminal-scoped input 2. **Screen Drivers**: Read screen content - vcsaDriver: Linux VCSA devices @@ -83,7 +82,6 @@ Fenrir supports various event hooks: - **onCursorChange**: Triggered when cursor moves - **onScreenUpdate**: Triggered on screen content changes - **onKeyInput**: Triggered on key presses -- **onByteInput**: Triggered on byte-level input - **onScreenChanged**: Triggered when switching screens ## Development Setup @@ -386,4 +384,4 @@ current_line = lines[self.env['screen']['newCursor']['y']] - **Wiki**: https://git.stormux.org/storm/fenrir/wiki - **Issues**: Use repository issue tracker - **Community**: IRC irc.stormux.org #stormux -- **Email**: stormux+subscribe@groups.io \ No newline at end of file +- **Email**: stormux+subscribe@groups.io diff --git a/docs/fenrir.1 b/docs/fenrir.1 index 3d50c0fb..6bed6ca2 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -13,13 +13,14 @@ fenrir \- A modern, modular console screen reader for Linux .IR SECTION#SETTING=VALUE;.. ] .RB [ \-d ] .RB [ \-p ] -.RB [ \-e ] -.RB [ \-E ] +.RB [ \-x ] +.RB [ \-\-x11-window-id +.IR WINDOWID ] .RB [ \-F ] .SH DESCRIPTION Fenrir is a modern, modular, flexible and fast console screen reader written in Python 3. -It provides spoken feedback for Linux console applications and supports multiple interface types including TTY, terminal emulators, and desktop environments. +It provides spoken feedback for Linux console applications and supports Linux TTYs plus X11 terminal mode. Fenrir features a modular driver architecture supporting multiple speech synthesizers, sound systems, input methods, and screen reading techniques. It includes advanced features like review mode, multiple clipboards, spell checking, bookmarks, and configurable key bindings. @@ -53,14 +54,6 @@ Enable debug mode. Debug information will be logged to /var/log/fenrir.log. .BR \-p ", " \-\-print Print debug messages to screen in addition to logging them. -.TP -.BR \-e ", " \-\-emulated-pty -Use PTY emulation with escape sequences for input. This enables usage in desktop/X11/Wayland environments and terminal emulators. - -.TP -.BR \-E ", " \-\-emulated-evdev -Use PTY emulation with evdev for input (single instance mode). - .TP .BR \-F ", " \-\-force-all-screens Force Fenrir to respond on all screens, ignoring the ignoreScreen setting. This temporarily overrides screen filtering for the current session. @@ -234,14 +227,12 @@ gstreamerDriver - GStreamer-based .IP \[bu] 4 debugDriver - Debug/testing -.TP +.TP .B Input Drivers: .IP \[bu] 4 evdevDriver - Linux evdev (recommended for Linux) .IP \[bu] 4 -ptyDriver - Terminal emulation (cross-platform) -.IP \[bu] 4 -atspiDriver - AT-SPI for desktop environments +x11Driver - X11 terminal-scoped input for fenrir -x .TP .B Screen Drivers: @@ -267,10 +258,6 @@ Start Fenrir as a daemon with default settings. .B fenrir -f -d Run Fenrir in foreground with debug output. -.TP -.B fenrir -e -Run Fenrir with PTY emulation for desktop/terminal use. - .TP .B fenrir -o "speech#rate=0.8;sound#volume=0.5" Override speech rate and sound volume settings. @@ -300,6 +287,19 @@ enableCommandRemote=True .SS Using socat with Unix Sockets +.TP +.B Instance Discovery: +.EX +# List registered Fenrir instances and their socket paths +echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock +.EE + +In X terminal mode (fenrir -x), multiple Fenrir instances can run at the +same time. Each instance has a private socket at +/tmp/fenrirscreenreader-.sock, and one instance may also own the +standard control socket. Use ls or "command ls" on the standard socket to +find the private socket for a specific instance. + .TP .B Basic Speech Control: .EX @@ -355,6 +355,8 @@ command say - Speak the specified text command interrupt - Stop current speech .IP \[bu] 2 command tempdisablespeech - Disable speech until next key press +.IP \[bu] 2 +ls / list / command ls / command list - List registered Fenrir instances .TP .B Settings Commands: @@ -516,4 +518,4 @@ This software is licensed under the LGPL v3. Full documentation: https://git.stormux.org/storm/fenrir/wiki .PP -Support: stormux+subscribe@groups.io \ No newline at end of file +Support: stormux+subscribe@groups.io diff --git a/docs/fenrir.adoc b/docs/fenrir.adoc index a4cb2852..44ba51ac 100644 --- a/docs/fenrir.adoc +++ b/docs/fenrir.adoc @@ -1214,12 +1214,6 @@ Enable debug mode. Debug information will be logged. `+-p, --print+`:: Print debug messages to screen in addition to logging them. -`+-e, --emulated-pty+`:: -Use PTY emulation with escape sequences for input. This enables usage in desktop/X11/Wayland environments and terminal emulators. - -`+-E, --emulated-evdev+`:: -Use PTY emulation with evdev for input (single instance mode). - `+-F, --force-all-screens+`:: Force Fenrir to respond on all screens, ignoring the ignoreScreen setting. This temporarily overrides screen filtering for the current session. @@ -1277,6 +1271,19 @@ enable_command_remote=True The `+socat+` command provides the easiest way to send commands to Fenrir: +===== Instance Discovery + +.... +# List registered Fenrir instances and their socket paths +echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock +.... + +In X terminal mode (`+fenrir -x+`), multiple Fenrir instances can run at the +same time. Each instance has a private socket at +`+/tmp/fenrirscreenreader-.sock+`, and one instance may also own the +standard control socket. Use `+ls+` or `+command ls+` on the standard socket to +find the private socket for a specific instance. + ===== Basic Speech Control .... @@ -1338,6 +1345,7 @@ echo "command quitapplication" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-dea * `+command say +` - Speak the specified text * `+command interrupt+` - Stop current speech * `+command tempdisablespeech+` - Disable speech until next key press +* `+ls+` / `+list+` / `+command ls+` / `+command list+` - List registered Fenrir instances *Clipboard Commands:* @@ -1669,8 +1677,9 @@ off=`+False+` ==== Screen -The settings for screens, (TTY, PTY) are configured in the `+[screen]+` -section. +The settings for screen access are configured in the `+[screen]+` +section. `+vcsaDriver+` is used for Linux TTYs, and `+ptyDriver+` is +used by X11 terminal mode. The driver to get the information from the screen: diff --git a/docs/user.md b/docs/user.md index 600fe12f..fc1723d4 100644 --- a/docs/user.md +++ b/docs/user.md @@ -132,6 +132,18 @@ enable_command_remote=True # allow command execution ### Basic Usage with socat +#### Instance Discovery +```bash +# List registered Fenrir instances and their socket paths +echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock +``` + +In X terminal mode (`fenrir -x`), multiple Fenrir instances can run at the same +time. Each instance has a private socket at `/tmp/fenrirscreenreader-.sock`, +and one instance may also own the standard control socket. Use `ls` or +`command ls` on the standard socket to find the private socket for a specific +instance. + #### Speech Control ```bash # Interrupt current speech @@ -193,6 +205,7 @@ echo "command quitapplication" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-dea - `command say ` - Speak text - `command interrupt` - Stop speech - `command tempdisablespeech` - Disable until next key +- `ls` / `list` / `command ls` / `command list` - List registered Fenrir instances **Settings Commands:** - `setting set
#=` - Change setting @@ -318,7 +331,7 @@ Fenrir automatically detects and provides audio feedback for progress indicators ### Input Drivers - **evdevDriver** - Linux evdev (recommended for Linux) -- **ptyDriver** - Terminal emulation (cross-platform) +- **x11Driver** - X11 terminal-scoped input for `fenrir -x` ### Screen Drivers - **vcsaDriver** - Linux VCSA devices (TTY) @@ -341,8 +354,6 @@ fenrir [OPTIONS] - `-o, --options SECTION#SETTING=VALUE;..` - Override settings - `-d, --debug` - Enable debug mode - `-p, --print` - Print debug to screen -- `-e, --emulated-pty` - PTY emulation for desktop use -- `-E, --emulated-evdev` - PTY + evdev emulation - `-x, --x11` - PTY + X11 keyboard input scoped to the terminal window - `--x11-window-id WINDOWID` - X11 window id to use for `--x11` terminal mode - `-F, --force-all-screens` - Ignore ignoreScreen setting @@ -368,6 +379,8 @@ X11 terminal mode uses the same keyboard layout files as TTY Fenrir. Supported F This mode requires `python-xlib`. +For a dedicated PTY/terminal screen reader, see TDSR: https://github.com/tspivey/tdsr + ## Troubleshooting ### No Speech diff --git a/docs/user.txt b/docs/user.txt index e19abc23..7cc0e764 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -1013,7 +1013,8 @@ Values: Integer, * ''0'' = display size * ''>0'' number of cells ==== Screen ==== -The settings for screens, (TTY, PTY) are configured in the ''[screen]'' section. +The settings for screen access are configured in the ''[screen]'' section. +''vcsaDriver'' is used for Linux TTYs, and ''ptyDriver'' is used by X11 terminal mode. The driver to get the information from the screen: driver=vcsaDriver diff --git a/locale/ru/LC_MESSAGES/fenrir.po b/locale/ru/LC_MESSAGES/fenrir.po index 42a8e313..d9650571 100644 --- a/locale/ru/LC_MESSAGES/fenrir.po +++ b/locale/ru/LC_MESSAGES/fenrir.po @@ -744,7 +744,6 @@ msgid "Script file is not executable" msgstr "Файл скрипта не исполняемый" #: ../src/fenrirscreenreader/commands/commands/temp_disable_speech.py:17 -#: ../src/fenrirscreenreader/commands/onByteInput/15000-enable_temp_speech.py:17 #: ../src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py:17 msgid "disables speech until next keypress" msgstr "Отключить речь пока не нажата следующая клавиша" @@ -898,7 +897,6 @@ msgid "speech disabled" msgstr "Речь выключена" #: ../src/fenrirscreenreader/commands/commands/toggle_speech.py:25 -#: ../src/fenrirscreenreader/commands/onByteInput/15000-enable_temp_speech.py:24 #: ../src/fenrirscreenreader/commands/onKeyInput/15000-enable_temp_speech.py:28 msgid "speech enabled" msgstr "Речь включена" @@ -1089,15 +1087,6 @@ msgstr "Мерцание" msgid "default" msgstr "По умолчанию" -#: ../src/fenrirscreenreader/core/byteManager.py:103 -#: ../src/fenrirscreenreader/core/byteManager.py:105 -msgid "Sticky Mode On" -msgstr "Режим залипания включен" - -#: ../src/fenrirscreenreader/core/byteManager.py:109 -msgid "bypass" -msgstr "" - #: ../src/fenrirscreenreader/core/fenrirManager.py:26 msgid "Start Fenrir" msgstr "fenrir запущен" @@ -1216,4 +1205,3 @@ msgstr "Меню" #: ../src/fenrirscreenreader/core/vmenuManager.py:234 msgid "Action" msgstr "Действие" - diff --git a/requirements.txt b/requirements.txt index a901c88f..de203a24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ daemonize dbus-python evdev -pexpect pyenchant pyperclip pyte diff --git a/setup.py b/setup.py index 1f461830..859afa19 100755 --- a/setup.py +++ b/setup.py @@ -103,7 +103,6 @@ setup( "pyudev>=0.21.0", "setuptools", "setproctitle", - "pexpect", "pyte>=0.7.0", ], extras_require={ diff --git a/src/fenrir b/src/fenrir index 0da21156..c6fc9aa8 100755 --- a/src/fenrir +++ b/src/fenrir @@ -60,16 +60,6 @@ def create_argument_parser(): action='store_true', help='Print debug messages to screen' ) - argumentParser.add_argument( - '-e', '--emulated-pty', - action='store_true', - help='Use PTY emulation with escape sequences for input (enables desktop/X/Wayland usage)' - ) - argumentParser.add_argument( - '-E', '--emulated-evdev', - action='store_true', - help='Use PTY emulation with evdev for input (single instance)' - ) argumentParser.add_argument( '-x', '--x11', action='store_true', @@ -102,13 +92,8 @@ def validate_arguments(cliArgs): if option and ('#' not in option or '=' not in option): return False, f"Invalid option format: {option}\nExpected format: SECTION#SETTING=VALUE" - emulated_modes = [ - cliArgs.emulated_pty, - cliArgs.emulated_evdev, - cliArgs.x11, - ] - if sum(bool(mode) for mode in emulated_modes) > 1: - return False, "Cannot combine --emulated-pty, --emulated-evdev, and --x11" + if cliArgs.x11_window_id and not cliArgs.x11: + return False, "--x11-window-id requires --x11" return True, None @@ -141,7 +126,7 @@ def run_fenrir(): def should_run_foreground(cliArgs): - return cliArgs.foreground or cliArgs.emulated_pty or cliArgs.x11 + return cliArgs.foreground or cliArgs.x11 def main(): diff --git a/src/fenrirscreenreader/commands/onByteInput/10000-shut_up.py b/src/fenrirscreenreader/commands/onByteInput/10000-shut_up.py deleted file mode 100644 index 32fa85a2..00000000 --- a/src/fenrirscreenreader/commands/onByteInput/10000-shut_up.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return "" - - def run(self): - if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( - "keyboard", "interrupt_on_key_press" - ): - return - if self.env["runtime"]["InputManager"].no_key_pressed(): - return - if self.env["runtime"]["ScreenManager"].is_screen_change(): - return - if len(self.env["input"]["curr_input"]) <= len( - self.env["input"]["prev_input"] - ): - return - # if the filter is set - if ( - self.env["runtime"]["SettingsManager"] - .get_setting("keyboard", "interrupt_on_key_press_filter") - .strip() - != "" - ): - filter_list = ( - self.env["runtime"]["SettingsManager"] - .get_setting("keyboard", "interrupt_on_key_press_filter") - .split(",") - ) - for curr_key in self.env["input"]["curr_input"]: - if curr_key not in filter_list: - return - self.env["runtime"]["OutputManager"].interrupt_output() - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onByteInput/15000-enable_temp_speech.py b/src/fenrirscreenreader/commands/onByteInput/15000-enable_temp_speech.py deleted file mode 100644 index 697c4f90..00000000 --- a/src/fenrirscreenreader/commands/onByteInput/15000-enable_temp_speech.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - - -from fenrirscreenreader.core.i18n import _ - - -class command: - def __init__(self): - pass - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def get_description(self): - return _("disables speech until next keypress") - - def run(self): - if not self.env["commandBuffer"]["enableSpeechOnKeypress"]: - return - self.env["runtime"]["SettingsManager"].set_setting( - "speech", - "enabled", - str(self.env["commandBuffer"]["enableSpeechOnKeypress"]), - ) - self.env["commandBuffer"]["enableSpeechOnKeypress"] = False - # Also disable prompt watching since speech was manually re-enabled - if "silenceUntilPrompt" in self.env["commandBuffer"]: - self.env["commandBuffer"]["silenceUntilPrompt"] = False - self.env["runtime"]["OutputManager"].present_text( - _("speech enabled"), sound_icon="SpeechOn", interrupt=True - ) - - def set_callback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/onByteInput/__init__.py b/src/fenrirscreenreader/commands/onByteInput/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py b/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py index 6e04abb2..a676bad7 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py +++ b/src/fenrirscreenreader/commands/onCursorChange/15000-char_echo.py @@ -42,19 +42,10 @@ class command: ) if x_move > 3: return - if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]: - if self.env["runtime"][ - "InputManager" - ].get_last_deepest_input() in [["KEY_TAB"]]: - return - elif self.env["runtime"]["InputManager"].get_shortcut_type() in [ - "BYTE" - ]: - if self.env["runtime"]["ByteManager"].get_last_byte_key() in [ - b" ", - b"\t", - ]: - return + if self.env["runtime"][ + "InputManager" + ].get_last_deepest_input() in [["KEY_TAB"]]: + return # detect deletion or chilling if ( self.env["screen"]["new_cursor"]["x"] diff --git a/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py b/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py index 061753b3..86c0a138 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py +++ b/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py @@ -81,11 +81,10 @@ class command: if curr_char.isspace(): # Only announce spaces during pure navigation (arrow keys) # Check if this is really navigation by looking at input history - if self.env["runtime"]["InputManager"].get_shortcut_type() in [ - "KEY" - ] and self.env["runtime"]["InputManager"].get_last_deepest_input()[ - 0 - ] in [ + last_input = self.env["runtime"][ + "InputManager" + ].get_last_deepest_input() + if last_input and last_input[0] in [ "KEY_LEFT", "KEY_RIGHT", "KEY_UP", diff --git a/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py b/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py index 40a197ba..a118d5f1 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py +++ b/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py @@ -33,19 +33,10 @@ class command: current_time = time.time() tab_detected = False - # Check KEY mode - if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]: - if (self.env["runtime"]["InputManager"].get_last_deepest_input() - in [["KEY_TAB"]]): - tab_detected = True - self.env["commandBuffer"]["tabCompletion"]["lastTabTime"] = current_time - - # Check BYTE mode - elif self.env["runtime"]["InputManager"].get_shortcut_type() in ["BYTE"]: - for currByte in self.env["runtime"]["ByteManager"].get_last_byte_key(): - if currByte == 9: # Tab character - tab_detected = True - self.env["commandBuffer"]["tabCompletion"]["lastTabTime"] = current_time + if (self.env["runtime"]["InputManager"].get_last_deepest_input() + in [["KEY_TAB"]]): + tab_detected = True + self.env["commandBuffer"]["tabCompletion"]["lastTabTime"] = current_time # Check if tab was pressed recently (200ms window) if not tab_detected: diff --git a/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py b/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py index 36956e27..87414661 100755 --- a/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py +++ b/src/fenrirscreenreader/commands/onHeartBeat/76000-time.py @@ -5,11 +5,16 @@ # By Chrys, Storm Dragon, and contributors. import datetime +import os +import tempfile import time from fenrirscreenreader.core.i18n import _ +ANNOUNCEMENT_LOCK_TIMEOUT_SEC = 5.0 + + class command: def __init__(self): pass @@ -26,6 +31,59 @@ class command: def get_description(self): return "No Description found" + def _get_announcement_lock_path(self): + return os.path.join( + tempfile.gettempdir(), + f"fenrirscreenreader-{os.getuid()}-time-announcement.lock", + ) + + def _try_create_announcement_lock(self, announcement_slot, now): + lock_path = self._get_announcement_lock_path() + try: + lock_fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + return False + + with os.fdopen(lock_fd, "w", encoding="utf-8") as lock_file: + lock_file.write(f"{os.getpid()} {announcement_slot} {now}\n") + return True + + def _read_announcement_lock_slot(self, lock_path): + with open(lock_path, "r", encoding="utf-8") as lock_file: + lock_content = lock_file.readline().strip().split() + if len(lock_content) < 2: + return "" + return lock_content[1] + + def _claim_announcement_lock(self, announcement_slot): + now = time.time() + if self._try_create_announcement_lock(announcement_slot, now): + return True + + lock_path = self._get_announcement_lock_path() + try: + lock_slot = self._read_announcement_lock_slot(lock_path) + lock_stat = os.stat(lock_path) + except FileNotFoundError: + return self._try_create_announcement_lock(announcement_slot, now) + except OSError: + return False + + if lock_slot == announcement_slot: + return False + + if not lock_slot and now - lock_stat.st_mtime < ANNOUNCEMENT_LOCK_TIMEOUT_SEC: + return False + + try: + os.unlink(lock_path) + except FileNotFoundError: + pass + except OSError: + return False + + return self._try_create_announcement_lock(announcement_slot, now) + def run(self): if not self.env["runtime"]["SettingsManager"].get_setting_as_bool( "time", "enabled" @@ -50,6 +108,7 @@ class command: if delay_sec > 0: if int((now - self.last_time).total_seconds()) < delay_sec: return + announcement_slot = f"delay:{int(now.timestamp()) // delay_sec}" else: # should announce? if not str(now.minute).zfill(2) in on_minutes: @@ -58,6 +117,7 @@ class command: if now.hour == self.last_time.hour: if now.minute == self.last_time.minute: return + announcement_slot = f"minute:{datetime.datetime.strftime(now, '%Y%m%d%H%M')}" date_format = self.env["runtime"]["SettingsManager"].get_setting( "general", "date_format" @@ -78,6 +138,10 @@ class command: if not (present_date or present_time): return + if not self._claim_announcement_lock(announcement_slot): + self.last_time = now + return + time_format = self.env["runtime"]["SettingsManager"].get_setting( "general", "time_format" ) diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py b/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py index 00158b27..c26a00b9 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/60000-history.py @@ -41,20 +41,11 @@ class command: == self.env["runtime"]["ScreenManager"].get_rows() - 1 ): return - if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]: - if not ( - self.env["runtime"]["InputManager"].get_last_deepest_input() - in [["KEY_UP"], ["KEY_DOWN"]] - ): - return - elif self.env["runtime"]["InputManager"].get_shortcut_type() in [ - "BYTE" - ]: - if not ( - self.env["runtime"]["ByteManager"].get_last_byte_key() - in [b"^[[A", b"^[[B"] - ): - return + if not ( + self.env["runtime"]["InputManager"].get_last_deepest_input() + in [["KEY_UP"], ["KEY_DOWN"]] + ): + return # Get the current cursor's line from both old and new content prev_line = self.env["screen"]["old_content_text"].split("\n")[ diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py index 59ad6793..b7b1c1d9 100644 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py @@ -37,7 +37,7 @@ class command(config_command): self.present_text(f"Current screen driver: {current_description}") # Cycle through the available drivers - drivers = ["vcsaDriver", "ptyDriver", "dummyDriver", "debugDriver"] + drivers = ["vcsaDriver", "ptyDriver", "dummyDriver"] try: current_index = drivers.index(current_driver) next_index = (current_index + 1) % len(drivers) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py index fb077565..840b5124 100755 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/Help/about_nano.py @@ -29,12 +29,7 @@ class command: self.env["runtime"]["OutputManager"].present_text( "Okay, loading the information about Nano.", interrupt=True ) - if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]: - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - elif self.env["runtime"]["InputManager"].get_shortcut_type() in [ - "BYTE" - ]: - self.env["runtime"]["ByteManager"].send_bytes(self.byteMakro) + self.env["runtime"]["InputManager"].send_keys(self.key_macro) def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py index 1d5b4796..659ac270 100755 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/nano/file/save.py @@ -29,12 +29,7 @@ class command: self.env["runtime"]["OutputManager"].present_text( "Okay, you will now be asked to save your work.", interrupt=True ) - if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]: - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - elif self.env["runtime"]["InputManager"].get_shortcut_type() in [ - "BYTE" - ]: - self.env["runtime"]["ByteManager"].send_bytes(self.byteMakro) + self.env["runtime"]["InputManager"].send_keys(self.key_macro) def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/template.py b/src/fenrirscreenreader/commands/vmenu-profiles/template.py index 91c556d2..a85c8dc5 100644 --- a/src/fenrirscreenreader/commands/vmenu-profiles/template.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/template.py @@ -18,7 +18,6 @@ class command: # self.key_macro = [[1,'KEY_LEFTCTRL'],[1,'KEY_O'],[0.05,'SLEEP'],[0,'KEY_O'],[0,'KEY_LEFTCTRL']] # self.key_macro = [[1,'KEY_LEFTSHIFT'],[1,'KEY_LEFTCTRL'],[1,'KEY_N'],[0.05,'SLEEP'],[0,'KEY_N'],[0,'KEY_LEFTCTRL'],[0,'KEY_LEFTSHIFT']] self.key_macro = [] - self.byteMakro = [] def shutdown(self): pass @@ -27,12 +26,7 @@ class command: return "No description found" def run(self): - if self.env["runtime"]["InputManager"].get_shortcut_type() in ["KEY"]: - self.env["runtime"]["InputManager"].send_keys(self.key_macro) - elif self.env["runtime"]["InputManager"].get_shortcut_type() in [ - "BYTE" - ]: - self.env["runtime"]["ByteManager"].send_bytes(self.byteMakro) + self.env["runtime"]["InputManager"].send_keys(self.key_macro) def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/core/byteManager.py b/src/fenrirscreenreader/core/byteManager.py deleted file mode 100644 index 8b47dedc..00000000 --- a/src/fenrirscreenreader/core/byteManager.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import inspect -import os -import re -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.eventData import FenrirEventType -from fenrirscreenreader.core.i18n import _ - -currentdir = os.path.dirname( - os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe()))) -) -fenrir_path = os.path.dirname(currentdir) - - -class ByteManager: - def __init__(self): - self.switchCtrlModeOnce = 0 - self.controlMode = True - self.repeat = 1 - self.lastInputTime = time.time() - self.lastByteKey = b"" - - def initialize(self, environment): - self.env = environment - - def shutdown(self): - pass - - def unify_escape_seq(self, escapeSequence): - converted_escape_sequence = escapeSequence - if converted_escape_sequence[0] == 27: - converted_escape_sequence = b"^[" + converted_escape_sequence[1:] - if len(converted_escape_sequence) > 1: - if ( - converted_escape_sequence[0] == 94 - and converted_escape_sequence[1] == 91 - ): - converted_escape_sequence = ( - b"^[" + converted_escape_sequence[2:] - ) - return converted_escape_sequence - - def handle_byte_stream(self, event_data, sep=b"\x1b"): - buffer = event_data - # handle prefix - end_index = buffer.find(sep) - if end_index > 0: - curr_sequence = buffer[:end_index] - buffer = buffer[end_index:] - self.handle_single_byte_sequence(curr_sequence) - # special handlig for none found (performance) - elif end_index == -1: - self.handle_single_byte_sequence(buffer) - return - # handle outstanding sequence - while buffer != b"": - end_index = buffer[len(sep) :].find(sep) - if end_index == -1: - curr_sequence = buffer - buffer = b"" - else: - curr_sequence = buffer[: end_index + len(sep)] - buffer = buffer[end_index + len(sep) :] - self.handle_single_byte_sequence(curr_sequence) - - def handle_byte_input(self, event_data): - if not event_data: - return - if event_data == b"": - return - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "handle_byte_input " + event_data.decode("utf8"), - debug.DebugLevel.INFO, - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ByteManager handle_byte_input: Error decoding byte data: " - + str(e), - debug.DebugLevel.ERROR, - ) - self.handle_byte_stream(event_data) - - def handle_single_byte_sequence(self, event_data): - converted_escape_sequence = self.unify_escape_seq(event_data) - - if self.switchCtrlModeOnce > 0: - self.switchCtrlModeOnce -= 1 - - is_control_mode = False - if ( - self.controlMode - and not self.switchCtrlModeOnce == 1 - or not self.controlMode - ): - is_control_mode = self.handle_control_mode(event_data) - - is_command = False - if ( - self.controlMode - and not self.switchCtrlModeOnce == 1 - or not self.controlMode - and self.switchCtrlModeOnce == 1 - ): - if self.lastByteKey == converted_escape_sequence: - if time.time() - self.lastInputTime <= self.env["runtime"][ - "SettingsManager" - ].get_setting_as_float("keyboard", "double_tap_timeout"): - self.repeat += 1 - shortcut_data = b"" - for i in range(self.repeat): - shortcut_data = shortcut_data + converted_escape_sequence - is_command = self.detect_byte_command(shortcut_data) - # fall back to single stroke - do we want this? - if not is_command: - is_command = self.detect_byte_command( - converted_escape_sequence - ) - self.repeat = 1 - if not (is_command or is_control_mode): - self.env["runtime"]["ScreenManager"].inject_text_to_screen( - event_data - ) - if not is_command: - self.repeat = 1 - self.lastByteKey = converted_escape_sequence - self.lastInputTime = time.time() - - def get_last_byte_key(self): - return self.lastByteKey - - def handle_control_mode(self, escapeSequence): - converted_escape_sequence = self.unify_escape_seq(escapeSequence) - if converted_escape_sequence == b"^[R": - self.controlMode = not self.controlMode - self.switchCtrlModeOnce = 0 - if self.controlMode: - self.env["runtime"]["OutputManager"].present_text( - _("Sticky Mode On"), - sound_icon="Accept", - interrupt=True, - flush=True, - ) - else: - self.env["runtime"]["OutputManager"].present_text( - _("Sticky Mode On"), - sound_icon="Cancel", - interrupt=True, - flush=True, - ) - return True - if converted_escape_sequence == b"^[:": - self.switchCtrlModeOnce = 2 - self.env["runtime"]["OutputManager"].present_text( - _("bypass"), sound_icon="PTYBypass", interrupt=True, flush=True - ) - return True - return False - - def send_bytes(self, byteMacro): - pass - - def detect_byte_command(self, escapeSequence): - converted_escape_sequence = self.unify_escape_seq(escapeSequence) - command = self.env["runtime"]["InputManager"].get_command_for_shortcut( - converted_escape_sequence - ) - if command != "": - self.env["runtime"]["EventManager"].put_to_event_queue( - FenrirEventType.execute_command, command - ) - command = "" - return True - return False - - def load_byte_shortcuts( - self, kb_config_path=fenrir_path + "/../../config/keyboard/pty.conf" - ): - kb_config = open(kb_config_path, "r") - while True: - line = kb_config.readline() - if not line: - break - line = line.replace("\n", "") - if line.replace(" ", "") == "": - continue - if line.replace(" ", "").startswith("#"): - continue - if line.count("=") != 1: - continue - values = line.split("=") - clean_shortcut = bytes(values[0], "UTF-8") - repeat = 1 - if len(clean_shortcut) > 2: - if chr(clean_shortcut[1]) == ",": - try: - repeat = int(chr(clean_shortcut[0])) - clean_shortcut = clean_shortcut[2:] - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "ByteManager load_byte_shortcuts: Error parsing repeat count: " - + str(e), - debug.DebugLevel.ERROR, - ) - repeat = 1 - clean_shortcut = clean_shortcut - shortcut = b"" - for i in range(repeat): - shortcut += clean_shortcut - command_name = values[1].upper() - self.env["bindings"][shortcut] = command_name - self.env["runtime"]["DebugManager"].write_debug_out( - "Byte Shortcut: " + str(shortcut) + " command:" + command_name, - debug.DebugLevel.INFO, - on_any_level=True, - ) - kb_config.close() diff --git a/src/fenrirscreenreader/core/eventData.py b/src/fenrirscreenreader/core/eventData.py index 7c90b495..1880b67d 100644 --- a/src/fenrirscreenreader/core/eventData.py +++ b/src/fenrirscreenreader/core/eventData.py @@ -18,8 +18,7 @@ class FenrirEventType(Enum): screen_changed = 5 heart_beat = 6 execute_command = 7 - byte_input = 8 - remote_incomming = 9 + remote_incomming = 8 def __int__(self): return self.value diff --git a/src/fenrirscreenreader/core/eventManager.py b/src/fenrirscreenreader/core/eventManager.py index 68b3f48e..88d81f9e 100644 --- a/src/fenrirscreenreader/core/eventManager.py +++ b/src/fenrirscreenreader/core/eventManager.py @@ -61,8 +61,6 @@ class EventManager: self.env["runtime"]["FenrirManager"].handle_heart_beat(event) elif event["Type"] == FenrirEventType.execute_command: self.env["runtime"]["FenrirManager"].handle_execute_command(event) - elif event["Type"] == FenrirEventType.byte_input: - self.env["runtime"]["FenrirManager"].handle_byte_input(event) elif event["Type"] == FenrirEventType.remote_incomming: self.env["runtime"]["FenrirManager"].handle_remote_incomming(event) diff --git a/src/fenrirscreenreader/core/fenrirManager.py b/src/fenrirscreenreader/core/fenrirManager.py index 23e15eb1..d234fd53 100644 --- a/src/fenrirscreenreader/core/fenrirManager.py +++ b/src/fenrirscreenreader/core/fenrirManager.py @@ -10,6 +10,7 @@ import sys import time from fenrirscreenreader.core import debug +from fenrirscreenreader.core import remoteInstanceRegistry from fenrirscreenreader.core import settingsManager from fenrirscreenreader.core.eventData import FenrirEventType from fenrirscreenreader.core.i18n import _ @@ -132,16 +133,6 @@ class FenrirManager: "onKeyInput" ) - def handle_byte_input(self, event): - if not event["data"] or event["data"] == b"": - return - self.environment["runtime"]["ByteManager"].handle_byte_input( - event["data"] - ) - self.environment["runtime"]["CommandManager"].execute_default_trigger( - "onByteInput" - ) - def handle_execute_command(self, event): if not event["data"] or event["data"] == "": return @@ -456,6 +447,7 @@ class FenrirManager: # Clean up socket files that might not be removed by the driver try: socket_file = None + screen_driver = None if ( "runtime" in self.environment and "SettingsManager" in self.environment["runtime"] @@ -466,14 +458,19 @@ class FenrirManager: ].get_setting("remote", "socket_file") except Exception: pass # Use default socket file path + try: + screen_driver = self.environment["runtime"][ + "SettingsManager" + ].get_setting("screen", "driver") + except Exception: + pass if not socket_file: - # Use default socket file paths - socket_file = "/tmp/fenrirscreenreader-deamon.sock" - if os.path.exists(socket_file): - os.unlink(socket_file) + if screen_driver == "vcsaDriver": + socket_file = "/tmp/fenrirscreenreader-deamon.sock" + if os.path.exists(socket_file): + os.unlink(socket_file) - # Also try PID-based socket file pid_socket_file = ( "/tmp/fenrirscreenreader-" + str(os.getpid()) @@ -483,6 +480,7 @@ class FenrirManager: os.unlink(pid_socket_file) elif os.path.exists(socket_file): os.unlink(socket_file) + remoteInstanceRegistry.remove_instance() except Exception: pass # Ignore errors during socket cleanup diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py index 3c3718b7..8ef7a1b0 100644 --- a/src/fenrirscreenreader/core/generalData.py +++ b/src/fenrirscreenreader/core/generalData.py @@ -14,7 +14,6 @@ general_data = { "managerList": [ "AttributeManager", "PunctuationManager", - "ByteManager", "CursorManager", "ApplicationManager", "CommandManager", @@ -40,7 +39,6 @@ general_data = { "commandFolderList": [ "commands", "onKeyInput", - "onByteInput", "onCursorChange", "onScreenUpdate", "onScreenChanged", diff --git a/src/fenrirscreenreader/core/inputDriver.py b/src/fenrirscreenreader/core/inputDriver.py index f87d4cdb..b45d3352 100644 --- a/src/fenrirscreenreader/core/inputDriver.py +++ b/src/fenrirscreenreader/core/inputDriver.py @@ -15,7 +15,6 @@ class InputDriver: def initialize(self, environment): self.env = environment - self.env["runtime"]["InputManager"].set_shortcut_type("KEY") self._is_initialized = True def shutdown(self): diff --git a/src/fenrirscreenreader/core/inputManager.py b/src/fenrirscreenreader/core/inputManager.py index 601ae3da..48cf35bf 100644 --- a/src/fenrirscreenreader/core/inputManager.py +++ b/src/fenrirscreenreader/core/inputManager.py @@ -21,17 +21,9 @@ fenrir_path = os.path.dirname(currentdir) class InputManager: def __init__(self): - self.shortcutType = "KEY" self.executeDeviceGrab = False self.lastDetectedDevices = None - def set_shortcut_type(self, shortcutType="KEY"): - if shortcutType in ["KEY", "BYTE"]: - self.shortcutType = shortcutType - - def get_shortcut_type(self): - return self.shortcutType - def initialize(self, environment): self.env = environment self.env["runtime"]["SettingsManager"].load_driver( diff --git a/src/fenrirscreenreader/core/remoteInstanceRegistry.py b/src/fenrirscreenreader/core/remoteInstanceRegistry.py new file mode 100644 index 00000000..987d2634 --- /dev/null +++ b/src/fenrirscreenreader/core/remoteInstanceRegistry.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + +import json +import os +import tempfile +import time + + +INSTANCE_TIMEOUT_SEC = 30.0 + + +def get_registry_dir(): + return os.path.join( + tempfile.gettempdir(), + f"fenrirscreenreader-instances-{os.getuid()}", + ) + + +def get_instance_file(pid=None): + if pid is None: + pid = os.getpid() + return os.path.join(get_registry_dir(), f"{pid}.json") + + +def write_instance(instance_data): + registry_dir = get_registry_dir() + os.makedirs(registry_dir, mode=0o700, exist_ok=True) + os.chmod(registry_dir, 0o700) + instance_data["updated_at"] = time.time() + instance_path = get_instance_file(instance_data.get("pid")) + with open(instance_path, "w", encoding="utf-8") as instance_file: + json.dump(instance_data, instance_file, sort_keys=True) + instance_file.write("\n") + + +def remove_instance(pid=None): + try: + os.unlink(get_instance_file(pid)) + except FileNotFoundError: + pass + + +def process_exists(pid): + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def list_instances(): + registry_dir = get_registry_dir() + instances = [] + try: + instance_files = os.listdir(registry_dir) + except FileNotFoundError: + return instances + + now = time.time() + for instance_name in instance_files: + instance_path = os.path.join(registry_dir, instance_name) + try: + with open(instance_path, "r", encoding="utf-8") as instance_file: + instance_data = json.load(instance_file) + pid = int(instance_data.get("pid", 0)) + updated_at = float(instance_data.get("updated_at", 0)) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + continue + + if not pid or not process_exists(pid) or now - updated_at > INSTANCE_TIMEOUT_SEC: + try: + os.unlink(instance_path) + except OSError: + pass + continue + instances.append(instance_data) + + return sorted(instances, key=lambda instance: int(instance.get("pid", 0))) diff --git a/src/fenrirscreenreader/core/remoteManager.py b/src/fenrirscreenreader/core/remoteManager.py index dc64ca54..ac96a4e2 100644 --- a/src/fenrirscreenreader/core/remoteManager.py +++ b/src/fenrirscreenreader/core/remoteManager.py @@ -26,14 +26,21 @@ command interrupt """ +import hashlib import os +import tempfile import time from fenrirscreenreader.core import debug +from fenrirscreenreader.core import remoteInstanceRegistry from fenrirscreenreader.core.eventData import FenrirEventType from fenrirscreenreader.core.i18n import _ +REMOTE_COMMAND_LOCK_TIMEOUT_SEC = 2.0 +REMOTE_COMMAND_LOCK_PREFIX = f"fenrirscreenreader-{os.getuid()}-remote-" + + class RemoteManager: def __init__(self): # command controll @@ -48,6 +55,8 @@ class RemoteManager: self.resetWindowConst = "RESETWINDOW" self.setClipboardConst = "CLIPBOARD " self.exportClipboardConst = "EXPORTCLIPBOARD" + self.listInstancesConst = "LS" + self.listInstancesLongConst = "LIST" # setting controll self.settingConst = "SETTING " self.setSettingConst = "SET " @@ -208,6 +217,14 @@ class RemoteManager: "success": True, "message": "Clipboard exported to file", } + elif upper_command_text in ( + self.listInstancesConst, + self.listInstancesLongConst, + ): + return { + "success": True, + "message": self.list_instances(), + } else: return { "success": False, @@ -257,6 +274,102 @@ class RemoteManager: self.set_clipboard(parameter_text) elif upper_command_text.startswith(self.exportClipboardConst): self.export_clipboard() + elif upper_command_text in ( + self.listInstancesConst, + self.listInstancesLongConst, + ): + return + + def _get_remote_command_lock_path(self, event_data): + event_hash = hashlib.sha256(event_data.encode("utf-8")).hexdigest() + return os.path.join( + tempfile.gettempdir(), + f"{REMOTE_COMMAND_LOCK_PREFIX}{event_hash}.lock", + ) + + def _cleanup_stale_remote_command_locks(self, now): + try: + lock_files = os.listdir(tempfile.gettempdir()) + except OSError: + return + + for lock_file in lock_files: + if not lock_file.startswith(REMOTE_COMMAND_LOCK_PREFIX): + continue + lock_path = os.path.join(tempfile.gettempdir(), lock_file) + try: + lock_age = now - os.stat(lock_path).st_mtime + if lock_age > REMOTE_COMMAND_LOCK_TIMEOUT_SEC: + os.unlink(lock_path) + except OSError: + pass + + def _try_create_remote_command_lock(self, lock_path, now): + try: + lock_fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + return False + + with os.fdopen(lock_fd, "w", encoding="utf-8") as lock_file: + lock_file.write(f"{os.getpid()} {now}\n") + return True + + def _claim_remote_command(self, event_data): + lock_path = self._get_remote_command_lock_path(event_data) + now = time.time() + self._cleanup_stale_remote_command_locks(now) + if self._try_create_remote_command_lock(lock_path, now): + return True + + try: + with open(lock_path, "r", encoding="utf-8") as lock_file: + lock_parts = lock_file.readline().strip().split() + lock_pid = int(lock_parts[0]) if lock_parts else 0 + lock_stat = os.stat(lock_path) + except FileNotFoundError: + return self._try_create_remote_command_lock(lock_path, now) + except (OSError, ValueError): + return False + + if lock_pid == os.getpid(): + return True + + if now - lock_stat.st_mtime < REMOTE_COMMAND_LOCK_TIMEOUT_SEC: + return False + + try: + os.unlink(lock_path) + except FileNotFoundError: + pass + except OSError: + return False + + return self._try_create_remote_command_lock(lock_path, now) + + def list_instances(self): + instances = remoteInstanceRegistry.list_instances() + if not instances: + return "No Fenrir instances registered" + + lines = [] + for instance in instances: + socket_files = ", ".join(instance.get("socket_files", [])) + x11_window_id = instance.get("x11_window_id") or "none" + main_socket = "yes" if instance.get("main_socket") else "no" + lines.append( + "pid={pid} ppid={ppid} screen={screen} keyboard={keyboard} " + "main_socket={main_socket} x11_window_id={x11_window_id} " + "sockets={sockets}".format( + pid=instance.get("pid", ""), + ppid=instance.get("ppid", ""), + screen=instance.get("screen_driver", ""), + keyboard=instance.get("keyboard_driver", ""), + main_socket=main_socket, + x11_window_id=x11_window_id, + sockets=socket_files, + ) + ) + return "\n".join(lines) def temp_disable_speech(self): self.env["runtime"]["OutputManager"].temp_disable_speech() @@ -381,6 +494,14 @@ class RemoteManager: ) try: + if upper_event_data in ( + self.listInstancesConst, + self.listInstancesLongConst, + ): + return { + "success": True, + "message": self.list_instances(), + } if upper_event_data.startswith(self.settingConst): settings_text = event_data[len(self.settingConst) :] return self.handle_settings_change_with_response(settings_text) @@ -406,6 +527,9 @@ class RemoteManager: debug.DebugLevel.INFO, ) + if not self._claim_remote_command(event_data): + return + if upper_event_data.startswith(self.settingConst): settings_text = event_data[len(self.settingConst) :] self.handle_settings_change(settings_text) diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 081a84f9..8105bc46 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -11,7 +11,6 @@ from configparser import ConfigParser from fenrirscreenreader.core import applicationManager from fenrirscreenreader.core import attributeManager from fenrirscreenreader.core import barrierManager -from fenrirscreenreader.core import byteManager from fenrirscreenreader.core import commandManager from fenrirscreenreader.core import cursorManager from fenrirscreenreader.core import debug @@ -410,9 +409,7 @@ class SettingsManager: if setting == "driver": valid_drivers = [ "evdevDriver", - "ptyDriver", "x11Driver", - "atspiDriver", "dummyDriver", ] if value not in valid_drivers: @@ -496,18 +493,6 @@ class SettingsManager: if cliArgs.print: self.set_setting("general", "debug_level", 3) self.set_setting("general", "debug_mode", "PRINT") - if cliArgs.emulated_pty: - # Set PTY driver settings - pty_settings = { - "screen": {"driver": "ptyDriver"}, - "keyboard": {"driver": "ptyDriver", "keyboard_layout": "pty"} - } - for section, settings in pty_settings.items(): - for key, value in settings.items(): - self.set_setting(section, key, value) - if cliArgs.emulated_evdev: - self.set_setting("screen", "driver", "ptyDriver") - self.set_setting("keyboard", "driver", "evdevDriver") if cliArgs.x11: self.set_setting("screen", "driver", "ptyDriver") self.set_setting("keyboard", "driver", "x11Driver") @@ -631,9 +616,6 @@ class SettingsManager: environment["runtime"]["OutputManager"] = outputManager.OutputManager() environment["runtime"]["OutputManager"].initialize(environment) - environment["runtime"]["ByteManager"] = byteManager.ByteManager() - environment["runtime"]["ByteManager"].initialize(environment) - environment["runtime"]["InputManager"] = inputManager.InputManager() environment["runtime"]["InputManager"].initialize(environment) @@ -656,89 +638,45 @@ class SettingsManager: ] = diffReviewManager.DiffReviewManager() environment["runtime"]["DiffReviewManager"].initialize(environment) - if environment["runtime"]["InputManager"].get_shortcut_type() == "KEY": - if not os.path.exists( - self.get_setting("keyboard", "keyboard_layout") + if not os.path.exists( + self.get_setting("keyboard", "keyboard_layout") + ): + if os.path.exists( + settings_root + + "keyboard/" + + self.get_setting("keyboard", "keyboard_layout") ): - if os.path.exists( + self.set_setting( + "keyboard", + "keyboard_layout", settings_root + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout"), - ) - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - if os.path.exists( - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf" - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf", - ) - environment["runtime"]["InputManager"].load_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - else: + + self.get_setting("keyboard", "keyboard_layout"), + ) environment["runtime"]["InputManager"].load_shortcuts( self.get_setting("keyboard", "keyboard_layout") ) - elif ( - environment["runtime"]["InputManager"].get_shortcut_type() - == "BYTE" - ): - if not os.path.exists( - self.get_setting("keyboard", "keyboard_layout") + if os.path.exists( + settings_root + + "keyboard/" + + self.get_setting("keyboard", "keyboard_layout") + + ".conf" ): - if os.path.exists( + self.set_setting( + "keyboard", + "keyboard_layout", settings_root + "keyboard/" + self.get_setting("keyboard", "keyboard_layout") - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout"), - ) - environment["runtime"]["ByteManager"].load_byte_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - if os.path.exists( - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf" - ): - self.set_setting( - "keyboard", - "keyboard_layout", - settings_root - + "keyboard/" - + self.get_setting("keyboard", "keyboard_layout") - + ".conf", - ) - environment["runtime"]["ByteManager"].load_byte_shortcuts( - self.get_setting("keyboard", "keyboard_layout") - ) - else: - environment["runtime"]["ByteManager"].load_byte_shortcuts( + + ".conf", + ) + environment["runtime"]["InputManager"].load_shortcuts( self.get_setting("keyboard", "keyboard_layout") ) + else: + environment["runtime"]["InputManager"].load_shortcuts( + self.get_setting("keyboard", "keyboard_layout") + ) environment["runtime"]["CursorManager"] = cursorManager.CursorManager() environment["runtime"]["CursorManager"].initialize(environment) diff --git a/src/fenrirscreenreader/core/vmenuManager.py b/src/fenrirscreenreader/core/vmenuManager.py index 415f8059..f01d0825 100755 --- a/src/fenrirscreenreader/core/vmenuManager.py +++ b/src/fenrirscreenreader/core/vmenuManager.py @@ -33,9 +33,7 @@ class VmenuManager: self.env = environment # use default path self.defaultVMenuPath = ( - fenrir_path - + "/commands/vmenu-profiles/" - + self.env["runtime"]["InputManager"].get_shortcut_type() + fenrir_path + "/commands/vmenu-profiles/KEY" ) # if there is no user configuration if ( @@ -49,9 +47,7 @@ class VmenuManager: ].get_setting("menu", "vmenu_path") if not self.defaultVMenuPath.endswith("/"): self.defaultVMenuPath += "/" - self.defaultVMenuPath += self.env["runtime"][ - "InputManager" - ].get_shortcut_type() + self.defaultVMenuPath += "KEY" self.create_menu_tree() self.closeAfterAction = False diff --git a/src/fenrirscreenreader/inputDriver/debugDriver.py b/src/fenrirscreenreader/inputDriver/debugDriver.py index 88be858e..c7a047cb 100644 --- a/src/fenrirscreenreader/inputDriver/debugDriver.py +++ b/src/fenrirscreenreader/inputDriver/debugDriver.py @@ -16,7 +16,6 @@ class driver(inputDriver): def initialize(self, environment): self.env = environment - self.env["runtime"]["InputManager"].set_shortcut_type("KEY") self._initialized = True print("Input Debug Driver: Initialized") diff --git a/src/fenrirscreenreader/inputDriver/evdevDriver.py b/src/fenrirscreenreader/inputDriver/evdevDriver.py index e0bcd37b..6cef33c0 100644 --- a/src/fenrirscreenreader/inputDriver/evdevDriver.py +++ b/src/fenrirscreenreader/inputDriver/evdevDriver.py @@ -87,7 +87,6 @@ class driver(inputDriver): if libraries are not available. """ self.env = environment - self.env["runtime"]["InputManager"].set_shortcut_type("KEY") global _evdevAvailable global _udevAvailable global _evdevAvailableError diff --git a/src/fenrirscreenreader/inputDriver/ptyDriver.py b/src/fenrirscreenreader/inputDriver/ptyDriver.py deleted file mode 100644 index 74f26ee3..00000000 --- a/src/fenrirscreenreader/inputDriver/ptyDriver.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributors. - -import time - -from fenrirscreenreader.core import debug -from fenrirscreenreader.core.inputDriver import InputDriver as inputDriver - - -class driver(inputDriver): - """PTY (Pseudo-terminal) input driver for Fenrir screen reader. - - This driver provides input handling for terminal emulation environments - where direct device access (evdev) is not available or appropriate. - It uses byte-based input processing instead of key event processing. - - This is primarily used when running Fenrir in terminal emulators, - desktop environments, or other contexts where traditional TTY device - access is not available. - - Features: - - Byte-based input processing - - Terminal emulation compatibility - - Simplified input handling for non-TTY environments - """ - def __init__(self): - self._is_initialized = False - inputDriver.__init__(self) - - def initialize(self, environment): - """Initialize the PTY input driver. - - Sets the input manager to use byte-based shortcuts instead of - key-based shortcuts, enabling proper operation in terminal - emulation environments. - - Args: - environment: Fenrir environment dictionary - - Returns: - bool: True if initialization successful, False otherwise - """ - try: - if environment is None: - raise ValueError("Environment cannot be None") - - self.env = environment - - # Validate required managers are available - if "runtime" not in self.env: - raise ValueError("Runtime environment missing") - if "InputManager" not in self.env["runtime"]: - raise ValueError("InputManager not available") - - self.env["runtime"]["InputManager"].set_shortcut_type("BYTE") - self._is_initialized = True - - self.env["runtime"]["DebugManager"].write_debug_out( - "PTY inputDriver: Initialized with byte-based shortcuts", - debug.DebugLevel.INFO - ) - return True - - except Exception as e: - # Log error if possible, otherwise fallback to print - try: - if hasattr(self, 'env') and self.env and "runtime" in self.env: - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY inputDriver: Initialization failed: {e}", - debug.DebugLevel.ERROR - ) - else: - print(f"PTY inputDriver initialization error: {e}") - except: - print(f"PTY inputDriver initialization error: {e}") - - self._is_initialized = False - return False - - def shutdown(self): - """Shutdown the PTY input driver. - - Performs cleanup operations when the driver is being stopped. - For PTY driver, this involves cleaning up any resources and - logging the shutdown. - """ - if not self._is_initialized: - return - - try: - self.env["runtime"]["DebugManager"].write_debug_out( - "PTY inputDriver: Shutting down", - debug.DebugLevel.INFO - ) - except Exception as e: - # Fallback logging if debug manager is unavailable - print(f"PTY inputDriver shutdown error: {e}") - finally: - self._is_initialized = False - - def get_input_event(self): - """Get input event from PTY. - - For PTY driver, input events are handled through the byte-based - shortcut system rather than direct device events. This method - returns None as PTY input is processed through the screen driver - and InputManager's byte processing. - - Returns: - None: PTY driver uses byte-based processing, not event-based - """ - return None - - def is_device_connected(self): - """Check if PTY input device is connected. - - For PTY driver, the "device" is the terminal interface itself, - which is considered connected if the driver is initialized. - - Returns: - bool: True if driver is initialized, False otherwise - """ - return self._is_initialized - - def get_device_name(self): - """Get the name of the PTY input device. - - Returns: - str: Human-readable name of the PTY input device - """ - return "PTY (Pseudo-terminal) Input" - - def grab_devices(self, grab=True): - """Grab or release input devices. - - For PTY driver, device grabbing is not applicable since input - is processed through terminal emulation rather than direct - device access. - - Args: - grab (bool): Whether to grab (True) or release (False) devices - - Returns: - bool: Always returns True for PTY driver (no-op success) - """ - if not self._is_initialized: - return False - - action = "grab" if grab else "release" - self.env["runtime"]["DebugManager"].write_debug_out( - f"PTY inputDriver: {action} devices (no-op for PTY)", - debug.DebugLevel.INFO - ) - return True - - def has_device_detection(self): - """Check if driver supports device detection. - - PTY driver does not support dynamic device detection since - it operates on the terminal interface directly. - - Returns: - bool: Always False for PTY driver - """ - return False - - def get_device_list(self): - """Get list of available input devices. - - For PTY driver, there is only one logical device - the terminal - interface itself. - - Returns: - list: Single-item list containing PTY device info - """ - if not self._is_initialized: - return [] - - return [{ - 'name': 'PTY Terminal', - 'path': '/dev/pts/*', - 'type': 'terminal', - 'connected': True - }] - - def get_led_state(self, led_mask=None): - """Get LED state information. - - PTY driver cannot access LED states since it operates through - terminal emulation rather than direct hardware access. - - Args: - led_mask: LED mask parameter (ignored for PTY) - - Returns: - dict: Empty dict (no LED access for PTY) - """ - return {} - - def set_led_state(self, led_dict): - """Set LED states. - - PTY driver cannot control LEDs since it operates through - terminal emulation rather than direct hardware access. - - Args: - led_dict (dict): LED state dictionary (ignored for PTY) - - Returns: - bool: Always False (LED control not supported) - """ - return False diff --git a/src/fenrirscreenreader/inputDriver/x11Driver.py b/src/fenrirscreenreader/inputDriver/x11Driver.py index f0e35e2f..b8934d61 100644 --- a/src/fenrirscreenreader/inputDriver/x11Driver.py +++ b/src/fenrirscreenreader/inputDriver/x11Driver.py @@ -160,7 +160,6 @@ class driver(inputDriver): def initialize(self, environment): self.env = environment - self.env["runtime"]["InputManager"].set_shortcut_type("KEY") if display is None: self.fail_startup("python-xlib is not available: " + str(_x_error)) self.display = display.Display() diff --git a/src/fenrirscreenreader/remoteDriver/unixDriver.py b/src/fenrirscreenreader/remoteDriver/unixDriver.py index 088c7339..c335ef66 100644 --- a/src/fenrirscreenreader/remoteDriver/unixDriver.py +++ b/src/fenrirscreenreader/remoteDriver/unixDriver.py @@ -8,15 +8,22 @@ import os import os.path import select import socket +import time from fenrirscreenreader.core import debug +from fenrirscreenreader.core import remoteInstanceRegistry from fenrirscreenreader.core.eventData import FenrirEventType from fenrirscreenreader.core.remoteDriver import RemoteDriver as remoteDriver +MAIN_SOCKET_FILE = "/tmp/fenrirscreenreader-deamon.sock" + + class driver(remoteDriver): def __init__(self): remoteDriver.__init__(self) + self.fenrirSocks = [] + self.socket_files = [] def initialize(self, environment): self.env = environment @@ -26,9 +33,7 @@ class driver(remoteDriver): self.watch_dog, multiprocess=False ) - def watch_dog(self, active, event_queue): - # echo "command say this is a test" | socat - - # UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock + def _get_configured_socket_file(self): socket_file = "" try: socket_file = self.env["runtime"]["SettingsManager"].get_setting( @@ -40,62 +45,153 @@ class driver(remoteDriver): + str(e), debug.DebugLevel.ERROR, ) - if socket_file == "": - if ( - self.env["runtime"]["SettingsManager"].get_setting( - "screen", "driver" - ) - == "vcsaDriver" - ): - socket_file = "/tmp/fenrirscreenreader-deamon.sock" - else: - socket_file = ( - "/tmp/fenrirscreenreader-" + str(os.getppid()) + ".sock" - ) + return socket_file + + def _get_socket_candidates(self): + configured_socket_file = self._get_configured_socket_file() + if configured_socket_file: + return [(configured_socket_file, False)] + + screen_driver = self.env["runtime"]["SettingsManager"].get_setting( + "screen", "driver" + ) + if screen_driver == "vcsaDriver": + return [(MAIN_SOCKET_FILE, False)] + + private_socket_file = ( + "/tmp/fenrirscreenreader-" + str(os.getpid()) + ".sock" + ) + return [(private_socket_file, False), (MAIN_SOCKET_FILE, True)] + + def _is_socket_active(self, socket_file): + test_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + test_sock.settimeout(0.2) + test_sock.connect(socket_file) + return True + except OSError: + return False + finally: + test_sock.close() + + def _bind_socket(self, socket_file, optional): if os.path.exists(socket_file): + if optional and self._is_socket_active(socket_file): + return None os.unlink(socket_file) - self.fenrirSock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.fenrirSock.bind(socket_file) - os.chmod(socket_file, 0o666) # Allow all users to read/write - self.fenrirSock.listen(1) + + fenrir_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + fenrir_sock.bind(socket_file) + os.chmod(socket_file, 0o666) # Allow all users to read/write + fenrir_sock.listen(1) + except OSError: + fenrir_sock.close() + if optional: + return None + raise + return fenrir_sock + + def _register_instance(self): + settings_manager = self.env["runtime"]["SettingsManager"] + instance_data = { + "pid": os.getpid(), + "ppid": os.getppid(), + "socket_files": self.socket_files, + "main_socket": MAIN_SOCKET_FILE in self.socket_files, + "screen_driver": settings_manager.get_setting("screen", "driver"), + "keyboard_driver": settings_manager.get_setting("keyboard", "driver"), + "x11_window_id": settings_manager.get_setting( + "keyboard", "x11_window_id" + ), + "created_at": time.time(), + } + remoteInstanceRegistry.write_instance(instance_data) + + def _cleanup(self): + for fenrir_sock in self.fenrirSocks: + try: + fenrir_sock.close() + except OSError: + pass + self.fenrirSocks = [] + + for socket_file in self.socket_files: + try: + if os.path.exists(socket_file): + os.unlink(socket_file) + except OSError: + pass + self.socket_files = [] + remoteInstanceRegistry.remove_instance() + + def _handle_client(self, client_sock, event_queue): + try: + rawdata = client_sock.recv(8129) + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + "unixDriver watch_dog: Error receiving data from client: " + + str(e), + debug.DebugLevel.ERROR, + ) + rawdata = b"" + + try: + data = rawdata.decode("utf-8").rstrip().lstrip() + upper_data = data.upper() + if upper_data in ("LS", "LIST", "COMMAND LS", "COMMAND LIST"): + response = self.env["runtime"][ + "RemoteManager" + ].handle_remote_incomming_with_response(data) + client_sock.sendall((response["message"] + "\n").encode("utf-8")) + return + + event_queue.put( + { + "Type": FenrirEventType.remote_incomming, + "data": data, + } + ) + except Exception as e: + self.env["runtime"]["DebugManager"].write_debug_out( + "unixDriver watch_dog: Error decoding/queuing data: " + + str(e), + debug.DebugLevel.ERROR, + ) + + def watch_dog(self, active, event_queue): + # echo "command say this is a test" | socat - + # UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock + for socket_file, optional in self._get_socket_candidates(): + fenrir_sock = self._bind_socket(socket_file, optional) + if fenrir_sock is None: + continue + self.fenrirSocks.append(fenrir_sock) + self.socket_files.append(socket_file) + + if not self.fenrirSocks: + return + + self._register_instance() + last_register = time.time() while active.value: + if time.time() - last_register > 10.0: + self._register_instance() + last_register = time.time() + # Check if the client is still connected and if data is available: try: - r, _, _ = select.select([self.fenrirSock], [], [], 0.8) + r, _, _ = select.select(self.fenrirSocks, [], [], 0.8) except select.error: break if r == []: continue - if self.fenrirSock in r: - client_sock, client_addr = self.fenrirSock.accept() + for fenrir_sock in r: + client_sock, client_addr = fenrir_sock.accept() # Ensure client socket is always closed to prevent resource # leaks try: - try: - rawdata = client_sock.recv(8129) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver watch_dog: Error receiving data from " - "client: " - + str(e), - debug.DebugLevel.ERROR, - ) - rawdata = b"" # Set default empty data if recv fails - - try: - data = rawdata.decode("utf-8").rstrip().lstrip() - event_queue.put( - { - "Type": FenrirEventType.remote_incomming, - "data": data, - } - ) - except Exception as e: - self.env["runtime"]["DebugManager"].write_debug_out( - "unixDriver watch_dog: Error decoding/queuing data: " - + str(e), - debug.DebugLevel.ERROR, - ) + self._handle_client(client_sock, event_queue) finally: # Always close client socket, even if data processing fails try: @@ -106,8 +202,8 @@ class driver(remoteDriver): + str(e), debug.DebugLevel.ERROR, ) - if self.fenrirSock: - self.fenrirSock.close() - self.fenrirSock = None - if os.path.exists(socket_file): - os.unlink(socket_file) + self._cleanup() + + def shutdown(self): + self._cleanup() + remoteDriver.shutdown(self) diff --git a/src/fenrirscreenreader/screenDriver/ptyDriver.py b/src/fenrirscreenreader/screenDriver/ptyDriver.py index 1ca7f5a6..cbd2d246 100644 --- a/src/fenrirscreenreader/screenDriver/ptyDriver.py +++ b/src/fenrirscreenreader/screenDriver/ptyDriver.py @@ -254,9 +254,6 @@ class driver(screenDriver): # Load configurable timeouts from settings self._load_pty_settings() - self.shortcutType = self.env["runtime"][ - "InputManager" - ].get_shortcut_type() self.env["runtime"]["ProcessManager"].add_custom_event_thread( self.terminal_emulation ) @@ -403,31 +400,23 @@ class driver(screenDriver): } ) break - if self.shortcutType == "KEY": - try: - self.inject_text_to_screen(msg_bytes) - except Exception as e: - self.env["runtime"][ - "DebugManager" - ].write_debug_out( - "ptyDriver getInputData: Error injecting text to screen: " - + str(e), - debug.DebugLevel.ERROR, - ) - event_queue.put( - { - "Type": FenrirEventType.stop_main_loop, - "data": None, - } - ) - break - else: + try: + self.inject_text_to_screen(msg_bytes) + except Exception as e: + self.env["runtime"][ + "DebugManager" + ].write_debug_out( + "ptyDriver getInputData: Error injecting text to screen: " + + str(e), + debug.DebugLevel.ERROR, + ) event_queue.put( { - "Type": FenrirEventType.byte_input, - "data": msg_bytes, + "Type": FenrirEventType.stop_main_loop, + "data": None, } ) + break # output if self.p_out in r: try: diff --git a/tests/integration/test_remote_control.py b/tests/integration/test_remote_control.py index 53b73b5d..9b875f58 100644 --- a/tests/integration/test_remote_control.py +++ b/tests/integration/test_remote_control.py @@ -216,6 +216,68 @@ class TestRemoteDataFormat: assert result["success"] is False assert "Unknown command format" in result["message"] + def test_list_instances_top_level_command(self, mock_environment): + """Test listing registered Fenrir instances.""" + self.manager.initialize(mock_environment) + + with patch( + "fenrirscreenreader.core.remoteManager.remoteInstanceRegistry.list_instances", + return_value=[ + { + "pid": 123, + "ppid": 100, + "screen_driver": "ptyDriver", + "keyboard_driver": "x11Driver", + "main_socket": True, + "x11_window_id": "0x123", + "socket_files": [ + "/tmp/fenrirscreenreader-123.sock", + "/tmp/fenrirscreenreader-deamon.sock", + ], + } + ], + ): + result = self.manager.handle_remote_incomming_with_response("ls") + + assert result["success"] is True + assert "pid=123" in result["message"] + assert "x11_window_id=0x123" in result["message"] + assert "/tmp/fenrirscreenreader-123.sock" in result["message"] + + def test_remote_incoming_suppresses_command_claimed_by_other_instance( + self, mock_environment, tmp_path + ): + """Test untargeted duplicate remote commands only run in one process.""" + self.manager.initialize(mock_environment) + lock_path = tmp_path / "remote-command.lock" + lock_path.write_text("999999 1\n") + + with patch.object( + self.manager, + "_get_remote_command_lock_path", + return_value=str(lock_path), + ): + self.manager.handle_remote_incomming("command say duplicated") + + mock_environment["runtime"]["OutputManager"].speak_text.assert_not_called() + + def test_remote_incoming_allows_same_instance_repeat( + self, mock_environment, tmp_path + ): + """Test repeated direct commands to one instance are not suppressed.""" + self.manager.initialize(mock_environment) + lock_path = tmp_path / "remote-command.lock" + + with patch.object( + self.manager, + "_get_remote_command_lock_path", + return_value=str(lock_path), + ): + self.manager.handle_remote_incomming("command say repeat") + self.manager.handle_remote_incomming("command say repeat") + + assert mock_environment["runtime"]["OutputManager"].speak_text.call_count == 2 + @pytest.mark.integration @pytest.mark.remote diff --git a/tests/unit/test_pty_terminal_sequences.py b/tests/unit/test_pty_terminal_sequences.py new file mode 100644 index 00000000..2aef6b04 --- /dev/null +++ b/tests/unit/test_pty_terminal_sequences.py @@ -0,0 +1,22 @@ +import pytest + +from fenrirscreenreader.screenDriver.ptyDriver import Terminal + + +class DummyProcessInput: + def __init__(self): + self.data = [] + + def write(self, data): + self.data.append(data) + + +@pytest.mark.unit +def test_csi_sequences_with_intermediate_characters_do_not_render_final_byte(): + terminal = Terminal(10, 3, DummyProcessInput()) + + terminal.feed(b"\x1b[2026$p\x1b[2048$pX") + screen = terminal.get_screen_content() + + assert screen["text"].splitlines()[0] == "X " + assert "p" not in screen["text"] diff --git a/tests/unit/test_settings_validation.py b/tests/unit/test_settings_validation.py index 10ad1771..e5beeda7 100644 --- a/tests/unit/test_settings_validation.py +++ b/tests/unit/test_settings_validation.py @@ -136,11 +136,14 @@ class TestDriverValidation: """Keyboard driver should only accept whitelisted values.""" # Valid drivers self.manager._validate_setting_value("keyboard", "driver", "evdevDriver") - self.manager._validate_setting_value("keyboard", "driver", "ptyDriver") self.manager._validate_setting_value("keyboard", "driver", "x11Driver") - self.manager._validate_setting_value("keyboard", "driver", "atspiDriver") self.manager._validate_setting_value("keyboard", "driver", "dummyDriver") + with pytest.raises(ValueError, match="Invalid input driver"): + self.manager._validate_setting_value("keyboard", "driver", "ptyDriver") + with pytest.raises(ValueError, match="Invalid input driver"): + self.manager._validate_setting_value("keyboard", "driver", "atspiDriver") + # Invalid driver with pytest.raises(ValueError, match="Invalid input driver"): self.manager._validate_setting_value("keyboard", "driver", "badDriver") diff --git a/tests/unit/test_time_command.py b/tests/unit/test_time_command.py new file mode 100644 index 00000000..08cb6a8a --- /dev/null +++ b/tests/unit/test_time_command.py @@ -0,0 +1,83 @@ +""" +Unit tests for automatic time announcement behavior. +""" + +import datetime +import importlib.util +from pathlib import Path +from unittest.mock import Mock + + +def _load_time_module(): + module_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "fenrirscreenreader" + / "commands" + / "onHeartBeat" + / "76000-time.py" + ) + spec = importlib.util.spec_from_file_location("fenrir_time_command", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _create_environment(output_manager): + now = datetime.datetime.now() + settings_manager = Mock() + settings_manager.get_setting.side_effect = lambda section, setting: { + ("time", "on_minutes"): str(now.minute).zfill(2), + ("general", "date_format"): "%A, %B %d, %Y", + ("general", "time_format"): "%I:%M%P", + }[(section, setting)] + settings_manager.get_setting_as_int.side_effect = lambda section, setting: { + ("time", "delay_sec"): 0, + }[(section, setting)] + settings_manager.get_setting_as_bool.side_effect = lambda section, setting: { + ("time", "enabled"): True, + ("time", "present_date"): True, + ("time", "present_time"): True, + ("time", "interrupt"): False, + ("time", "announce"): True, + }[(section, setting)] + + return { + "runtime": { + "OutputManager": output_manager, + "SettingsManager": settings_manager, + } + } + + +def test_time_announcement_lock_suppresses_second_instance(tmp_path): + time_module = _load_time_module() + lock_path = tmp_path / "time-announcement.lock" + first_output = Mock( + interrupt_output=Mock(), + play_sound_icon=Mock(), + present_text=Mock(), + ) + second_output = Mock( + interrupt_output=Mock(), + play_sound_icon=Mock(), + present_text=Mock(), + ) + first_command = time_module.command() + second_command = time_module.command() + + first_command.initialize(_create_environment(first_output)) + second_command.initialize(_create_environment(second_output)) + first_command._get_announcement_lock_path = lambda: str(lock_path) + second_command._get_announcement_lock_path = lambda: str(lock_path) + first_command.last_time -= datetime.timedelta(hours=1) + second_command.last_time -= datetime.timedelta(hours=1) + + first_command.run() + second_command.run() + + first_output.play_sound_icon.assert_called_once_with("announce") + first_output.present_text.assert_called() + second_output.interrupt_output.assert_not_called() + second_output.play_sound_icon.assert_not_called() + second_output.present_text.assert_not_called() diff --git a/tests/unit/test_x11_terminal_mode.py b/tests/unit/test_x11_terminal_mode.py index 10f5f245..3c780eae 100644 --- a/tests/unit/test_x11_terminal_mode.py +++ b/tests/unit/test_x11_terminal_mode.py @@ -30,14 +30,17 @@ def test_x11_mode_runs_in_foreground(): @pytest.mark.unit -def test_x11_mode_rejects_other_emulated_modes(): +def test_removed_emulated_pty_flag_is_rejected(): fenrir = load_fenrir_entrypoint() - args = fenrir.create_argument_parser().parse_args(["-x", "-e"]) + with pytest.raises(SystemExit): + fenrir.create_argument_parser().parse_args(["-e"]) - is_valid, error = fenrir.validate_arguments(args) - assert is_valid is False - assert "--x11" in error +@pytest.mark.unit +def test_removed_emulated_evdev_flag_is_rejected(): + fenrir = load_fenrir_entrypoint() + with pytest.raises(SystemExit): + fenrir.create_argument_parser().parse_args(["-E"]) @pytest.mark.unit @@ -50,6 +53,19 @@ def test_x11_cli_accepts_window_id(): assert args.x11_window_id == "0x123" +@pytest.mark.unit +def test_x11_window_id_requires_x11_mode(): + fenrir = load_fenrir_entrypoint() + args = fenrir.create_argument_parser().parse_args( + ["--x11-window-id", "0x123"] + ) + + is_valid, error = fenrir.validate_arguments(args) + + assert is_valid is False + assert "--x11-window-id requires --x11" == error + + @pytest.mark.unit def test_x11_key_name_mapping_for_keypad_and_capslock(): x11 = X11Driver() diff --git a/tools/fenrir.pot b/tools/fenrir.pot index 2d02e8c2..e7b85c79 100644 --- a/tools/fenrir.pot +++ b/tools/fenrir.pot @@ -744,7 +744,6 @@ msgid "Script file is not executable" msgstr "" #: ../src/fenrirscreenreader\commands\commands\temp_disable_speech.py:17 -#: ../src/fenrirscreenreader\commands\onByteInput\15000-enable_temp_speech.py:17 #: ../src/fenrirscreenreader\commands\onKeyInput\15000-enable_temp_speech.py:17 msgid "disables speech until next keypress" msgstr "" @@ -898,7 +897,6 @@ msgid "speech disabled" msgstr "" #: ../src/fenrirscreenreader\commands\commands\toggle_speech.py:25 -#: ../src/fenrirscreenreader\commands\onByteInput\15000-enable_temp_speech.py:24 #: ../src/fenrirscreenreader\commands\onKeyInput\15000-enable_temp_speech.py:28 msgid "speech enabled" msgstr "" @@ -1089,15 +1087,6 @@ msgstr "" msgid "default" msgstr "" -#: ../src/fenrirscreenreader\core\byteManager.py:103 -#: ../src/fenrirscreenreader\core\byteManager.py:105 -msgid "Sticky Mode On" -msgstr "" - -#: ../src/fenrirscreenreader\core\byteManager.py:109 -msgid "bypass" -msgstr "" - #: ../src/fenrirscreenreader\core\fenrirManager.py:26 msgid "Start Fenrir" msgstr "" @@ -1216,4 +1205,3 @@ msgstr "" #: ../src/fenrirscreenreader\core\vmenuManager.py:234 msgid "Action" msgstr "" -