diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 2ec8c570..0d8c3cdd 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -114,6 +114,8 @@ class Controller(Singleton): image_entropy_final_image: Image = None address_explorer_data: dict = None + + sign_message_data: dict = None # TODO: end refactor section # Destination placeholder for when we need to jump out to a side flow but intend to @@ -123,6 +125,7 @@ class Controller(Singleton): FLOW__VERIFY_MULTISIG_ADDR = "multisig_addr" FLOW__VERIFY_SINGLESIG_ADDR = "singlesig_addr" FLOW__ADDRESS_EXPLORER = "address_explorer" + FLOW__SIGN_MESSAGE = "sign_message" resume_main_flow: str = None back_stack: BackStack = None diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 7aafcbcb..d4274a11 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -1,6 +1,8 @@ import math import os import pathlib +import re +from time import time from dataclasses import dataclass from decimal import Decimal @@ -275,7 +277,7 @@ class TextArea(BaseComponent): height: int = None # None = special case: autosize to min height screen_x: int = 0 screen_y: int = 0 - min_text_x: int = None + min_text_x: int = 0 # Text can not start at x any less than this background_color: str = GUIConstants.BACKGROUND_COLOR font_name: str = GUIConstants.BODY_FONT_NAME font_size: int = GUIConstants.BODY_FONT_SIZE @@ -292,129 +294,74 @@ class TextArea(BaseComponent): if not self.width: self.width = self.canvas_width + + if self.screen_x + self.width > self.canvas_width: + self.width = self.canvas_width - self.screen_x - if self.font_size < 18 and (not self.supersampling_factor or self.supersampling_factor == 1): - self.supersampling_factor = 2 - - self.font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size)) - self.supersampled_width = self.supersampling_factor * self.width - if self.height is None: - self.supersampled_height = None - else: - self.supersampled_height = self.supersampling_factor * self.height self.line_spacing = GUIConstants.BODY_LINE_SPACING # We have to figure out if and where to make line breaks in the text so that it # fits in its bounding rect (plus accounting for edge padding) using its given # font. - # Measure from left baseline ("ls") - # TODO: getbbox() seems to ignore "\n" so isn't properly factored into height - # calcs and yields incorrect full_text_width. For now must specify self.height to - # render properly. Centering will be wrong. - (left, top, full_text_width, bottom) = self.font.getbbox(self.text, anchor="ls") - self.text_font_height = -1 * top - self.bbox_height = self.text_font_height + bottom + # Do initial calcs without worrying about supersampling. + self.text_lines = reflow_text_for_width( + text=self.text, + width=self.width - 2*self.edge_padding, + font_name=self.font_name, + font_size=self.font_size, + allow_text_overflow=self.allow_text_overflow, + ) - # Stores each line of text and its rendering starting x-coord - self.text_lines = [] - self.text_width = 0 - def _add_text_line(text, width): - if self.is_text_centered: - text_x = int((self.supersampled_width - width) / 2) - else: - text_x = self.supersampling_factor * self.edge_padding - if self.min_text_x is not None and text_x < self.min_text_x: - text_x = self.min_text_x - self.text_lines.append({"text": text, "text_x": text_x}) + # Calculate the actual font height from the "baseline" anchor ("_s") + font = Fonts.get_font(self.font_name, self.font_size) - if width > self.text_width: - self.text_width = width + # Note: from the baseline anchor, `top` is a negative number while `bottom` + # conveys the pixels used below the baseline (e.g. in "py"). + (left, top, right, bottom) = font.getbbox(self.text + "A", anchor="ls") # For consistency, ensure we have a full-height character above baseline + self.text_height_above_baseline = -1 * top + self.text_height_below_baseline = bottom - if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # The whole text fits on one line - _add_text_line(self.text, full_text_width) + # Initialize the text rendering relative to the baseline + self.text_y = self.text_height_above_baseline - if self.height is None: - self.text_y = self.text_font_height - self.supersampled_height = self.bbox_height - self.height = int(self.bbox_height / self.supersampling_factor) - else: - # Vertical starting point calc is easy in this case - self.text_y = self.text_font_height + int((self.supersampled_height - self.text_font_height)/2) - - self.text_width = full_text_width + # Other components, like IconTextLine will need to know how wide the actual + # rendered text will be, separate from the TextArea's defined overall `width`. + self.text_width = max(line["text_width"] for line in self.text_lines) + + # Calculate the actual height + if len(self.text_lines) == 1: + total_text_height = self.text_height_above_baseline + self.text_height_below_baseline + else: + # Multiply for the number of lines plus the spacer + total_text_height = self.text_height_above_baseline * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1) + + if re.findall(f"[gjpqy]", self.text_lines[-1]["text"]): + # Last line has at least one char that dips below baseline + total_text_height += self.text_height_below_baseline + + if self.height is None: + # Autoscale height to text lines + self.height = total_text_height else: - # Have to calc how to break text into multiple lines - def _binary_len_search(min_index, max_index): - # Try the middle of the range - index = math.ceil((max_index + min_index) / 2) - if index == 0: - # Handle edge case where there's only one word in the last line - index = 1 - - tw, th = self.font.getsize(" ".join(words[0:index])) - - if tw > self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # Candidate line is still too long. Restrict search range down. - if min_index + 1 == index: - if index == 1: - # It's just one long, unbreakable word. There's no good - # solution here. Just accept it as is and let it render off - # the edges. - return (index, tw) - else: - # There's still room to back down the min_index in the next - # round. - index -= 1 - return _binary_len_search(min_index=min_index, max_index=index) - elif index == max_index: - # We have converged - return (index, tw) + if total_text_height > self.height: + if not self.allow_text_overflow: + raise TextDoesNotFitException(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}") else: - # Candidate line is possibly shorter than necessary. - return _binary_len_search(min_index=index, max_index=max_index) - - if len(self.text.split()) == 1 and not self.allow_text_overflow: - # No whitespace chars to split on! - raise TextDoesNotFitException("Text cannot fit in target rect with this font/size") - - for line in self.text.split("\n"): - words = line.split() - if not words: - # It's a blank line - _add_text_line("", 0) - else: - while words: - (index, tw) = _binary_len_search(0, len(words)) - _add_text_line(" ".join(words[0:index]), tw) - words = words[index:] - - # TODO: Don't render blank lines as full height - total_text_height = self.bbox_height * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1) - if self.height is None: - # Autoscale height to text lines - self.supersampled_height = total_text_height - self.height = int(self.supersampled_height / self.supersampling_factor) - self.text_y = self.text_font_height + # Just let it render off the edge, but preserve the top portion + pass else: - self.supersampled_height = self.height * self.supersampling_factor - if total_text_height > self.height * self.supersampling_factor + 2*GUIConstants.COMPONENT_PADDING * self.supersampling_factor: - if not self.allow_text_overflow: - raise TextDoesNotFitException("Text cannot fit in target rect with this font/size") - else: - # Just let it render off the edge, but preserve the top portion - self.text_y = self.text_font_height - + # Vertically center the text's starting point + if len(self.text_lines) == 1: + # For consistency when used in TopNav and elsewhere, ignore the + # text's pixels below the baseline. + # In other words: "Home" and "Something" will get the same text_y, + # even though the "g" dips below baseline. + self.text_y += int(self.height - (total_text_height - self.text_height_below_baseline))/2 else: - # Vertically center the multiline text's starting point - self.text_y = self.text_font_height + int((self.supersampled_height - total_text_height)/2) - - # Make sure the width/height that get referenced outside this obj are - # specified and restored to their normal scaling factor. - self.width = int(self.text_width / self.supersampling_factor) - self.text_font_height = int(self.text_font_height / self.supersampling_factor) + # Vertically center for the full height. + self.text_y += int(self.height - (total_text_height))/2 def render(self): @@ -422,17 +369,51 @@ class TextArea(BaseComponent): # with bicubic resampling. # TODO: Store resulting super-sampled image as a member var in __post_init__ and # just re-paste it here. - img = Image.new("RGBA", (self.supersampled_width, self.supersampled_height), self.background_color) + if self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1): + self.supersampling_factor = 2 + + resample_padding = 10 if self.supersampling_factor > 1.0 else 0 + img = Image.new( + "RGB", + ( + self.width * self.supersampling_factor, + (self.height + 2*resample_padding) * self.supersampling_factor + ), + self.background_color + ) draw = ImageDraw.Draw(img) - cur_y = self.text_y + + # draw.line((0, resample_padding * self.supersampling_factor, self.width * self.supersampling_factor, resample_padding * self.supersampling_factor), fill="blue", width=1) + # draw.line((0, (resample_padding + self.height) * self.supersampling_factor, self.width * self.supersampling_factor, (resample_padding + self.height) * self.supersampling_factor), fill="red", width=1) + cur_y = (self.text_y + resample_padding) * self.supersampling_factor + + supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size)) + + if self.is_text_centered: + anchor = "ms" + else: + anchor = "ls" + + # Position where we'll render each line of text + text_x = self.edge_padding for line in self.text_lines: - draw.text((line["text_x"], cur_y), line["text"], fill=self.font_color, font=self.font, anchor="ls") - cur_y += self.bbox_height + self.line_spacing + if self.is_text_centered: + # We'll render with a centered anchor so we just need the midpoint + text_x = int(self.width/2) + if text_x - int(line["text_width"]/2) < self.min_text_x: + # The left edge of the centered text will protrude too far; nudge it right + text_x = self.min_text_x + int(line["text_width"]/2) - resized = img.resize((int(self.supersampled_width / self.supersampling_factor), self.height), Image.LANCZOS) - resized = resized.filter(ImageFilter.SHARPEN) - self.canvas.paste(resized, (self.screen_x, self.screen_y)) + draw.text((text_x * self.supersampling_factor, cur_y), line["text"], fill=self.font_color, font=supersampled_font, anchor=anchor) + cur_y += (self.text_height_above_baseline + self.line_spacing) * self.supersampling_factor + + # Crop off the top_padding and resize the result down to onscreen size + if self.supersampling_factor > 1.0: + resized = img.resize((self.width, self.height + 2*resample_padding), Image.LANCZOS) + sharpened = resized.filter(ImageFilter.SHARPEN) + img = sharpened.crop((0, resample_padding, self.width, self.height + 2*resample_padding)) + self.canvas.paste(img, (self.screen_x, self.screen_y)) @@ -529,8 +510,8 @@ class IconTextLine(BaseComponent): value_textarea_screen_y = self.screen_y if self.label_text: - label_padding_y = GUIConstants.COMPONENT_PADDING - value_textarea_screen_y += self.label_textarea.text_font_height + label_padding_y + label_padding_y = int(GUIConstants.COMPONENT_PADDING / 2) + value_textarea_screen_y += self.label_textarea.height + label_padding_y self.value_textarea = TextArea( image_draw=self.image_draw, @@ -550,11 +531,11 @@ class IconTextLine(BaseComponent): if self.label_text: if not self.height: self.height = self.label_textarea.height + label_padding_y + self.value_textarea.height - max_textarea_width = max(self.label_textarea.width, self.value_textarea.width) + max_textarea_width = max(self.label_textarea.text_width, self.value_textarea.text_width) else: if not self.height: self.height = self.value_textarea.height - max_textarea_width = self.value_textarea.width + max_textarea_width = self.value_textarea.text_width # Now we can update the icon's y position if self.icon_name: @@ -563,17 +544,12 @@ class IconTextLine(BaseComponent): self.height = max(self.icon.height, self.height) - if self.is_text_centered: - if self.icon_name: - total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer - self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2) - if self.label_text: - self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer - self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer - # else: - # if self.label_text: - # self.label_textarea.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - max_textarea_width + (max_textarea_width - self.label_textarea.width))/2) - # self.value_textarea.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - max_textarea_width + (max_textarea_width - self.value_textarea.width))/2) + if self.is_text_centered and self.icon_name: + total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer + self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2) + if self.label_text: + self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer + self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer self.width = self.canvas_width @@ -587,6 +563,7 @@ class IconTextLine(BaseComponent): self.icon.render() + @dataclass class ToastOverlay(BaseComponent): icon_name: str = None @@ -912,7 +889,7 @@ class BtcAmount(BaseComponent): # font = smaller_digit_font (left, top, text_width, bottom) = font.getbbox(btc_text, anchor="ls") - text_height = -1 * top + text_height = -1 * top + bottom text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2) draw.text( @@ -939,7 +916,7 @@ class BtcAmount(BaseComponent): if self.total_sats > 1e9: font = smaller_digit_font (left, top, text_width, bottom) = font.getbbox(sats_text, anchor="ls") - text_height = -1 * top + text_height = -1 * top + bottom text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2) draw.text( xy=( @@ -976,7 +953,7 @@ class BtcAmount(BaseComponent): cur_x = btc_icon.width + int(GUIConstants.COMPONENT_PADDING/4) (left, top, text_width, bottom) = smaller_digit_font.getbbox(btc_text, anchor="ls") - text_height = -1 * top + text_height = -1 * top + bottom text_y = self.paste_image.height - int((self.paste_image.height - text_height)/2) draw.text( @@ -1042,7 +1019,7 @@ class BtcAmount(BaseComponent): ) unit_textarea.render() - final_x = cur_x + GUIConstants.COMPONENT_PADDING + unit_textarea.width + final_x = cur_x + GUIConstants.COMPONENT_PADDING + unit_text_width self.paste_image = self.paste_image.crop((0, 0, final_x, self.paste_image.height)) self.paste_coords = ( @@ -1316,10 +1293,10 @@ class TopNav(BaseComponent): height=GUIConstants.TOP_NAV_BUTTON_SIZE, ) - min_x = 0 + min_text_x = 0 if self.show_back_button: # Don't let the title intrude on the BACK button - min_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING + min_text_x = self.left_button.screen_x + self.left_button.width + GUIConstants.COMPONENT_PADDING if self.icon_name: self.title = IconTextLine( @@ -1338,7 +1315,8 @@ class TopNav(BaseComponent): self.title = TextArea( screen_x=0, screen_y=0, - min_text_x=min_x, + min_text_x=min_text_x, + width=self.width, height=self.height, text=self.text, is_text_centered=True, @@ -1418,3 +1396,133 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int] return points + + +def reflow_text_for_width(text: str, + width: int, + font_name=GUIConstants.BODY_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + allow_text_overflow: bool=False) -> list[dict]: + """ + Reflows text to fit within `width` by breaking long lines up. + + Returns a List with each reflowed line of text as its own entry. + + Note: It is up to the calling code to handle any height considerations for the + resulting lines of text. + """ + # We have to figure out if and where to make line breaks in the text so that it + # fits in its bounding rect (plus accounting for edge padding) using its given + # font. + start = time() + font = Fonts.get_font(font_name=font_name, size=font_size) + # Measure from left baseline ("ls") + (left, top, full_text_width, bottom) = font.getbbox(text, anchor="ls") + + # Stores each line of text and its rendering starting x-coord + text_lines = [] + def _add_text_line(text, text_width): + text_lines.append({"text": text, "text_width": text_width}) + + if "\n" not in text and full_text_width < width: + # The whole text fits on one line + _add_text_line(text, full_text_width) + + else: + # Have to calc how to break text into multiple lines + def _binary_len_search(min_index, max_index): + # Try the middle of the range + index = math.ceil((max_index + min_index) / 2) + if index == 0: + # Handle edge case where there's only one word in the last line + index = 1 + + # Measure rendered width from "left" anchor (anchor="l_") + (left, top, right, bottom) = font.getbbox(" ".join(words[0:index]), anchor="ls") + line_width = right - left + + if line_width >= width: + # Candidate line is still too long. Restrict search range down. + if min_index + 1 == index: + if index == 1: + # It's just one long, unbreakable word. There's no good + # solution here. Just accept it as is and let it render off + # the edges. + return (index, line_width) + else: + # There's still room to back down the min_index in the next + # round. + index -= 1 + return _binary_len_search(min_index=min_index, max_index=index) + elif index == max_index: + # We have converged + return (index, line_width) + else: + # Candidate line is possibly shorter than necessary. + return _binary_len_search(min_index=index, max_index=max_index) + + if len(text.split()) == 1 and not allow_text_overflow: + # No whitespace chars to split on! + raise TextDoesNotFitException("Text cannot fit in target rect with this font+size") + + # Now we're ready to go line-by-line into our line break binary search! + for line in text.split("\n"): + words = line.split() + if not words: + # It's a blank line + _add_text_line("", 0) + else: + while words: + (index, tw) = _binary_len_search(0, len(words)) + _add_text_line(" ".join(words[0:index]), tw) + words = words[index:] + + return text_lines + + + +def reflow_text_into_pages(text: str, + width: int, + height: int, + font_name=GUIConstants.BODY_FONT_NAME, + font_size=GUIConstants.BODY_FONT_SIZE, + line_spacer: int = GUIConstants.BODY_LINE_SPACING, + allow_text_overflow: bool=False) -> list[str]: + """ + Invokes `reflow_text_for_width` above to convert long text into width-limited + individual text lines and then calculates how many lines will fit on a "page" and + groups the output accordingly. + + Returns a list of strings where each string is a page's worth of line-breaked text. + """ + reflowed_lines_dicts = reflow_text_for_width(text=text, + width=width, + font_name=font_name, + font_size=font_size, + allow_text_overflow=allow_text_overflow) + + lines = [] + for line_dict in reflowed_lines_dicts: + lines.append(line_dict["text"]) + print(f"""{line_dict["text_width"]:3}: {line_dict["text"]}""") + + font = Fonts.get_font(font_name=font_name, size=font_size) + # Measure the font's height above baseline and how for below it certain characters + # (e.g. lowercase "g") can render. + (left, top, right, bottom) = font.getbbox("Agjpqy", anchor="ls") + font_height_above_baseline = -1 * top + font_height_below_baseline = bottom + + # I'm sure there's a smarter way to do this... + lines_per_page = 0 + for i in range(1, height): + if height > font_height_above_baseline * i + line_spacer * (i-1) + font_height_below_baseline: + lines_per_page = i + else: + break + + pages = [] + for i in range(0, len(lines), lines_per_page): + pages.append("\n".join(lines[i:i+lines_per_page])) + + return pages diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 8aaaef4a..9534a774 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -84,125 +84,7 @@ class Renderer(ConfigurableSingleton): self.disp.ShowImage(crop, 0, 0) - # TODO: Remove all references - def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None): - image_copy = image.copy().convert("RGBA") - - text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) - text_overlay_draw = ImageDraw.Draw(text_overlay) - if not font: - font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE) - tw, th = text_overlay_draw.textsize(text, font=font) - if text_background: - text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background) - text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font) - - self.show_image(image_copy, alpha_overlay=text_overlay) - - - # TODO: Should probably move this to screens.py - def draw_modal(self, lines = [], title = "", bottom = "") -> None: - self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0) - - if len(title) > 0: - tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - if len(bottom) > 0: - tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18)) - self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18)) - - if len(lines) == 1: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - elif len(lines) == 2: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 3: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 4: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - self.show_image() - - return - - - # TODO: Should probably move this to templates.py - def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None: - self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom) - return - - - # TODO: Should probably move this to templates.py - def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None: - self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0) - - if len(title) > 0: - tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - if len(bottom) > 0: - tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18)) - self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18)) - - if len(lines) == 1: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - elif len(lines) == 2: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 3: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26)) - self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - elif len(lines) == 4: - tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22)) - self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22)) - - a_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(a_txt)[0] - self.draw.text((a_x_offset, 39 + 0), a_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25)) - - b_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(b_txt)[0] - self.draw.text((b_x_offset , 39 + 60), b_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25)) - - c_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(c_txt)[0] - self.draw.text((c_x_offset , 39 + 120), c_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25)) - - self.show_image() - - return - def display_blank_screen(self): self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0) self.show_image() - - - - diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 306ba968..d0005fb9 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -847,7 +847,7 @@ class LargeIconStatusScreen(ButtonListScreen): self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2) self.components.append(self.status_icon) - next_y = self.status_icon.screen_y + self.status_icon.height + 4 + next_y = self.status_icon.screen_y + self.status_icon.height + int(GUIConstants.COMPONENT_PADDING/2) if self.status_headline: self.warning_headline_textarea = TextArea( text=self.status_headline, @@ -863,6 +863,7 @@ class LargeIconStatusScreen(ButtonListScreen): height=self.buttons[0].screen_y - next_y, text=self.text, width=self.canvas_width, + edge_padding=GUIConstants.EDGE_PADDING, # Don't render all the way up to the far left/right edges screen_y=next_y, allow_text_overflow=self.allow_text_overflow, )) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index ac2230b8..e1b88db0 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -11,8 +11,7 @@ from seedsigner.models.threads import BaseThread, ThreadsafeCounter from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton, - IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, - calc_text_centering) + IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages) from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants @@ -528,6 +527,22 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen): +@dataclass +class SeedBIP85SelectChildIndexScreen(KeyboardScreen): + def __post_init__(self): + self.title = "BIP-85 Index" + self.user_input = "" + + # Specify the keys in the keyboard + self.rows = 3 + self.cols = 5 + self.keys_charset = "0123456789" + self.show_save_button = True + + super().__post_init__() + + + @dataclass class SeedWordsBackupTestPromptScreen(ButtonListScreen): def __post_init__(self): @@ -1326,7 +1341,7 @@ class AddressVerificationSigTypeScreen(ButtonListScreen): @dataclass -class SeedSingleSigAddressVerificationSelectSeedScreen(ButtonListScreen): +class SeedSelectSeedScreen(ButtonListScreen): text: str = "" def __post_init__(self): @@ -1482,16 +1497,75 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): allow_text_overflow=True, )) + + @dataclass -class SeedBIP85SelectChildIndexScreen(KeyboardScreen): +class SeedSignMessageConfirmMessageScreen(ButtonListScreen): + page_num: int = None + def __post_init__(self): - self.title = "BIP-85 Index" - self.user_input = "" + from seedsigner.controller import Controller + renderer = Renderer.get_instance() + start_y = GUIConstants.TOP_NAV_HEIGHT + GUIConstants.COMPONENT_PADDING + end_y = renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING + message_height = end_y - start_y - # Specify the keys in the keyboard - self.rows = 3 - self.cols = 5 - self.keys_charset = "0123456789" - self.show_save_button = True + self.sign_message_data = Controller.get_instance().sign_message_data + if "paged_message" not in self.sign_message_data: + paged = reflow_text_into_pages( + text=self.sign_message_data["message"], + width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, + height=message_height, + ) + self.sign_message_data["paged_message"] = paged + if self.page_num >= len(self.sign_message_data["paged_message"]): + raise Exception("Bug in paged_message calculation") + + if len(self.sign_message_data["paged_message"]) == 1: + self.title = "Review Message" + else: + self.title = f"""Message (pt {self.page_num + 1}/{len(self.sign_message_data["paged_message"])})""" + self.is_bottom_list = True + self.is_button_text_centered = True + self.button_data = ["Next"] super().__post_init__() + + message_display = TextArea( + text=self.sign_message_data["paged_message"][self.page_num], + is_text_centered=False, + allow_text_overflow=True, + screen_y=start_y, + ) + self.components.append(message_display) + + + +@dataclass +class SeedSignMessageConfirmAddressScreen(ButtonListScreen): + derivation_path: str = None + address: str = None + + def __post_init__(self): + self.title = "Confirm Address" + self.is_bottom_list = True + self.is_button_text_centered = True + self.button_data = ["Sign Message"] + super().__post_init__() + + derivation_path_display = IconTextLine( + icon_name=SeedSignerIconConstants.DERIVATION, + icon_color=GUIConstants.INFO_COLOR, + label_text="derivation path", + value_text=self.derivation_path, + is_text_centered=True, + screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + ) + self.components.append(derivation_path_display) + + address_display = FormattedAddress( + address=self.address, + max_lines=3, + screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING, + ) + self.components.append(address_display) diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index b2188e0d..d420baab 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -166,7 +166,7 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen): self.components.append(TextArea( text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.", - screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, + screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2), )) @@ -252,7 +252,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): edge_padding=0, screen_x=bit_display_x, screen_y=screen_y, - height=bit_font_height, is_text_centered=False, )) @@ -266,7 +265,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): edge_padding=0, screen_x=checksum_x, screen_y=screen_y, - height=bit_font_height, is_text_centered=False, )) @@ -291,7 +289,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen): edge_padding=0, screen_x=bit_display_x, screen_y=screen_y, - height=bit_font_height, is_text_centered=False, )) diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index bdd95b7f..c33153e8 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -1,9 +1,14 @@ import embit -from embit import bip32 + +from binascii import b2a_base64 +from hashlib import sha256 + +from embit import bip32, compact, ec from embit.bip32 import HDKey from embit.descriptor import Descriptor from embit.networks import NETWORKS -from embit.networks import NETWORKS +from embit.util import secp256k1 + from seedsigner.models.settings_definition import SettingsConstants @@ -93,3 +98,85 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool elif descriptor.is_taproot: # TODO: Not yet implemented! raise Exception("Taproot verification not yet implemented!") + + + +def get_embit_network_name(settings_name): + """ Convert SeedSigner SettingsConstants for `network` to embit's NETWORK key """ + lookup = { + SettingsConstants.MAINNET: "main", + SettingsConstants.TESTNET: "test", + SettingsConstants.REGTEST: "regtest", + } + return lookup.get(settings_name) + + + +def parse_derivation_path(derivation_path: str) -> dict: + """ + Parses a derivation path into its related SettingsConstants equivalents. + + Primarily only supports single sig derivation paths. + + May return None for fields it cannot parse. + """ + # Support either m/44'/... or m/44h/... style + derivation_path = derivation_path.replace("'", "h") + + sections = derivation_path.split("/") + + if sections[1] == "48h": + # So far this helper is only meant for single sig message signing + raise Exception("Not implemented") + + lookups = { + "script_types": { + "84h": SettingsConstants.NATIVE_SEGWIT, + "49h": SettingsConstants.NESTED_SEGWIT, + "86h": SettingsConstants.TAPROOT, + }, + "networks": { + "0h": SettingsConstants.MAINNET, + "1h": [SettingsConstants.TESTNET, SettingsConstants.REGTEST], + } + } + + details = dict() + details["script_type"] = lookups["script_types"].get(sections[1]) + if not details["script_type"]: + details["script_type"] = SettingsConstants.CUSTOM_DERIVATION + details["network"] = lookups["networks"].get(sections[2]) + details["is_change"] = sections[-2] == "1" + details["index"] = int(sections[-1]) + + details["clean_match"] = True + for k, v in details.items(): + if v is None: + # At least one field couldn't be parsed + details["clean_match"] = False + break + + return details + + + +def sign_message(seed_bytes: bytes, derivation: str, msg: bytes, compressed: bool = True, embit_network: str = "main") -> bytes: + """ + from: https://github.com/cryptoadvance/specter-diy/blob/b58a819ef09b2bca880a82c7e122618944355118/src/apps/signmessage/signmessage.py + """ + """Sign message with private key""" + msghash = sha256( + sha256( + b"\x18Bitcoin Signed Message:\n" + compact.to_bytes(len(msg)) + msg + ).digest() + ).digest() + + root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS[embit_network]["xprv"]) + prv = root.derive(derivation).key + sig = secp256k1.ecdsa_sign_recoverable(msghash, prv._secret) + flag = sig[64] + sig = ec.Signature(sig[:64]) + c = 4 if compressed else 0 + flag = bytes([27 + flag + c]) + ser = flag + secp256k1.ecdsa_signature_serialize_compact(sig._sig) + return b2a_base64(ser).strip().decode() diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index f5e30302..a020c312 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -83,6 +83,9 @@ class DecodeQR: elif self.qr_type == QRType.BITCOIN_ADDRESS: self.decoder = BitcoinAddressQrDecoder() # Single Segment bitcoin address + elif self.qr_type == QRType.SIGN_MESSAGE: + self.decoder = SignMessageQrDecoder() # Single Segment sign message request + elif self.qr_type == QRType.WALLET__SPECTER: self.decoder = SpecterWalletQrDecoder() # Specter Desktop Wallet Export decoder @@ -131,6 +134,9 @@ class DecodeQR: return rt + # TODO: Refactor all of these specific `get_` to just something generic like + # `get_data` and let each QRDecoder class return whatever it needs to as a + # str, tuple, dict, etc? def get_psbt(self): if self.complete: data = self.get_data_psbt() @@ -187,6 +193,15 @@ class DecodeQR: return self.decoder.get_address_type() + def get_qr_data(self) -> dict: + """ + This provides a single access point for external code to retrieve the QR data, + regardless of which decoder is actually instantiated. + """ + # TODO: Implement this approach across all decoders + return self.decoder.get_qr_data() + + def get_wallet_descriptor(self): if self.is_wallet_descriptor: if self.qr_type in [QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: @@ -267,6 +282,11 @@ class DecodeQR: return self.qr_type == QRType.BITCOIN_ADDRESS + @property + def is_sign_message(self): + return self.qr_type == QRType.SIGN_MESSAGE + + @property def is_wallet_descriptor(self): check = self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__CONFIGFILE, QRType.WALLET__GENERIC, QRType.OUTPUT__UR] @@ -357,6 +377,10 @@ class DecodeQR: elif DecodeQR.is_bitcoin_address(s): return QRType.BITCOIN_ADDRESS + # message signing + elif DecodeQR.is_sign_message(s): + return QRType.SIGN_MESSAGE + # config data if s.startswith("settings::"): return QRType.SETTINGS @@ -476,7 +500,13 @@ class DecodeQR: return True else: return False - + + + @staticmethod + def is_sign_message(s): + return type(s) == str and s.startswith("signmessage") + + @staticmethod def multisig_setup_file_to_descriptor(text) -> str: # sample text file, parse the contents and create descriptor @@ -586,6 +616,10 @@ class BaseQrDecoder: def add(self, segment, qr_type): raise Exception("Not implemented in child class") + + def get_qr_data(self) -> dict: + # TODO: standardize this approach across all decoders (example: SignMessageQrDecoder) + raise Exception("get_qr_data must be implemented in decoder child class") @@ -854,6 +888,40 @@ class SettingsQrDecoder(BaseSingleFrameQrDecoder): +class SignMessageQrDecoder(BaseSingleFrameQrDecoder): + def __init__(self): + super().__init__() + self.message = None + self.derivation_path = None + + + def add(self, segment, qr_type=QRType.SIGN_MESSAGE): + """ + Expected QR data format: + + signmessage {derivation_path} ascii:{message} + """ + parts = segment.split() + self.derivation_path = parts[1].replace("h", "'") + fmt = parts[2].split(":")[0] + self.message = segment.split(f"{fmt}:")[1] + + # TODO: support formats other than ascii? + if fmt != "ascii": + print(f"Sign message: Unsupported format: {fmt}") + return DecodeQRStatus.INVALID + + self.complete = True + self.collected_segments = 1 + + return DecodeQRStatus.COMPLETE + + + def get_qr_data(self) -> dict: + return dict(derivation_path=self.derivation_path, message=self.message) + + + class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder): """ Decodes single frame representing a bitcoin address @@ -1019,8 +1087,7 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder): -class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder): - +class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder): def add(self, segment, qr_type=QRType.WALLET__CONFIGFILE): descriptor = DecodeQR.multisig_setup_file_to_descriptor(segment) return super().add(descriptor,qr_type=QRType.WALLET__CONFIGFILE) diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 0e6493d5..6efa715d 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -39,6 +39,7 @@ class EncodeQR: qr_density: str = SettingsConstants.DENSITY__MEDIUM wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH bitcoin_address: str = None + signed_message: str = None def __post_init__(self): self.qr = QR() @@ -98,9 +99,13 @@ class EncodeQR: self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed_phrase, wordlist_language_code=self.wordlist_language_code) + # Misc formats elif self.qr_type == QRType.BITCOIN_ADDRESS: self.encoder = BitcoinAddressEncoder(address=self.bitcoin_address) + elif self.qr_type == QRType.SIGN_MESSAGE: + self.encoder = SignedMessageEncoder(signed_message=self.signed_message) + else: raise Exception('QR Type not supported') @@ -155,6 +160,17 @@ class BaseQrEncoder: +class BaseStaticQrEncoder(BaseQrEncoder): + def seq_len(self): + return 1 + + + @property + def is_complete(self): + return True + + + class BasePsbtQrEncoder(BaseQrEncoder): def __init__(self, psbt: PSBT): self.psbt = psbt @@ -264,7 +280,7 @@ class SpecterPsbtQrEncoder(BasePsbtQrEncoder): -class SeedQrEncoder(BaseQrEncoder): +class SeedQrEncoder(BaseStaticQrEncoder): def __init__(self, seed_phrase: List[str], wordlist_language_code: str): super().__init__() self.seed_phrase = seed_phrase @@ -274,10 +290,6 @@ class SeedQrEncoder(BaseQrEncoder): raise Exception('Wordlist Required') - def seq_len(self): - return 1 - - def next_part(self): data = "" # Output as Numeric data format @@ -287,11 +299,6 @@ class SeedQrEncoder(BaseQrEncoder): return data - @property - def is_complete(self): - return True - - class CompactSeedQrEncoder(SeedQrEncoder): def next_part(self): @@ -323,23 +330,29 @@ class CompactSeedQrEncoder(SeedQrEncoder): -class BitcoinAddressEncoder(BaseQrEncoder): +class BitcoinAddressEncoder(BaseStaticQrEncoder): def __init__(self, address: str): super().__init__() self.address = address - def seq_len(self): - return 1 - - def next_part(self): return self.address - @property - def is_complete(self): - return True + +class SignedMessageEncoder(BaseStaticQrEncoder): + """ + Assumes that a signed message will fit in a single-frame QR + """ + def __init__(self, signed_message: str): + super().__init__() + self.signed_message = signed_message + + + def next_part(self): + return self.signed_message + class XpubQrEncoder(BaseQrEncoder): diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index 7ef0ca67..6ec1f5ec 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -21,6 +21,8 @@ class QRType: BITCOIN_ADDRESS = "bitcoin_address" + SIGN_MESSAGE = "sign_message" + WALLET__SPECTER = "wallet__specter" WALLET__UR = "wallet__ur" WALLET__CONFIGFILE = "wallet__configfile" diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 7d9941f3..5c9e4b99 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -158,6 +158,7 @@ class SettingsConstants: SETTING__CAMERA_ROTATION = "camera_rotation" SETTING__COMPACT_SEEDQR = "compact_seedqr" SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds" + SETTING__MESSAGE_SIGNING = "message_signing" SETTING__PRIVACY_WARNINGS = "privacy_warnings" SETTING__DIRE_WARNINGS = "dire_warnings" SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips" @@ -455,6 +456,12 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING, + display_name="Message signing", + visibility=SettingsConstants.VISIBILITY__ADVANCED, + default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS, abbreviated_name="priv_warn", diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 48139065..b9cfa064 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -26,10 +26,14 @@ class PSBTSelectSeedView(View): if not self.controller.psbt: # Shouldn't be able to get here raise Exception("No PSBT currently loaded") - + + if self.controller.psbt_seed: + if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): + # skip the seed prompt if a seed was previous selected and has matching input fingerprint + return Destination(PSBTOverviewView) + seeds = self.controller.storage.seeds button_data = [] - for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): @@ -42,11 +46,6 @@ class PSBTSelectSeedView(View): button_data.append(self.TYPE_12WORD) button_data.append(self.TYPE_24WORD) - if self.controller.psbt_seed: - if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): - # skip the seed prompt if a seed was previous selected and has matching input fingerprint - return Destination(PSBTOverviewView) - selected_menu_num = self.run_screen( ButtonListScreen, title="Select Signer", @@ -177,6 +176,8 @@ class PSBTUnsupportedScriptTypeWarningView(View): skip_current_view=True, # Prevent going BACK to WarningViews ) + + class PSBTNoChangeWarningView(View): def run(self): selected_menu_num = WarningScreen( @@ -246,7 +247,7 @@ class PSBTAddressDetailsView(View): if not psbt_parser: # Should not be able to get here - return Destination(MainMenuView) + raise Exception("Routing error") title = "Will Send" if psbt_parser.num_destinations > 1: @@ -268,18 +269,17 @@ class PSBTAddressDetailsView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) + if self.address_num < len(psbt_parser.destination_addresses) - 1: + # Show the next receive addr + return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1}) + + elif psbt_parser.change_amount > 0: + # Move on to display change + return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0}) + else: - if self.address_num < len(psbt_parser.destination_addresses) - 1: - # Show the next receive addr - return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1}) - - elif psbt_parser.change_amount > 0: - # Move on to display change - return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0}) - - else: - # There's no change output to verify. Move on to sign the PSBT. - return Destination(PSBTFinalizeView) + # There's no change output to verify. Move on to sign the PSBT. + return Destination(PSBTFinalizeView) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 2774a3de..2b83d03b 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -7,7 +7,7 @@ from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants from seedsigner.views.settings_views import SettingsIngestSettingsQRView -from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination +from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination @@ -135,6 +135,21 @@ class ScanView(View): } ) + elif self.decoder.is_sign_message: + if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__ENABLED: + from seedsigner.views.seed_views import SeedSignMessageStartView + qr_data = self.decoder.get_qr_data() + + return Destination( + SeedSignMessageStartView, + view_args=dict( + derivation_path=qr_data["derivation_path"], + message=qr_data["message"], + ) + ) + else: + return Destination(OptionDisabledView, view_args=dict(error_msg="Message signing is currently disabled in Settings")) + else: return Destination(NotYetImplementedView) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0570b143..a4236cb0 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -68,6 +68,94 @@ class SeedsMenuView(View): +class SeedSelectSeedView(View): + """ + Reusable seed selection UI. Prompts the user to select amongst the already-loaded + seeds OR to load a seed. + + * `flow`: indicates which user flow is in progress during seed selection (e.g. + verify single sig addr or sign message). + """ + SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + + + def __init__(self, flow: str = Controller.FLOW__VERIFY_SINGLESIG_ADDR): + super().__init__() + self.flow = flow + + + def run(self): + seeds = self.controller.storage.seeds + + if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: + title = "Verify Address" + if not seeds: + text = "Load the seed to verify" + else: + text = "Select seed to verify" + + elif self.flow == Controller.FLOW__SIGN_MESSAGE: + title = "Sign Message" + if not seeds: + text = "Load the seed to sign with" + else: + text = "Select seed to sign with" + + else: + raise Exception(f"Unsupported `flow` specified: {self.flow}") + + button_data = [] + for seed in seeds: + button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + + if seed.passphrase is not None: + # TODO: Include lock icon on right side of button + pass + button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT, "blue")) + + button_data.append(self.SCAN_SEED) + button_data.append(self.TYPE_12WORD) + button_data.append(self.TYPE_24WORD) + + selected_menu_num = self.run_screen( + seed_screens.SeedSelectSeedScreen, + title=title, + text=text, + is_button_text_centered=False, + button_data=button_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if len(seeds) > 0 and selected_menu_num < len(seeds): + # User selected one of the n seeds + view_args = dict(seed_num=selected_menu_num) + if self.flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: + return Destination(SeedAddressVerificationView, view_args=view_args) + + elif self.flow == Controller.FLOW__SIGN_MESSAGE: + self.controller.sign_message_data["seed_num"] = selected_menu_num + return Destination(SeedSignMessageConfirmMessageView) + + self.controller.resume_main_flow = self.flow + + if button_data[selected_menu_num] == self.SCAN_SEED: + from seedsigner.views.scan_views import ScanView + return Destination(ScanView) + + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + from seedsigner.views.seed_views import SeedMnemonicEntryView + if button_data[selected_menu_num] == self.TYPE_12WORD: + self.controller.storage.init_pending_mnemonic(num_words=12) + else: + self.controller.storage.init_pending_mnemonic(num_words=24) + return Destination(SeedMnemonicEntryView) + + + """**************************************************************************** Loading seeds, passphrases, etc ****************************************************************************""" @@ -290,7 +378,7 @@ class SeedReviewPassphraseView(View): seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - + class SeedDiscardView(View): KEEP = "Keep Seed" @@ -342,6 +430,7 @@ class SeedOptionsView(View): VERIFY_ADDRESS = "Verify Addr" EXPORT_XPUB = "Export Xpub" EXPLORER = "Address Explorer" + SIGN_MESSAGE = "Sign Message" BACKUP = ("Backup Seed", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT) BIP85_CHILD_SEED = "BIP-85 Child Seed" DISCARD = ("Discard Seed", None, None, "red") @@ -368,6 +457,10 @@ class SeedOptionsView(View): # derivation path is specified. return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True) + elif self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE: + self.controller.sign_message_data["seed_num"] = self.seed_num + return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True) + if self.controller.psbt: if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: @@ -391,6 +484,9 @@ class SeedOptionsView(View): button_data.append(self.EXPLORER) button_data.append(self.BACKUP) + if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.SIGN_MESSAGE) + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: button_data.append(self.BIP85_CHILD_SEED) @@ -422,6 +518,12 @@ class SeedOptionsView(View): self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG)) + elif button_data[selected_menu_num] == self.SIGN_MESSAGE: + from seedsigner.views.scan_views import ScanView + self.controller.sign_message_data = dict(seed_num=self.seed_num) + self.controller.resume_main_flow = Controller.FLOW__SIGN_MESSAGE + return Destination(ScanView) + elif button_data[selected_menu_num] == self.BACKUP: return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) @@ -1458,7 +1560,7 @@ class AddressVerificationStartView(View): else: sig_type = SettingsConstants.SINGLE_SIG - destination = Destination(SeedSingleSigAddressVerificationSelectSeedView, skip_current_view=True) + destination = Destination(SeedSelectSeedView, skip_current_view=True) elif self.controller.unverified_address["script_type"] == SettingsConstants.TAPROOT: # TODO: add Taproot support @@ -1501,7 +1603,7 @@ class AddressVerificationSigTypeView(View): elif button_data[selected_menu_num] == SINGLE_SIG: sig_type = SettingsConstants.SINGLE_SIG - destination = Destination(SeedSingleSigAddressVerificationSelectSeedView) + destination = Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR)) elif button_data[selected_menu_num] == MULTISIG: sig_type = SettingsConstants.MULTISIG @@ -1523,61 +1625,6 @@ class AddressVerificationSigTypeView(View): -class SeedSingleSigAddressVerificationSelectSeedView(View): - def run(self): - seeds = self.controller.storage.seeds - - SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] - - text = "Load the seed to verify" - - for seed in seeds: - button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) - text = "Select seed to verify" - - button_data.append(SCAN_SEED) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) - - selected_menu_num = seed_screens.SeedSingleSigAddressVerificationSelectSeedScreen( - title="Verify Address", - text=text, - is_button_text_centered=False, - button_data=button_data - ).display() - - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - if len(seeds) > 0 and selected_menu_num < len(seeds): - # User selected one of the n seeds - return Destination( - SeedAddressVerificationView, - view_args=dict( - seed_num=selected_menu_num, - ) - ) - - self.controller.resume_main_flow = Controller.FLOW__VERIFY_SINGLESIG_ADDR - - if button_data[selected_menu_num] == SCAN_SEED: - from seedsigner.views.scan_views import ScanSeedQRView - return Destination(ScanSeedQRView) - - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: - from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: - self.controller.storage.init_pending_mnemonic(num_words=12) - else: - self.controller.storage.init_pending_mnemonic(num_words=24) - return Destination(SeedMnemonicEntryView) - - - class SeedAddressVerificationView(View): """ Creates a worker thread to brute-force calculate addresses. Writes its @@ -1773,7 +1820,8 @@ class AddressVerificationSuccessView(View): LargeIconStatusScreen( status_headline="Address Verified", - text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}.""" + text=f"""{address[:7]} = {source}'s {"change" if verified_index_is_change else "receive"} address #{verified_index}.""", + show_back_button=False, ).display() return Destination(MainMenuView) @@ -1854,3 +1902,155 @@ class MultisigWalletDescriptorView(View): return Destination(ToolsAddressExplorerAddressTypeView) return Destination(MainMenuView) + + + +"""**************************************************************************** + Sign Message Views +****************************************************************************""" +class SeedSignMessageStartView(View): + """ + Routes users straight through to the "Sign" screen if a signing `seed_num` has + already been selected. Otherwise routes to `SeedSelectSeedView` to select or + load a seed first. + """ + def __init__(self, derivation_path: str, message: str): + super().__init__() + self.derivation_path = derivation_path + self.message = message + + data = self.controller.sign_message_data + if not data: + data = {} + self.controller.sign_message_data = data + data["derivation_path"] = derivation_path + data["message"] = message + + # May be None + self.seed_num = data.get("seed_num") + + + def run(self): + if self.seed_num is not None: + # We already know which seed we're signing with + return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True) + else: + return Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True) + + + +class SeedSignMessageConfirmMessageView(View): + def __init__(self, page_num: int = 0): + super().__init__() + self.page_num = page_num # Note: zero-indexed numbering! + + self.seed_num = self.controller.sign_message_data.get("seed_num") + if self.seed_num is None: + raise Exception("Routing error: seed_num hasn't been set") + + + def run(self): + from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmMessageScreen + + selected_menu_num = self.run_screen( + SeedSignMessageConfirmMessageScreen, + page_num=self.page_num, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + if self.page_num == 0: + # We're exiting this flow entirely + self.controller.resume_main_flow = None + self.controller.sign_message_data = None + return Destination(BackStackView) + + # User clicked "Next" + if self.page_num == len(self.controller.sign_message_data["paged_message"]) - 1: + # We've reached the end of the paged message + return Destination(SeedSignMessageConfirmAddressView) + else: + return Destination(SeedSignMessageConfirmMessageView, view_args=dict(page_num=self.page_num + 1)) + + + +class SeedSignMessageConfirmAddressView(View): + def __init__(self): + super().__init__() + data = self.controller.sign_message_data + self.seed_num = data.get("seed_num") + self.derivation_path = data.get("derivation_path") + + if self.seed_num is None or not self.derivation_path: + raise Exception("Routing error: sign_message_data hasn't been set") + + # calculate the actual receive address + seed = self.controller.storage.seeds[self.seed_num] + addr_format = embit_utils.parse_derivation_path(self.derivation_path) + if not addr_format["clean_match"]: + raise Exception("Signing messages for custom derivation paths not supported") + + if addr_format["network"] != SettingsConstants.MAINNET: + # We're in either Testnet or Regtest or...? + if self.settings.get_value(SettingsConstants.SETTING__NETWORK) in [SettingsConstants.TESTNET, SettingsConstants.REGTEST]: + addr_format["network"] = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + else: + from seedsigner.views.view import NetworkMismatchErrorView + self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}"))) + + # cleanup. Note: We could leave this in place so the user can resume the + # flow, but for now we avoid complications and keep things simple. + self.controller.resume_main_flow = None + self.controller.sign_message_data = None + return + + xpub = seed.get_xpub(wallet_path=self.derivation_path, network=addr_format["network"]) + embit_network = embit_utils.get_embit_network_name(addr_format["network"]) + self.address = embit_utils.get_single_sig_address(xpub=xpub, script_type=addr_format["script_type"], index=addr_format["index"], is_change=addr_format["is_change"], embit_network=embit_network) + + + def run(self): + from seedsigner.gui.screens.seed_screens import SeedSignMessageConfirmAddressScreen + selected_menu_num = self.run_screen( + SeedSignMessageConfirmAddressScreen, + derivation_path=self.derivation_path, + address=self.address, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + # User clicked "Sign Message" + return Destination(SeedSignMessageSignedMessageQRView) + + + +class SeedSignMessageSignedMessageQRView(View): + """ + Displays the signed message as a QR code. + """ + def __init__(self): + super().__init__() + data = self.controller.sign_message_data + + self.seed_num = data["seed_num"] + seed = self.controller.get_seed(self.seed_num) + derivation_path = data["derivation_path"] + message: str = data["message"] + + self.signed_message = embit_utils.sign_message(seed_bytes=seed.seed_bytes, derivation=derivation_path, msg=message.encode()) + + + def run(self): + qr_encoder = EncodeQR(qr_type=QRType.SIGN_MESSAGE, signed_message=self.signed_message) + + self.run_screen( + QRDisplayScreen, + qr_encoder=qr_encoder, + ) + + # cleanup + self.controller.resume_main_flow = None + self.controller.sign_message_data = None + + # Exiting/Canceling the QR display screen always returns Home + return Destination(MainMenuView, skip_current_view=True) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 3bf92bee..dfd59eb3 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -4,7 +4,7 @@ from typing import Type from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen -from seedsigner.models.settings import Settings +from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.threads import BaseThread @@ -42,7 +42,12 @@ class BackStackView: "Cancel" - End task and return to entry point (destructive) """ class View: - def __init__(self) -> None: + def _initialize(self): + """ + Whether the View is a regular class initialized by __init__() or a dataclass + initialized by __post_init__(), this method will be called to set up the View's + instance variables. + """ # Import here to avoid circular imports from seedsigner.controller import Controller from seedsigner.gui import Renderer @@ -56,7 +61,44 @@ class View: self.canvas_height = self.renderer.canvas_height self.screen = None - + + self._redirect: 'Destination' = None + + + def __init__(self): + self._initialize() + + + def __post_init__(self): + self._initialize() + + + @property + def has_redirect(self) -> bool: + if not hasattr(self, '_redirect'): + # Easy for a View to forget to call super().__init__() + raise Exception(f"{self.__class__.__name__} did not call super().__init__()") + return self._redirect is not None + + + def set_redirect(self, destination: 'Destination'): + """ + Enables early `__init__()` / `__post_init__()` logic to redirect away from the + current View. + + Set a redirect Destination and then immediately `return` to exit `__init__()` or + `__post_init__()`. When the `Destination.run()` is called, it will see the redirect + and immediately return that new Destination to the Controller without running + the View's `run()`. + """ + # Always insure skip_current_view is set for a redirect + destination.skip_current_view = True + self._redirect = destination + + + def get_redirect(self) -> 'Destination': + return self._redirect + def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str: """ @@ -108,6 +150,8 @@ class Destination: def _run_view(self): + if self.view.has_redirect: + return self.view.get_redirect() return self.view.run() @@ -262,14 +306,14 @@ class NotYetImplementedView(View): @dataclass class ErrorView(View): - """ - """ title: str = "Error" + show_back_button: bool = True status_headline: str = None text: str = None button_text: str = None next_destination: Destination = Destination(MainMenuView, clear_history=True) + def run(self): self.run_screen( WarningScreen, @@ -277,15 +321,35 @@ class ErrorView(View): status_headline=self.status_headline, text=self.text, button_data=[self.button_text], + show_back_button=self.show_back_button, ) return self.next_destination +@dataclass +class NetworkMismatchErrorView(ErrorView): + title: str = "Network Mismatch" + show_back_button: bool = False + button_text: str = "Change Settings" + next_destination: Destination = None + + + def __post_init__(self): + super().__post_init__() + if not self.text: + self.text = f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match current action." + + if not self.next_destination: + from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView + self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True) + + + +@dataclass class UnhandledExceptionView(View): - def __init__(self, error: list[str]): - self.error = error + error: list[str] def run(self): @@ -300,3 +364,22 @@ class UnhandledExceptionView(View): ) return Destination(MainMenuView, clear_history=True) + + + +@dataclass +class OptionDisabledView(View): + error_msg: str + + + def run(self): + WarningScreen( + title="Option Disabled", + status_headline=None, + text=self.error_msg, + button_data=["OK"], + show_back_button=False, + allow_text_overflow=True, # Fit what we can, let the rest go off the edges + ).display() + + return Destination(MainMenuView, clear_history=True) diff --git a/tests/base.py b/tests/base.py index 708d64f7..0628fb1a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -151,6 +151,15 @@ class FlowTest(BaseTest): # View.run() call below. sequence.pop(0) + if destination.view.has_redirect: + # TODO: Migrate all View redirects to use `View.set_redirect()` + # in their `__init__()` rather than `run()` and then refactor + # here to explicitly require `has_redirect` to be True. + # For now: Support the newer `set_redirect()` routing while + # still letting redirects get returned by `View.run()` further + # below. + return destination.view.get_redirect() + # Some Views reach into their Screen's variables directly (e.g. # Screen.buttons to preserve the scroll position), so we need to mock out the # Screen instance that is created by the View. diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 037a7905..b498fa2e 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -1,7 +1,7 @@ import embit import os import sys -from mock import Mock, patch, MagicMock +from mock import Mock, MagicMock # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. @@ -15,16 +15,13 @@ sys.modules['seedsigner.hardware.microsd'] = MagicMock() from seedsigner.controller import Controller from seedsigner.gui.renderer import Renderer -from seedsigner.hardware.buttons import HardwareButtons -from seedsigner.hardware.camera import Camera from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.qr_type import QRType from seedsigner.models.seed import Seed -from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, NotYetImplementedView, UnhandledExceptionView, - psbt_views, scan_views, seed_views, settings_views, tools_views) -from seedsigner.views.view import View + psbt_views, seed_views, settings_views, tools_views) +from seedsigner.views.view import NetworkMismatchErrorView, PowerOffView, View from .utils import ScreenshotComplete, ScreenshotRenderer @@ -47,6 +44,9 @@ def test_generate_screenshots(target_locale): Renderer.configure_instance = Mock() Renderer.get_instance = Mock(return_value=screenshot_renderer) + # Additional mocks needed + PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command! + controller = Controller.get_instance() # Set up some test data that we'll need in the `Controller` for certain Views @@ -71,21 +71,14 @@ def test_generate_screenshots(target_locale): # Multisig wallet descriptor for the multisig in the above PSBT MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx""" controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR) - - def screencap_view(view_cls: View, view_name: str, view_args: dict={}): - screenshot_renderer.set_screenshot_filename(f"{view_name}.png") - try: - print(f"Running {view_name}") - view_cls(**view_args).run() - except ScreenshotComplete: - # Slightly hacky way to exit ScreenshotRenderer as expected - pass - print(f"Completed {view_name}") - except Exception as e: - # Something else went wrong - print(repr(e)) - raise e + # Message signing data + controller.sign_message_data = { + "seed_num": 0, + "derivation_path": "m/84h/0h/0h/0/0", + "message": "I attest that I control this bitcoin address blah blah blah", + } + # Automatically populate all Settings options Views settings_views_list = [] settings_views_list.append(settings_views.SettingsMenuView) @@ -108,10 +101,12 @@ def test_generate_screenshots(target_locale): MainMenuView, PowerOptionsView, RestartView, - #PowerOffView # this test is too real; pi will power-off + PowerOffView, NotYetImplementedView, (UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)), - (settings_views.SettingsIngestSettingsQRView, dict(data="settings::v1 name=factory_reset")) + (settings_views.SettingsIngestSettingsQRView, dict(data="settings::v1 name=factory_reset")), + NetworkMismatchErrorView, + ], "Seed Views": [ @@ -164,6 +159,9 @@ def test_generate_screenshots(target_locale): seed_views.LoadMultisigWalletDescriptorView, seed_views.MultisigWalletDescriptorView, (seed_views.SeedDiscardView, dict(seed_num=0)), + + seed_views.SeedSignMessageConfirmMessageView, + seed_views.SeedSignMessageConfirmAddressView, ], "PSBT Views": [ psbt_views.PSBTSelectSeedView, # this will fail, be rerun below @@ -203,12 +201,25 @@ def test_generate_screenshots(target_locale): "Settings Views": settings_views_list, } - - screenshot_renderer.set_screenshot_path(screenshot_root) - readme = f"""# SeedSigner Screenshots\n""" + def screencap_view(view_cls: View, view_name: str, view_args: dict={}): + screenshot_renderer.set_screenshot_filename(f"{view_name}.png") + try: + print(f"Running {view_name}") + view_cls(**view_args).run() + except ScreenshotComplete: + # Slightly hacky way to exit ScreenshotRenderer as expected + pass + print(f"Completed {view_name}") + except Exception as e: + # Something else went wrong + print(repr(e)) + raise e + for section_name, screenshot_list in screenshot_sections.items(): + subdir = section_name.lower().replace(" ", "_") + screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, subdir)) readme += "\n\n---\n\n" readme += f"## {section_name}\n\n" readme += """
{view_name}![]() |
{view_name}![]() |