ui: show the Bolt passkey as a guided click sequence

The passkey page rendered the whole sequence as one run-on sentence
("Press right, right, left, ... and then press left and right buttons
simultaneously."), which is hard to follow and impossible to keep a place in.

Draw it instead as eleven numbered steps: two rows of five top-down mouse
glyphs plus the final both-buttons step, set apart below a separator and drawn
larger so it cannot be mistaken for another single click. Every cell always
shows which button it needs, so the sequence can be read ahead; the emphasis is
a separate channel that only says how far the receiver has got.

Keyboards reuse the same strip with digit keys and an enter key as the final
step, and keep showing the passcode as text since it is meant to be read.

The strip is drawn with cairo in the theme's own colours, so it needs no new
assets and stays legible in any theme. A drawing area is invisible to screen
readers, so the sequence stays available as the existing sentence through the
tooltip and the accessible description.

The bit to button mapping is deliberately unchanged. The receiver never reports
which button was pressed, so a different polarity cannot be validated from
inside Solaar, and the page says as much rather than implying it checked.

Without progress notifications the strip degrades to the plain numbered
sequence with nothing highlighted, so it never depends on them.

Also fixes two defects in the same path:

- the passkey page was appended again on every 500 ms check, stacking dozens of
  duplicate pages during a normal entry and scrolling the pairing instructions
  out of view. It is now created once and refreshed in place.
- pair_device was re-issued every 500 ms for as long as a discovered device was
  pending. It is now issued once per discovered address.
This commit is contained in:
Daniel Banariba 2026-08-13 16:48:48 -06:00
parent bd9f11b12e
commit 68946a7f1d
2 changed files with 504 additions and 12 deletions

View File

@ -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):

View File

@ -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