diff --git a/config/settings/settings.conf b/config/settings/settings.conf index 9881c934..f2e4523e 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -271,6 +271,7 @@ clipboard_export_path=/tmp/fenrirClipboard # Keep Fenrir's clipboard history and the X clipboard synchronized. # In fenrir -x, an empty sync_display uses the current DISPLAY. # In console/TTY mode, set sync_display explicitly, for example :0. +# Manual graphical clipboard import/export also use sync_display when set. sync_enabled=False sync_display= sync_interval=0.5 diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py index 01a4ad3c..90d83169 100644 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py +++ b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py @@ -9,9 +9,9 @@ from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import x_clipboard -def write_clipboard(clipboard): +def write_clipboard(clipboard, display): """Write the graphical clipboard without accessing Fenrir state.""" - return x_clipboard.write_text(clipboard, scan_displays=True) + return x_clipboard.write_text(clipboard, display, scan_displays=True) class command: @@ -47,6 +47,9 @@ class command: clipboard = self.env["runtime"][ "MemoryManager" ].get_index_list_element("clipboardHistory") + display = self.env["runtime"]["SettingsManager"].get_setting( + "clipboard", "sync_display" + ) task_id = self.env["runtime"][ "BackgroundTaskManager" @@ -54,6 +57,7 @@ class command: write_clipboard, lambda result: self._handle_result(clipboard, result), clipboard, + display, ) if task_id is not None: self._task_ids.add(task_id) diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py index a30fda0e..3eb9c1a4 100644 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py +++ b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py @@ -8,9 +8,9 @@ from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.utils import x_clipboard -def read_clipboard(): +def read_clipboard(display): """Read the graphical clipboard without accessing Fenrir state.""" - return x_clipboard.read_text(scan_displays=True) + return x_clipboard.read_text(display, scan_displays=True) class command: @@ -32,8 +32,11 @@ class command: return _("imports the graphical clipboard to Fenrir's clipboard") def run(self): + display = self.env["runtime"]["SettingsManager"].get_setting( + "clipboard", "sync_display" + ) task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task( - read_clipboard, self._handle_result + read_clipboard, self._handle_result, display ) if task_id is not None: self._task_ids.add(task_id) @@ -58,6 +61,9 @@ class command: self.env["runtime"]["MemoryManager"].add_value_to_first_index( "clipboardHistory", clipboard_content ) + sync_manager = self.env["runtime"].get("ClipboardSyncManager") + if sync_manager: + sync_manager.mark_imported_from_x(clipboard_content) self.env["runtime"]["OutputManager"].present_text( _("Import to Clipboard"), sound_icon="CopyToClipboard", diff --git a/src/fenrirscreenreader/core/clipboardSyncManager.py b/src/fenrirscreenreader/core/clipboardSyncManager.py index af09b499..5a20fda7 100644 --- a/src/fenrirscreenreader/core/clipboardSyncManager.py +++ b/src/fenrirscreenreader/core/clipboardSyncManager.py @@ -43,8 +43,8 @@ def synchronize_clipboards( result["error"] = ( "ClipboardSyncManager copy failed: " + str(error) ) - result["last_observed_fenrir"] = fenrir_text if written: + result["last_observed_fenrir"] = fenrir_text result["last_written_to_x"] = fenrir_text result["last_observed_x"] = fenrir_text return result @@ -72,6 +72,7 @@ class ClipboardSyncManager: self.interval = 0.5 self.running = False self._task_id = None + self._transfer_revision = 0 self._scheduler_stop = threading.Event() self._sync_event_pending = threading.Event() self._scheduler_thread = None @@ -106,10 +107,19 @@ class ClipboardSyncManager: def mark_written_to_x(self, text): if not isinstance(text, str) or not text: return + self._transfer_revision += 1 self.last_written_to_x = text self.last_observed_fenrir = text self.last_observed_x = text + def mark_imported_from_x(self, text): + if not isinstance(text, str) or not text: + return + self._transfer_revision += 1 + self.last_imported_from_x = text + self.last_observed_fenrir = text + self.last_observed_x = text + def _resolve_startup(self): settings_manager = self.env["runtime"]["SettingsManager"] if not settings_manager.get_setting_as_bool( @@ -173,11 +183,14 @@ class ClipboardSyncManager: return fenrir_text = self._get_fenrir_clipboard_text() + transfer_revision = self._transfer_revision self._task_id = self.env["runtime"][ "BackgroundTaskManager" ].submit_task( synchronize_clipboards, - lambda result: self._handle_poll_result(fenrir_text, result), + lambda result: self._handle_poll_result( + fenrir_text, transfer_revision, result + ), self.display, fenrir_text, self.last_observed_fenrir, @@ -186,7 +199,9 @@ class ClipboardSyncManager: if self._task_id is None: self._sync_event_pending.clear() - def _handle_poll_result(self, fenrir_snapshot, task_result): + def _handle_poll_result( + self, fenrir_snapshot, transfer_revision, task_result + ): self._task_id = None self._sync_event_pending.clear() if not self.running: @@ -201,10 +216,13 @@ class ClipboardSyncManager: result = task_result.get("value") or {} if result.get("error"): self._debug(result["error"]) + if ( + self._get_fenrir_clipboard_text() != fenrir_snapshot + or transfer_revision != self._transfer_revision + ): + return import_text = result.get("import_text") if import_text: - if self._get_fenrir_clipboard_text() != fenrir_snapshot: - return self.env["runtime"]["MemoryManager"].add_value_to_first_index( "clipboardHistory", import_text ) @@ -239,7 +257,7 @@ class ClipboardSyncManager: if self._write_x_clipboard_text(fenrir_text): self.last_written_to_x = fenrir_text self.last_observed_x = fenrir_text - self.last_observed_fenrir = fenrir_text + self.last_observed_fenrir = fenrir_text return if x_changed: diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 3cd26918..81efe395 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.10.05" +version = "2026.10.10" code_name = "testing" diff --git a/src/fenrirscreenreader/utils/x_clipboard.py b/src/fenrirscreenreader/utils/x_clipboard.py index ececbe24..529f385c 100644 --- a/src/fenrirscreenreader/utils/x_clipboard.py +++ b/src/fenrirscreenreader/utils/x_clipboard.py @@ -30,9 +30,9 @@ def _display_candidates(display="", scan_displays=False): if display: return [display] current_display = os.environ.get("DISPLAY", "") - candidates = [] if current_display: - candidates.append(current_display) + return [current_display] + candidates = [] if scan_displays: candidates.extend(f":{index}" for index in range(10)) if not candidates: @@ -98,7 +98,6 @@ def _command_error(command, result): def read_text(display="", scan_displays=False): last_error = None - command_succeeded = False with _display_lock: for candidate in _display_candidates(display, scan_displays): commands = _read_commands(candidate) @@ -108,17 +107,21 @@ def read_text(display="", scan_displays=False): try: result = _run_command(command, candidate) if result.returncode != 0: + # xclip reports a missing text target for both an + # unowned selection and a non-text clipboard. + stderr = (result.stderr or b"").strip() + if command[0] == "xclip" and stderr in ( + b"Error: target STRING not available", + b"Error: target UTF8_STRING not available", + ): + return None last_error = _command_error(command, result) continue - command_succeeded = True text = result.stdout.decode(_ENCODING) - if text: - return text + return text or None except Exception as error: last_error = error if last_error: - if command_succeeded: - return None raise last_error return None diff --git a/tests/unit/test_clipboard_commands_background_result.py b/tests/unit/test_clipboard_commands_background_result.py index 97289ee7..b88ab9d4 100644 --- a/tests/unit/test_clipboard_commands_background_result.py +++ b/tests/unit/test_clipboard_commands_background_result.py @@ -28,12 +28,15 @@ def test_clipboard_import_changes_history_only_after_result_event(): "BackgroundTaskManager": task_manager, "MemoryManager": memory_manager, "OutputManager": Mock(), + "SettingsManager": Mock(get_setting=Mock(return_value=":2")), + "ClipboardSyncManager": Mock(), } } command = import_clipboard_from_x.command() command.initialize(environment) command.run() + assert task_manager.args == (":2",) memory_manager.add_value_to_first_index.assert_not_called() task_manager.callback( @@ -42,6 +45,8 @@ def test_clipboard_import_changes_history_only_after_result_event(): memory_manager.add_value_to_first_index.assert_called_once_with( "clipboardHistory", "copied text" ) + sync_manager = environment["runtime"]["ClipboardSyncManager"] + sync_manager.mark_imported_from_x.assert_called_once_with("copied text") def test_clipboard_export_updates_sync_state_only_after_result_event(): @@ -58,15 +63,29 @@ def test_clipboard_export_updates_sync_state_only_after_result_event(): "DebugManager": Mock(), "MemoryManager": memory_manager, "OutputManager": Mock(), + "SettingsManager": Mock(get_setting=Mock(return_value=":2")), } } command = export_clipboard_to_x.command() command.initialize(environment) command.run() + assert task_manager.args == ("copied text", ":2") sync_manager.mark_written_to_x.assert_not_called() task_manager.callback( {"task_id": 7, "succeeded": True, "value": True} ) sync_manager.mark_written_to_x.assert_called_once_with("copied text") + + +def test_manual_clipboard_workers_use_configured_display(monkeypatch): + read_text = Mock(return_value="text") + write_text = Mock(return_value=True) + monkeypatch.setattr(import_clipboard_from_x.x_clipboard, "read_text", read_text) + monkeypatch.setattr(export_clipboard_to_x.x_clipboard, "write_text", write_text) + + assert import_clipboard_from_x.read_clipboard(":2") == "text" + assert export_clipboard_to_x.write_clipboard("text", ":2") is True + read_text.assert_called_once_with(":2", scan_displays=True) + write_text.assert_called_once_with("text", ":2", scan_displays=True) diff --git a/tests/unit/test_clipboard_sync_manager.py b/tests/unit/test_clipboard_sync_manager.py index 481f2f00..b33da97e 100644 --- a/tests/unit/test_clipboard_sync_manager.py +++ b/tests/unit/test_clipboard_sync_manager.py @@ -355,3 +355,99 @@ def test_sync_worker_returns_changes_without_manager_access(monkeypatch): assert result["import_text"] == "from x" assert result["last_observed_x"] == "from x" + + +def run_sync_task(manager): + task_manager = manager.env['runtime']['BackgroundTaskManager'] + manager.handle_sync_event() + function, callback, *args = task_manager.submit_task.call_args.args + callback({'task_id': 12, 'succeeded': True, 'value': function(*args)}) + + +@pytest.mark.unit +@pytest.mark.parametrize('failure', [False, RuntimeError('temporary failure')]) +def test_runtime_sync_retries_failed_exports(monkeypatch, failure): + manager = ClipboardSyncManager() + manager.env = build_env(fenrir_text='new copy') + manager.env['runtime']['BackgroundTaskManager'] = Mock( + submit_task=Mock(return_value=12), + ) + manager.running = True + manager.display = ':1' + manager.last_observed_fenrir = 'previous copy' + manager.last_observed_x = 'previous copy' + write_text = Mock(side_effect=[failure, True]) + monkeypatch.setattr( + 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text', + Mock(return_value='previous copy'), + ) + monkeypatch.setattr( + 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text', + write_text, + ) + + run_sync_task(manager) + assert manager.last_observed_fenrir == 'previous copy' + run_sync_task(manager) + + assert write_text.call_count == 2 + assert manager.last_observed_fenrir == 'new copy' + assert manager.last_observed_x == 'new copy' + manager.env['runtime']['MemoryManager'].add_value_to_first_index.assert_not_called() + + +@pytest.mark.unit +def test_poll_once_retries_failed_export(monkeypatch): + manager = ClipboardSyncManager() + manager.env = build_env(fenrir_text='new copy') + manager.display = ':1' + write_text = Mock(side_effect=[False, True]) + monkeypatch.setattr( + 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text', + Mock(return_value=None), + ) + monkeypatch.setattr( + 'fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text', + write_text, + ) + + manager.poll_once() + assert manager.last_observed_fenrir is None + manager.poll_once() + assert write_text.call_count == 2 + assert manager.last_written_to_x == 'new copy' + + +@pytest.mark.unit +@pytest.mark.parametrize('manual_action', ['export', 'import']) +def test_runtime_sync_discards_result_superseded_by_manual_transfer( + manual_action, +): + manager = ClipboardSyncManager() + manager.env = build_env(fenrir_text='current copy') + task_manager = Mock(submit_task=Mock(return_value=12)) + manager.env['runtime']['BackgroundTaskManager'] = task_manager + manager.running = True + manager.last_observed_fenrir = 'current copy' + manager.last_observed_x = 'current copy' + + manager.handle_sync_event() + if manual_action == 'export': + manager.mark_written_to_x('current copy') + else: + manager.mark_imported_from_x('current copy') + callback = task_manager.submit_task.call_args.args[1] + callback({ + 'task_id': 12, 'succeeded': True, + 'value': { + 'import_text': 'old x copy', + 'last_imported_from_x': 'old x copy', + 'last_observed_fenrir': 'old x copy', + 'last_observed_x': 'old x copy', + }, + }) + + manager.env['runtime']['MemoryManager'].add_value_to_first_index.assert_not_called() + assert manager.last_observed_x == 'current copy' + assert manager._task_id is None + assert not manager._sync_event_pending.is_set() diff --git a/tests/unit/test_x_clipboard.py b/tests/unit/test_x_clipboard.py index bee2c77b..e271348b 100644 --- a/tests/unit/test_x_clipboard.py +++ b/tests/unit/test_x_clipboard.py @@ -107,3 +107,55 @@ def test_write_text_propagates_clipboard_timeout(monkeypatch): with pytest.raises(subprocess.TimeoutExpired): x_clipboard.write_text("clipboard text", ":3") + + +@pytest.mark.unit +@pytest.mark.parametrize("operation", ["read", "write"]) +def test_current_display_failure_does_not_scan_other_sessions( + monkeypatch, operation, +): + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + run_command = Mock(return_value=subprocess.CompletedProcess( + ["xclip"], 1, stdout=b"", stderr=b"Can't open display: :0", + )) + monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) + + with pytest.raises(RuntimeError): + if operation == "read": + x_clipboard.read_text(scan_displays=True) + else: + x_clipboard.write_text("text", scan_displays=True) + + assert run_command.call_count == 1 + assert run_command.call_args.kwargs["env"]["DISPLAY"] == ":0" + + +@pytest.mark.unit +def test_empty_clipboard_stops_display_scan(monkeypatch): + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + run_command = Mock(return_value=subprocess.CompletedProcess( + ["xclip"], 0, stdout=b"", stderr=b"", + )) + monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) + + assert x_clipboard.read_text(scan_displays=True) is None + assert run_command.call_count == 1 + + +@pytest.mark.unit +@pytest.mark.parametrize("target", ["STRING", "UTF8_STRING"]) +def test_unavailable_text_selection_is_an_empty_clipboard( + monkeypatch, target, +): + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + run_command = Mock(return_value=subprocess.CompletedProcess( + ["xclip"], 1, stdout=b"", + stderr=f"Error: target {target} not available\n".encode(), + )) + monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) + + assert x_clipboard.read_text(scan_displays=True) is None + assert run_command.call_count == 1 diff --git a/tools/fenrir.pot b/tools/fenrir.pot index be8e0641..8948af5d 100644 --- a/tools/fenrir.pot +++ b/tools/fenrir.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: fenrir\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-10-05 12:36-0400\n" +"POT-Creation-Date: 2026-10-10 18:03-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -456,11 +456,11 @@ msgstr "" msgid "Export current fenrir clipboard to X or GUI clipboard" msgstr "" -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:73 +#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:77 msgid "exported to the X session." msgstr "" -#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:84 +#: src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py:88 msgid "failed to export to X clipboard. No available display found." msgstr "" @@ -485,7 +485,7 @@ msgid "File does not exist" msgstr "" #: src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py:52 -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:62 +#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:68 msgid "Import to Clipboard" msgstr "" @@ -493,11 +493,11 @@ msgstr "" msgid "imports the graphical clipboard to Fenrir's clipboard" msgstr "" -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:46 +#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:49 msgid "Clipboard import failed" msgstr "" -#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:53 +#: src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py:56 msgid "No text found in clipboard or no accessible display" msgstr ""