Possible bug fix for clipboard sync and handling.
This commit is contained in:
1 parent
1638f66a93
commit
c018f5fd29
10 files changed
+225
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
+6
-6
@@ -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 <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\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 ""
|
||||
|
||||
|
||||
Reference in new issue
Block a user