Compare commits
4
Commits
312cb46f22
...
2026.08.06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14227981cb | ||
|
|
ee441c319a | ||
|
|
5cf7a11260 | ||
|
|
7593c8d0b3 |
@@ -113,6 +113,40 @@ class command:
|
||||
|
||||
return True
|
||||
|
||||
def get_curl_classic_percentage(self, text):
|
||||
"""Return progress from a structurally valid curl classic row."""
|
||||
import re
|
||||
|
||||
size_field = r"\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?"
|
||||
time_field = r"(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)"
|
||||
curl_classic_match = re.match(
|
||||
rf"^\s*(\d+)\s+{size_field}\s+"
|
||||
rf"(\d+)\s+{size_field}\s+"
|
||||
rf"(\d+)\s+{size_field}\s+"
|
||||
rf"{size_field}\s+{size_field}\s+"
|
||||
rf"{time_field}\s+{time_field}\s+{time_field}\s+"
|
||||
rf"{size_field}(?=\s|$)",
|
||||
text,
|
||||
)
|
||||
if not curl_classic_match:
|
||||
return None
|
||||
|
||||
total_percentage = int(curl_classic_match.group(1))
|
||||
received_percentage = int(curl_classic_match.group(2))
|
||||
uploaded_percentage = int(curl_classic_match.group(3))
|
||||
if not all(
|
||||
0 <= percentage <= 100
|
||||
for percentage in (
|
||||
total_percentage,
|
||||
received_percentage,
|
||||
uploaded_percentage,
|
||||
)
|
||||
):
|
||||
return None
|
||||
if total_percentage != received_percentage:
|
||||
return None
|
||||
return float(total_percentage)
|
||||
|
||||
def is_explicit_progress_delta(self, text):
|
||||
"""Allow long single-line deltas that still look like progress output."""
|
||||
import re
|
||||
@@ -120,6 +154,9 @@ class command:
|
||||
if "\n" in text or self.contains_url(text):
|
||||
return False
|
||||
|
||||
if self.get_curl_classic_percentage(text) is not None:
|
||||
return True
|
||||
|
||||
has_percentage = re.search(r"(^|\s)\d+(?:\.\d+)?\s*%", text)
|
||||
if not has_percentage:
|
||||
return self.has_progress_status_fields(text)
|
||||
@@ -229,34 +266,21 @@ class command:
|
||||
return
|
||||
|
||||
# Pattern 1a2: Curl classic progress format (percentage without % symbol)
|
||||
# Extract percentage from curl's classic format
|
||||
curl_classic_match = re.search(
|
||||
r"^\s*(\d+)\s+\d+[kMGT]?\s+(\d+)\s+\d+[kMGT]?\s+\d+\s+\d+\s+\d+[kMGT]?\s+\d+\s+\d+:\d+:\d+\s+\d+:\d+:\d+\s+\d+:\d+:\d+\s+\d+[kMGT]?\s*$", text
|
||||
)
|
||||
if curl_classic_match:
|
||||
# Use the first percentage (total progress)
|
||||
percentage = float(curl_classic_match.group(1))
|
||||
if 0 <= percentage <= 100:
|
||||
percentage = self.get_curl_classic_percentage(text)
|
||||
if percentage is not None:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"found curl classic percentage: " + str(percentage),
|
||||
debug.DebugLevel.INFO,
|
||||
)
|
||||
if percentage != self.env["commandBuffer"]["lastProgressValue"]:
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"found curl classic percentage: " + str(percentage),
|
||||
"Playing tone for curl: " + str(percentage),
|
||||
debug.DebugLevel.INFO,
|
||||
)
|
||||
if (
|
||||
percentage
|
||||
!= self.env["commandBuffer"]["lastProgressValue"]
|
||||
):
|
||||
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||
"Playing tone for curl: " + str(percentage),
|
||||
debug.DebugLevel.INFO,
|
||||
)
|
||||
self.play_progress_tone(percentage)
|
||||
self.env["commandBuffer"][
|
||||
"lastProgressValue"
|
||||
] = percentage
|
||||
self.env["commandBuffer"][
|
||||
"lastProgressTime"
|
||||
] = current_time
|
||||
return
|
||||
self.play_progress_tone(percentage)
|
||||
self.env["commandBuffer"]["lastProgressValue"] = percentage
|
||||
self.env["commandBuffer"]["lastProgressTime"] = current_time
|
||||
return
|
||||
|
||||
# Pattern 1b: Time/token activity (not percentage-based, so use single
|
||||
# beep)
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2026.07.29"
|
||||
version = "2026.08.06"
|
||||
code_name = "master"
|
||||
|
||||
@@ -161,6 +161,23 @@ class driver(remoteDriver):
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
def _get_peer_uid(self, client_sock):
|
||||
so_peercred = getattr(socket, "SO_PEERCRED", 17)
|
||||
try:
|
||||
creds = client_sock.getsockopt(
|
||||
socket.SOL_SOCKET, so_peercred, struct.calcsize("3i")
|
||||
)
|
||||
_pid, uid, _gid = struct.unpack("3i", creds)
|
||||
return uid
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
def _get_socket_owner_uid(self, socket_file):
|
||||
try:
|
||||
return os.stat(socket_file).st_uid
|
||||
except OSError:
|
||||
return -1
|
||||
|
||||
def _get_parent_pid(self, pid):
|
||||
try:
|
||||
with open(f"/proc/{pid}/stat", "r", encoding="utf-8") as proc_file:
|
||||
@@ -206,13 +223,20 @@ class driver(remoteDriver):
|
||||
socket_files.append(socket_file)
|
||||
return socket_files
|
||||
|
||||
def _find_available_private_socket(self, preferred_socket=""):
|
||||
def _find_available_private_socket(
|
||||
self, preferred_socket="", owner_uid=None
|
||||
):
|
||||
socket_files = self._get_registered_private_sockets()
|
||||
if preferred_socket and preferred_socket in socket_files:
|
||||
socket_files.remove(preferred_socket)
|
||||
socket_files.insert(0, preferred_socket)
|
||||
|
||||
for socket_file in socket_files:
|
||||
if (
|
||||
owner_uid is not None
|
||||
and self._get_socket_owner_uid(socket_file) != owner_uid
|
||||
):
|
||||
continue
|
||||
if self._is_own_socket_file(socket_file):
|
||||
return socket_file
|
||||
if self._is_socket_active(socket_file):
|
||||
@@ -253,14 +277,27 @@ class driver(remoteDriver):
|
||||
def _route_main_socket_command(self, data, client_sock, socket_file):
|
||||
if socket_file != MAIN_SOCKET_FILE:
|
||||
return False
|
||||
|
||||
owner_uid = None
|
||||
if not self._has_own_private_socket():
|
||||
return False
|
||||
peer_uid = self._get_peer_uid(client_sock)
|
||||
main_socket_uid = self._get_socket_owner_uid(MAIN_SOCKET_FILE)
|
||||
if peer_uid < 0 or peer_uid == main_socket_uid:
|
||||
return False
|
||||
owner_uid = peer_uid
|
||||
|
||||
peer_pid = self._get_peer_pid(client_sock)
|
||||
ancestor_socket = ""
|
||||
if peer_pid > 1:
|
||||
ancestor_socket = self._find_ancestor_private_socket(peer_pid)
|
||||
target_socket = self._find_available_private_socket(ancestor_socket)
|
||||
if owner_uid is None:
|
||||
target_socket = self._find_available_private_socket(
|
||||
ancestor_socket
|
||||
)
|
||||
else:
|
||||
target_socket = self._find_available_private_socket(
|
||||
ancestor_socket, owner_uid=owner_uid
|
||||
)
|
||||
if not target_socket:
|
||||
return False
|
||||
|
||||
|
||||
@@ -290,3 +290,82 @@ def test_explicit_progress_delta_ignores_percentage_in_bracketed_prose():
|
||||
assert not command.is_explicit_progress_delta(
|
||||
"Release notes [issue #749] are 41% complete"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_progress_detector_beeps_for_curl_classic_repaint_with_trailing_text():
|
||||
progress_module = _load_progress_module()
|
||||
command = progress_module.command()
|
||||
sample = (
|
||||
"53 537.8M 53 285.8M 0 0 9.48M 0 00:56 00:30 00:26 9.94M "
|
||||
"Executing cabextract -q -d /home/Username/.local/wine64/dosdevices/"
|
||||
"c:/windows/syswow64 -F dx8vb.dll /home/Username/.cache/winetricks/"
|
||||
"dx8vb/DX81NTger.exe"
|
||||
)
|
||||
command.env = {
|
||||
"commandBuffer": {
|
||||
"progress_monitoring": True,
|
||||
"lastProgressValue": -1,
|
||||
"lastProgressTime": 0,
|
||||
},
|
||||
"runtime": {
|
||||
"DebugManager": Mock(write_debug_out=Mock()),
|
||||
"ScreenManager": Mock(is_screen_change=Mock(return_value=False)),
|
||||
"CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)),
|
||||
},
|
||||
"screen": {
|
||||
"new_delta": sample,
|
||||
"new_delta_is_typing": False,
|
||||
"new_content_text": sample,
|
||||
"old_cursor": {"x": 0, "y": 0},
|
||||
"new_cursor": {"x": 0, "y": 0},
|
||||
},
|
||||
}
|
||||
command.play_progress_tone = Mock()
|
||||
|
||||
assert len(sample) > 200
|
||||
|
||||
command.run()
|
||||
|
||||
command.play_progress_tone.assert_called_once_with(53.0)
|
||||
assert command.env["commandBuffer"]["lastProgressValue"] == 53.0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_progress_detector_ignores_curl_like_fields_with_mismatched_percentages():
|
||||
progress_module = _load_progress_module()
|
||||
command = progress_module.command()
|
||||
sample = (
|
||||
"53 537.8M 52 285.8M 0 0 9.48M 0 00:56 00:30 00:26 9.94M "
|
||||
"ordinary numeric report with enough trailing text to cross the long "
|
||||
"delta filter without representing a coherent download progress row "
|
||||
"or providing trustworthy percentage information"
|
||||
)
|
||||
command.env = {
|
||||
"commandBuffer": {
|
||||
"progress_monitoring": True,
|
||||
"lastProgressValue": -1,
|
||||
"lastProgressTime": 0,
|
||||
},
|
||||
"runtime": {
|
||||
"DebugManager": Mock(write_debug_out=Mock()),
|
||||
"ScreenManager": Mock(is_screen_change=Mock(return_value=False)),
|
||||
"CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)),
|
||||
},
|
||||
"screen": {
|
||||
"new_delta": sample,
|
||||
"new_delta_is_typing": False,
|
||||
"new_content_text": sample,
|
||||
"old_cursor": {"x": 0, "y": 0},
|
||||
"new_cursor": {"x": 0, "y": 0},
|
||||
},
|
||||
}
|
||||
command.play_activity_beep = Mock()
|
||||
command.play_progress_tone = Mock()
|
||||
|
||||
assert len(sample) > 200
|
||||
|
||||
command.run()
|
||||
|
||||
command.play_activity_beep.assert_not_called()
|
||||
command.play_progress_tone.assert_not_called()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import socket
|
||||
import struct
|
||||
from unittest.mock import Mock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
@@ -75,6 +77,19 @@ class FakeClientSocket:
|
||||
self.sent += data
|
||||
|
||||
|
||||
def test_get_peer_uid_from_unix_socket():
|
||||
driver = unixDriver.driver()
|
||||
client_sock = Mock()
|
||||
client_sock.getsockopt.return_value = struct.pack("3i", 4321, 1000, 100)
|
||||
|
||||
assert driver._get_peer_uid(client_sock) == 1000
|
||||
client_sock.getsockopt.assert_called_once_with(
|
||||
socket.SOL_SOCKET,
|
||||
socket.SO_PEERCRED,
|
||||
struct.calcsize("3i"),
|
||||
)
|
||||
|
||||
|
||||
def test_main_socket_routes_to_ancestor_private_socket(mock_environment):
|
||||
driver = unixDriver.driver()
|
||||
driver.env = mock_environment
|
||||
@@ -148,6 +163,10 @@ def test_main_socket_handles_command_locally_without_available_target(
|
||||
event_queue = Mock()
|
||||
|
||||
with patch.object(driver, "_get_peer_pid", return_value=1234), patch.object(
|
||||
driver, "_get_peer_uid", return_value=1000
|
||||
), patch.object(
|
||||
driver, "_get_socket_owner_uid", return_value=65534
|
||||
), patch.object(
|
||||
driver,
|
||||
"_find_available_private_socket",
|
||||
return_value="",
|
||||
@@ -171,7 +190,11 @@ def test_vcsa_main_socket_owner_handles_command_locally(mock_environment):
|
||||
client_sock = FakeClientSocket(b"command say root")
|
||||
event_queue = Mock()
|
||||
|
||||
with patch.object(driver, "_find_available_private_socket") as find_available:
|
||||
with patch.object(driver, "_get_peer_uid", return_value=1000), patch.object(
|
||||
driver, "_get_socket_owner_uid", return_value=1000
|
||||
), patch.object(
|
||||
driver, "_find_available_private_socket"
|
||||
) as find_available:
|
||||
driver._handle_client(
|
||||
client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE
|
||||
)
|
||||
@@ -185,6 +208,64 @@ def test_vcsa_main_socket_owner_handles_command_locally(mock_environment):
|
||||
)
|
||||
|
||||
|
||||
def test_vcsa_main_socket_routes_to_same_user_private_socket(
|
||||
mock_environment,
|
||||
):
|
||||
driver = unixDriver.driver()
|
||||
driver.env = mock_environment
|
||||
driver.bound_sockets = [(Mock(), unixDriver.MAIN_SOCKET_FILE)]
|
||||
client_sock = FakeClientSocket(b"command say x session")
|
||||
event_queue = Mock()
|
||||
|
||||
with patch.object(driver, "_get_peer_pid", return_value=1234), patch.object(
|
||||
driver, "_get_peer_uid", return_value=1000
|
||||
), patch.object(
|
||||
driver, "_get_socket_owner_uid", return_value=65534
|
||||
), patch.object(
|
||||
driver, "_find_ancestor_private_socket", return_value=""
|
||||
), patch.object(
|
||||
driver,
|
||||
"_find_available_private_socket",
|
||||
return_value="/tmp/fenrirscreenreader-222.sock",
|
||||
) as find_available, patch.object(
|
||||
driver, "_forward_remote_to_socket", return_value=True
|
||||
) as forward:
|
||||
driver._handle_client(
|
||||
client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE
|
||||
)
|
||||
|
||||
find_available.assert_called_once_with("", owner_uid=1000)
|
||||
forward.assert_called_once_with(
|
||||
"command say x session", "/tmp/fenrirscreenreader-222.sock"
|
||||
)
|
||||
event_queue.put.assert_not_called()
|
||||
|
||||
|
||||
def test_vcsa_main_socket_stays_local_without_peer_credentials(
|
||||
mock_environment,
|
||||
):
|
||||
driver = unixDriver.driver()
|
||||
driver.env = mock_environment
|
||||
driver.bound_sockets = [(Mock(), unixDriver.MAIN_SOCKET_FILE)]
|
||||
client_sock = FakeClientSocket(b"command say unknown")
|
||||
event_queue = Mock()
|
||||
|
||||
with patch.object(driver, "_get_peer_uid", return_value=-1), patch.object(
|
||||
driver, "_find_available_private_socket"
|
||||
) as find_available:
|
||||
driver._handle_client(
|
||||
client_sock, event_queue, unixDriver.MAIN_SOCKET_FILE
|
||||
)
|
||||
|
||||
find_available.assert_not_called()
|
||||
event_queue.put.assert_called_once_with(
|
||||
{
|
||||
"Type": unixDriver.FenrirEventType.remote_incomming,
|
||||
"data": "command say unknown",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_private_socket_handles_command_locally(mock_environment):
|
||||
driver = unixDriver.driver()
|
||||
driver.env = mock_environment
|
||||
@@ -281,3 +362,33 @@ def test_find_available_private_socket_skips_main_socket(
|
||||
driver._find_available_private_socket()
|
||||
== "/tmp/fenrirscreenreader-111.sock"
|
||||
)
|
||||
|
||||
|
||||
def test_find_available_private_socket_filters_by_owner(mock_environment):
|
||||
driver = unixDriver.driver()
|
||||
driver.env = mock_environment
|
||||
|
||||
with patch(
|
||||
"fenrirscreenreader.remoteDriver.unixDriver.remoteInstanceRegistry.list_instances",
|
||||
return_value=[
|
||||
{
|
||||
"pid": 111,
|
||||
"socket_files": ["/tmp/fenrirscreenreader-111.sock"],
|
||||
},
|
||||
{
|
||||
"pid": 222,
|
||||
"socket_files": ["/tmp/fenrirscreenreader-222.sock"],
|
||||
},
|
||||
],
|
||||
), patch.object(
|
||||
driver,
|
||||
"_get_socket_owner_uid",
|
||||
side_effect=lambda socket_file: {
|
||||
"/tmp/fenrirscreenreader-111.sock": 1001,
|
||||
"/tmp/fenrirscreenreader-222.sock": 1000,
|
||||
}[socket_file],
|
||||
), patch.object(driver, "_is_socket_active", return_value=True):
|
||||
assert (
|
||||
driver._find_available_private_socket(owner_uid=1000)
|
||||
== "/tmp/fenrirscreenreader-222.sock"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user