diff --git a/lib/solaar/ui/pair_window.py b/lib/solaar/ui/pair_window.py index c89d7470..1b4eeb20 100644 --- a/lib/solaar/ui/pair_window.py +++ b/lib/solaar/ui/pair_window.py @@ -16,7 +16,10 @@ ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import logging +import math +import time +from dataclasses import dataclass from enum import Enum from gi.repository import GLib @@ -32,11 +35,40 @@ logger = logging.getLogger(__name__) _PAIRING_TIMEOUT = 30 # seconds _STATUS_CHECK = 500 # milliseconds +_PROGRESS_GRACE = 3 # seconds to wait for a first progress notification before giving up on them + +# steps the user has to perform to enter a passkey +_STEP_LEFT = "left" +_STEP_RIGHT = "right" +_STEP_BOTH = "both" +_STEP_ENTER = "enter" + +# widget state kept between the periodic checks, stashed on the widgets themselves +_PASSKEY_PAGE = "_solaar_passkey_page" +_PAIRED_ADDRESS = "_solaar_paired_address" +_ON_RETRY = "_solaar_on_retry" +_STEPS = "_solaar_steps" +_PROGRESS = "_solaar_progress" + +# step strip metrics, in pixels +_CELL_WIDTH = 28 +_CELL_HEIGHT = 38 +_CELL_GAP = 8 +_CELL_RADIUS = 9 +_NUMBER_GAP = 6 +_NUMBER_HEIGHT = 12 +_ROW_GAP = 8 +_SEPARATOR_GAP = 10 +_STRIP_MARGIN = 8 +_STEPS_PER_ROW = 5 +_FINAL_STEP_SCALE = 1.25 class GtkSignal(Enum): CANCEL = "cancel" CLOSE = "close" + CLICKED = "clicked" + DRAW = "draw" def create(receiver): @@ -124,7 +156,10 @@ def _check_lock_state(assistant, receiver, count): return True elif receiver.pairing.discovering and receiver.pairing.device_address and receiver.pairing.device_name: add = receiver.pairing.device_address + if getattr(assistant, _PAIRED_ADDRESS, None) == add: + return True # pairing was already requested for this device ent = 20 if receiver.pairing.device_kind == hidpp10_constants.DEVICE_KIND.keyboard else 10 + setattr(assistant, _PAIRED_ADDRESS, add) if receiver.pair_device(address=add, authentication=receiver.pairing.device_authentication, entropy=ent): return True else: @@ -160,22 +195,270 @@ def _finish(assistant, receiver): receiver.pairing.error = None +def _passkey_steps(passkey, authentication): + """Returns the ordered steps needed to enter the passkey on the device. + + Mice and touchpads take the passkey as ten button presses followed by a + simultaneous press of both buttons, keyboards as its digits followed by the + enter key. Returns None when the passkey cannot be interpreted, so that + callers running inside a periodic check never have to handle an exception. + """ + if passkey is None: + return None + if authentication & 0x01: # keyboards spell the passcode out + return [character for character in str(passkey)] + [_STEP_ENTER] + try: + bits = f"{int(passkey):010b}" + except (TypeError, ValueError): + return None + # keep this mapping as it is: the receiver never reports which button was + # pressed, so a changed polarity cannot be validated from inside Solaar + return [_STEP_RIGHT if bit == "1" else _STEP_LEFT for bit in bits] + [_STEP_BOTH] + + +def _passkey_description(steps, passkey, authentication): + """Renders the steps as the sentence used for the tooltip and for screen readers.""" + if authentication & 0x01: + return _("Type %(passcode)s and then press the enter key.") % {"passcode": passkey} + passcode = ", ".join(_("right") if step == _STEP_RIGHT else _("left") for step in steps[:-1]) + return _("Press %(code)s\nand then press left and right buttons simultaneously.") % {"code": passcode} + + +@dataclass +class _StepCell: + """Placement of a single step inside the step strip.""" + + x: float + y: float + width: float + height: float + step: str + index: int + + +def _step_strip_cells(steps): + """Lays the steps out in rows, with the final step alone below a separator.""" + leading = steps[:-1] # the final step gets a band of its own, below the separator + rows = [leading[index : index + _STEPS_PER_ROW] for index in range(0, len(leading), _STEPS_PER_ROW)] + columns = max((len(row) for row in rows), default=1) + width = 2 * _STRIP_MARGIN + columns * _CELL_WIDTH + (columns - 1) * _CELL_GAP + row_height = _CELL_HEIGHT + _NUMBER_GAP + _NUMBER_HEIGHT + cells = [] + index = 0 + y = float(_STRIP_MARGIN) + for row in rows: + row_width = len(row) * _CELL_WIDTH + (len(row) - 1) * _CELL_GAP + x = (width - row_width) / 2 + for step in row: + cells.append(_StepCell(x, y, _CELL_WIDTH, _CELL_HEIGHT, step, index)) + x += _CELL_WIDTH + _CELL_GAP + index += 1 + y += row_height + _ROW_GAP + separator_y = y - _ROW_GAP + _SEPARATOR_GAP + final_width = _CELL_WIDTH * _FINAL_STEP_SCALE + final_height = _CELL_HEIGHT * _FINAL_STEP_SCALE + final_y = separator_y + _SEPARATOR_GAP + cells.append(_StepCell((width - final_width) / 2, final_y, final_width, final_height, steps[-1], index)) + height = final_y + final_height + _NUMBER_GAP + _NUMBER_HEIGHT + _STRIP_MARGIN + return cells, separator_y, width, height + + +def _strip_accent_color(style): + """Takes the highlight colour from the theme, so the strip stays legible everywhere.""" + found, color = style.lookup_color("theme_selected_bg_color") + if found: + return color + color = style.get_color(Gtk.StateFlags.SELECTED) + if color is not None: + return color + return style.get_color(Gtk.StateFlags.NORMAL) + + +def _rounded_rectangle(cr, x, y, width, height, radius): + radius = min(radius, width / 2, height / 2) + cr.new_sub_path() + cr.arc(x + width - radius, y + radius, radius, -0.5 * math.pi, 0.0) + cr.arc(x + width - radius, y + height - radius, radius, 0.0, 0.5 * math.pi) + cr.arc(x + radius, y + height - radius, radius, 0.5 * math.pi, math.pi) + cr.arc(x + radius, y + radius, radius, math.pi, 1.5 * math.pi) + cr.close_path() + + +def _draw_mouse_cell(cr, cell, accent, outline, fill_alpha, outline_alpha): + """Draws a mouse seen from above, with the buttons this step needs filled in.""" + radius = _CELL_RADIUS * cell.width / _CELL_WIDTH + split_y = cell.y + cell.height * 0.42 + split_x = cell.x + cell.width / 2 + cr.save() + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.clip() + cr.set_source_rgba(accent.red, accent.green, accent.blue, fill_alpha) + if cell.step in (_STEP_LEFT, _STEP_BOTH): + cr.rectangle(cell.x, cell.y, cell.width / 2, split_y - cell.y) + cr.fill() + if cell.step in (_STEP_RIGHT, _STEP_BOTH): + cr.rectangle(split_x, cell.y, cell.width / 2, split_y - cell.y) + cr.fill() + cr.restore() + wheel_width = cell.width * 0.20 + wheel_height = cell.height * 0.28 + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.stroke() + cr.move_to(cell.x, split_y) + cr.line_to(cell.x + cell.width, split_y) + cr.stroke() + cr.move_to(split_x, cell.y) + cr.line_to(split_x, split_y - wheel_height / 2) + cr.stroke() + _rounded_rectangle(cr, split_x - wheel_width / 2, split_y - wheel_height / 2, wheel_width, wheel_height, wheel_width / 2) + cr.stroke() + + +def _draw_key_cell(cr, cell, accent, outline, fill_alpha, outline_alpha): + """Draws a key cap carrying either a digit of the passcode or an enter arrow.""" + radius = _CELL_RADIUS * cell.width / _CELL_WIDTH / 2 + cr.set_source_rgba(accent.red, accent.green, accent.blue, fill_alpha) + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.fill() + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.stroke() + if cell.step == _STEP_ENTER: + # an arrow pointing down and then left, the usual shape of an enter key + top = cell.y + cell.height * 0.32 + bottom = cell.y + cell.height * 0.62 + left = cell.x + cell.width * 0.28 + right = cell.x + cell.width * 0.72 + cr.move_to(right, top) + cr.line_to(right, bottom) + cr.line_to(left, bottom) + cr.stroke() + head = cell.width * 0.14 + cr.move_to(left + head, bottom - head) + cr.line_to(left, bottom) + cr.line_to(left + head, bottom + head) + cr.stroke() + else: + cr.set_font_size(cell.height * 0.46) + extents = cr.text_extents(cell.step) + cr.move_to( + cell.x + (cell.width - extents.width) / 2 - extents.x_bearing, + cell.y + (cell.height - extents.height) / 2 - extents.y_bearing, + ) + cr.show_text(cell.step) + + +def _draw_check_mark(cr, cell, outline, outline_alpha): + """Marks a step the receiver has already accepted.""" + size = cell.width * 0.22 + x = cell.x + cell.width * 0.72 + y = cell.y + cell.height * 0.78 + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + cr.move_to(x - size, y) + cr.line_to(x - size / 3, y + size * 0.7) + cr.line_to(x + size, y - size * 0.8) + cr.stroke() + + +def _draw_step_strip(area, cr): + """Paints which button every step needs, and how far entry has got. + + The two are kept apart on purpose: the filled quadrant always says which + button to press, and only the emphasis says where the user is. + """ + steps = getattr(area, _STEPS, None) + if not steps: + return False + progress = getattr(area, _PROGRESS, None) + # without progress notifications no step is singled out, and the strip is + # simply the whole sequence, numbered and always readable + done = 0 if progress is None else min(progress, len(steps)) + current = None if progress is None else min(progress, len(steps) - 1) + cells, separator_y, width, _height = _step_strip_cells(steps) + style = area.get_style_context() + accent = _strip_accent_color(style) + outline = style.get_color(Gtk.StateFlags.NORMAL) + is_mouse = steps[-1] == _STEP_BOTH + cr.save() + cr.translate(max(0, (area.get_allocated_width() - width) / 2), 0) + cr.set_line_width(1.0) + cr.set_line_join(0) # cairo.LINE_JOIN_MITER, spelled out to avoid importing cairo + cr.select_font_face("sans-serif") + cr.set_source_rgba(outline.red, outline.green, outline.blue, 0.25) + cr.move_to(_STRIP_MARGIN, separator_y) + cr.line_to(width - _STRIP_MARGIN, separator_y) + cr.stroke() + for cell in cells: + if cell.index < done: + fill_alpha, outline_alpha = 0.45, 0.35 + elif cell.index == current: + fill_alpha, outline_alpha = 1.0, 1.0 + else: + fill_alpha, outline_alpha = 0.22, 0.55 + if cell.index == current: + cr.set_source_rgba(accent.red, accent.green, accent.blue, 1.0) + cr.set_line_width(2.0) + _rounded_rectangle(cr, cell.x - 3, cell.y - 3, cell.width + 6, cell.height + 6, _CELL_RADIUS) + cr.stroke() + cr.set_line_width(1.0) + if is_mouse: + _draw_mouse_cell(cr, cell, accent, outline, fill_alpha, outline_alpha) + else: + _draw_key_cell(cr, cell, accent, outline, fill_alpha, outline_alpha) + if cell.index < done: + _draw_check_mark(cr, cell, outline, 0.8) + number = str(cell.index + 1) + cr.set_font_size(_NUMBER_HEIGHT) + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + extents = cr.text_extents(number) + cr.move_to( + cell.x + (cell.width - extents.width) / 2 - extents.x_bearing, + cell.y + cell.height + _NUMBER_GAP - extents.y_bearing, + ) + cr.show_text(number) + cr.restore() + return False + + +def _create_step_strip(steps, description): + area = Gtk.DrawingArea() + setattr(area, _STEPS, steps) + setattr(area, _PROGRESS, 0) + _cells, _separator_y, width, height = _step_strip_cells(steps) + area.set_size_request(width, int(math.ceil(height))) + area.connect(GtkSignal.DRAW.value, _draw_step_strip) + # a drawing area is invisible to screen readers, so the sequence has to + # stay available as text as well + area.set_tooltip_text(description) + area.get_accessible().set_description(description) + return area + + def _show_passcode(assistant, receiver, passkey): + """Shows the passkey page, creating it on the first check and refreshing it afterwards.""" + page = getattr(assistant, _PASSKEY_PAGE, None) + if page is None: + page = _create_passcode_page(assistant, receiver, passkey) + setattr(assistant, _PASSKEY_PAGE, page) + assistant.set_page_complete(page, True) + assistant.next_page() + _update_passcode_page(page, receiver) + + +def _create_passcode_page(assistant, receiver, passkey): logger.debug("%s show passkey: %s", receiver, passkey) name = receiver.pairing.device_name authentication = receiver.pairing.device_authentication intro_text = _("%(receiver_name)s: pair new device") % {"receiver_name": receiver.name} page_text = _("Enter passcode on %(name)s.") % {"name": name} - page_text += "\n" - if authentication & 0x01: - page_text += _("Type %(passcode)s and then press the enter key.") % { - "passcode": receiver.pairing.device_passkey, - } - else: - passcode = ", ".join( - [_("right") if bit == "1" else _("left") for bit in f"{int(receiver.pairing.device_passkey):010b}"] - ) - page_text += _("Press %(code)s\nand then press left and right buttons simultaneously.") % {"code": passcode} + steps = _passkey_steps(passkey, authentication) + if steps is None: # unreadable passkey, so fall back to the plain instructions + page_text += "\n" + page_text += _("Type %(passcode)s and then press the enter key.") % {"passcode": passkey} + elif authentication & 0x01: # for keyboards the passcode is meant to be read + page_text += "\n" + page_text += _passkey_description(steps, passkey, authentication) page = _create_page( assistant, Gtk.AssistantPageType.PROGRESS, @@ -183,8 +466,60 @@ def _show_passcode(assistant, receiver, passkey): "preferences-desktop-peripherals", page_text, ) - assistant.set_page_complete(page, True) - assistant.next_page() + if steps is None: + return page + strip = _create_step_strip(steps, _passkey_description(steps, passkey, authentication)) + page.pack_start(strip, False, False, 0) + status = Gtk.Label(label=_("Waiting for the first click…")) + status.set_line_wrap(True) + page.pack_start(status, False, False, 0) + if steps[-1] == _STEP_BOTH: + # the receiver reports that a button was pressed but never which one, + # so say so rather than let a counted click look like a checked one + honesty = Gtk.Label(label=_("Solaar cannot tell which button was pressed — only that the receiver accepted a click.")) + honesty.set_line_wrap(True) + honesty.get_style_context().add_class("dim-label") + page.pack_start(honesty, False, False, 0) + reminder = Gtk.Label(label=_("Keep the device switched on and within range until pairing finishes.")) + reminder.set_line_wrap(True) + reminder.get_style_context().add_class("dim-label") + page.pack_start(reminder, False, False, 0) + page.show_all() + page.strip = strip + page.status = status + page.started = time.monotonic() + return page + + +def _update_passcode_page(page, receiver): + """Refreshes the page in place, so that the periodic check never appends another one.""" + strip = getattr(page, "strip", None) + if strip is None: + return + steps = getattr(strip, _STEPS) + total = len(steps) - 1 # the receiver counts presses, not the final combined one + entered = receiver.pairing.passkey_entered + if receiver.pairing.passkey_complete: + progress = len(steps) + text = _("The receiver is checking the sequence…") + elif entered > 0: + progress = entered + if entered >= total and steps[-1] == _STEP_BOTH: + text = _("Finally, press the left and right buttons at the same time.") + else: + text = _("Clicks registered by the receiver: %(done)d of %(total)d") % { + "done": min(entered, total), + "total": total, + } + elif time.monotonic() - page.started < _PROGRESS_GRACE: + progress = 0 + text = _("Waiting for the first click…") + else: # this receiver never reports progress, so highlight nothing at all + progress = None + text = _("This receiver does not report entry progress. Follow the numbered steps in order.") + page.status.set_label(text) + setattr(strip, _PROGRESS, progress) + strip.queue_draw() def _create_assistant(receiver, ok, finish, title, text): diff --git a/tests/solaar/ui/test_pair_window.py b/tests/solaar/ui/test_pair_window.py index f31f4801..1e2cefa0 100644 --- a/tests/solaar/ui/test_pair_window.py +++ b/tests/solaar/ui/test_pair_window.py @@ -243,3 +243,160 @@ def test_create_failure_page(error, mocker): pair_window._pairing_failed(Assistant(True), Receiver("nano", "nano"), error) assert spy_create.call_count == 1 + + +@pytest.mark.parametrize( + "passkey, authentication, expected", + [ + ("50", 0x02, ["left"] * 4 + ["right"] * 2 + ["left"] * 2 + ["right"] + ["left"] + ["both"]), + (50, 0x02, ["left"] * 4 + ["right"] * 2 + ["left"] * 2 + ["right"] + ["left"] + ["both"]), + ("0", 0x02, ["left"] * 10 + ["both"]), + ("1023", 0x02, ["right"] * 10 + ["both"]), + ("000918", 0x01, ["0", "0", "0", "9", "1", "8", "enter"]), + ("abcdef", 0x02, None), + ("", 0x02, None), + (None, 0x02, None), + ], +) +def test_passkey_steps(passkey, authentication, expected): + assert pair_window._passkey_steps(passkey, authentication) == expected + + +def test_passkey_steps_bit_polarity(): + """The receiver never reports which button was pressed, so this mapping cannot be + validated from inside Solaar and must not be flipped without hardware evidence.""" + steps = pair_window._passkey_steps(f"{0b1010101010:d}", 0x02) + + assert steps == ["right", "left"] * 5 + ["both"] + + +@pytest.mark.parametrize("steps, expected_cells", [(["left"] * 10 + ["both"], 11), (["1", "2", "enter"], 3)]) +def test_step_strip_cells(steps, expected_cells): + cells, separator_y, width, height = pair_window._step_strip_cells(steps) + + assert [cell.step for cell in cells] == steps + assert [cell.index for cell in cells] == list(range(expected_cells)) + assert cells[-1].width > cells[0].width # the final step is drawn larger + assert cells[-1].y > separator_y # and below the separator + assert width > 0 and height > cells[-1].y + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_appends_a_single_page(): + """The periodic check keeps running while the passkey is shown, so the page has to + be created once and refreshed afterwards instead of being appended again.""" + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02), + ) + assistant = Assistant(True) + + first = pair_window._check_lock_state(assistant, r, 0) + second = pair_window._check_lock_state(assistant, r, 0) + + assert first is True + assert second is True + assert len(assistant.pages) == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_updates_status_in_place(): + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02), + ) + assistant = Assistant(True) + + pair_window._check_lock_state(assistant, r, 0) + page = getattr(assistant, pair_window._PASSKEY_PAGE) + waiting = page.status.get_label() + + r.pairing.passkey_entered = 4 + pair_window._check_lock_state(assistant, r, 0) + counted = page.status.get_label() + + r.pairing.passkey_complete = True + pair_window._check_lock_state(assistant, r, 0) + checking = page.status.get_label() + + assert waiting != counted != checking + assert "4" in counted + assert getattr(page.strip, pair_window._PROGRESS) == len(getattr(page.strip, pair_window._STEPS)) + assert len(assistant.pages) == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_clamps_overshoot(): + """The receiver owns the verdict, so more presses than steps must not raise.""" + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02, passkey_entered=99), + ) + assistant = Assistant(True) + + assert pair_window._check_lock_state(assistant, r, 0) is True + + page = getattr(assistant, pair_window._PASSKEY_PAGE) + assert getattr(page.strip, pair_window._PROGRESS) == 99 # clamped when drawn, not when stored + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_survives_unreadable_passkey(): + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="oops", device_authentication=0x02), + ) + assistant = Assistant(True) + + assert pair_window._check_lock_state(assistant, r, 0) is True + assert pair_window._check_lock_state(assistant, r, 0) is True + assert len(assistant.pages) == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_pair_device_issued_once(mocker): + r = Receiver("discovered", "bolt", True, receiver.Pairing(discovering=True, device_address=2, device_name=5)) + spy_pair_device = mocker.spy(r, "pair_device") + assistant = Assistant(True) + + first = pair_window._check_lock_state(assistant, r, 2) + second = pair_window._check_lock_state(assistant, r, 2) + + assert first is True + assert second is True + assert spy_pair_device.call_count == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +@pytest.mark.parametrize("progress", [None, 0, 5, 10, 11, 15]) +@pytest.mark.parametrize("passkey, authentication", [("50", 0x02), ("000918", 0x01)]) +def test_draw_step_strip(passkey, authentication, progress): + """Draws against an image surface, so cairo misuse and a bad highlight clamp are + caught without needing a display.""" + import cairo + + steps = pair_window._passkey_steps(passkey, authentication) + strip = pair_window._create_step_strip(steps, "sequence") + _cells, _separator_y, width, height = pair_window._step_strip_cells(steps) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, int(width), int(height)) + setattr(strip, pair_window._PROGRESS, progress) + + assert pair_window._draw_step_strip(strip, cairo.Context(surface)) is False + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_draw_step_strip_without_steps(): + import cairo + + strip = Gtk.DrawingArea() + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) + + assert pair_window._draw_step_strip(strip, cairo.Context(surface)) is False