Skip to content

Commit bbff4ce

Browse files
manuel-domkeclaude
andcommitted
Add signed SB2 firmware flashing for secure devices to Danger Zone
Secure and locked devices could previously only be flashed via the GitHub-release update flow. A new button in the Admin tab's Danger Zone lets users flash a local signed SB2.1 (.sb2) file instead — useful for older releases or offline setups. The flash itself reuses the Overview tab's existing firmware worker, log, and reconnect handling; the GUI switches there when flashing starts. Raw .bin files on locked devices are still rejected by the existing verify_firmware check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3633b65 commit bbff4ce

3 files changed

Lines changed: 71 additions & 16 deletions

File tree

src/solo_gui/views/main_window.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ def _setup_connections(self) -> None:
505505
self._admin_tab.reconnect_prepare.connect(self._prepare_for_reconnect)
506506
self._admin_tab.isp_done.connect(self._device_monitor.resume_monitoring)
507507
self._admin_tab.variant_detected.connect(self._overview_tab.on_variant_detected)
508+
self._admin_tab.flash_sb2_requested.connect(self._on_flash_sb2_requested)
508509
self._overview_tab.check_variant_requested.connect(self._admin_tab.trigger_check_variant)
509510
self._piv_tab.piv_availability.connect(self._on_piv_availability)
510511
self._gpg_tab.gpg_availability.connect(self._on_gpg_availability)
@@ -633,6 +634,11 @@ def _show_current_device_status(self) -> None:
633634
except Exception:
634635
self._status_bar.showMessage("Ready")
635636

637+
def _on_flash_sb2_requested(self, path: str) -> None:
638+
"""Danger Zone .sb2 flash — runs on the overview tab's flash machinery."""
639+
self._select_tab(self._overview_tab)
640+
self._overview_tab.start_flash_from_file(path)
641+
636642
def _prepare_for_reconnect(self) -> None:
637643
"""Tell the device monitor to expect a disconnect, without clearing worker state.
638644
Pauses all monitor polling to prevent the ISP check racing with discovery.

src/solo_gui/views/tabs/admin_tab.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ class AdminTab(QWidget):
8282

8383
reconnect_expected = Signal()
8484
reconnect_prepare = Signal() # prepare monitor + pause polling (ISP check starting)
85+
flash_sb2_requested = Signal(str) # path to .sb2 — handled by overview tab
8586
isp_done = Signal() # ISP check finished — resume monitor polling
8687
variant_detected = Signal(str) # forwarded to overview tab
8788
_check_variant_requested = Signal()
@@ -193,13 +194,7 @@ def _setup_ui(self) -> None:
193194
self._factory_reset_hint_label.setVisible(False)
194195
danger_layout.addWidget(self._factory_reset_hint_label)
195196

196-
danger_btn_layout = QHBoxLayout()
197-
self._factory_reset_btn = QPushButton("Factory Reset")
198-
self._factory_reset_btn.setToolTip(
199-
"Unplug and re-plug the device, start reset within about 10 seconds, then touch to confirm."
200-
)
201-
self._factory_reset_btn.clicked.connect(self._factory_reset)
202-
self._factory_reset_btn.setStyleSheet(f"""
197+
danger_btn_style = f"""
203198
QPushButton {{
204199
color: {danger_colors['title_text']};
205200
border: 1px solid {danger_colors['border']};
@@ -215,8 +210,24 @@ def _setup_ui(self) -> None:
215210
border-color: {danger_colors['button_disabled_border']};
216211
color: {danger_colors['button_disabled_text']};
217212
}}
218-
""")
213+
"""
214+
danger_btn_layout = QHBoxLayout()
215+
self._factory_reset_btn = QPushButton("Factory Reset")
216+
self._factory_reset_btn.setToolTip(
217+
"Unplug and re-plug the device, start reset within about 10 seconds, then touch to confirm."
218+
)
219+
self._factory_reset_btn.clicked.connect(self._factory_reset)
220+
self._factory_reset_btn.setStyleSheet(danger_btn_style)
219221
danger_btn_layout.addWidget(self._factory_reset_btn)
222+
self._flash_sb2_btn = QPushButton("Flash Signed Firmware (.sb2)…")
223+
self._flash_sb2_btn.setToolTip(
224+
"Flash a signed SB2.1 firmware file (.sb2) from disk.\n"
225+
"Works on all variants, including Secure devices — the bootloader\n"
226+
"rejects files that are not correctly signed."
227+
)
228+
self._flash_sb2_btn.clicked.connect(self._flash_sb2_from_file)
229+
self._flash_sb2_btn.setStyleSheet(danger_btn_style)
230+
danger_btn_layout.addWidget(self._flash_sb2_btn)
220231
self._restart_admin_button = QPushButton("Restart as Administrator")
221232
self._restart_admin_button.clicked.connect(partial(restart_as_admin_from_ui, self))
222233
self._restart_admin_button.setVisible(False)
@@ -315,6 +326,7 @@ def _set_controls_enabled(self, enabled: bool) -> None:
315326
self._unlock_btn.setEnabled(enabled and self._last_isp_variant == "Hacker (locked)")
316327
self._reboot_regular_btn.setEnabled(enabled)
317328
self._reboot_bootloader_btn.setEnabled(enabled)
329+
self._flash_sb2_btn.setEnabled(enabled)
318330
self._update_factory_reset_controls(enabled)
319331

320332
def _apply_capabilities(self, caps) -> None:
@@ -324,6 +336,7 @@ def _apply_capabilities(self, caps) -> None:
324336
self._unlock_btn.setEnabled(can_boot and self._last_isp_variant == "Hacker (locked)")
325337
self._reboot_regular_btn.setEnabled(has_device and caps.has_reboot)
326338
self._reboot_bootloader_btn.setEnabled(can_boot)
339+
self._flash_sb2_btn.setEnabled(can_boot)
327340
self._update_factory_reset_controls(has_device)
328341

329342
def _apply_factory_reset_hint_style(self) -> None:
@@ -446,6 +459,32 @@ def _unlock_device(self) -> None:
446459
self._show_touch_prompt()
447460
self._unlock_requested.emit("")
448461

462+
def _flash_sb2_from_file(self) -> None:
463+
reply = QMessageBox.warning(
464+
self,
465+
"Flash Signed Firmware",
466+
"This flashes a signed SB2.1 firmware file (.sb2), like the ones "
467+
"attached to official GitHub releases.\n\n"
468+
"Signed firmware can be installed on any variant, including Secure "
469+
"devices — the bootloader rejects files that are not correctly signed.\n\n"
470+
"After you select a file, watch the Solo 2 and press its button when "
471+
"it asks for touch confirmation to enter bootloader mode.\n\n"
472+
"Do not disconnect the device during the process.\n\n"
473+
"Continue?",
474+
QMessageBox.Yes | QMessageBox.No,
475+
QMessageBox.No,
476+
)
477+
if reply != QMessageBox.Yes:
478+
return
479+
480+
path, _ = QFileDialog.getOpenFileName(
481+
self, "Select Signed Firmware", "", "Signed Firmware (*.sb2);;All Files (*)"
482+
)
483+
if not path:
484+
return
485+
486+
self.flash_sb2_requested.emit(path)
487+
449488
def _reboot(self, mode: RebootMode) -> None:
450489
mode_name = "bootloader" if mode == RebootMode.BOOTLOADER else "normal"
451490
reply = QMessageBox.question(

src/solo_gui/views/tabs/overview_tab.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -319,14 +319,24 @@ def _flash_from_file(self) -> None:
319319
QMessageBox.No,
320320
)
321321
if reply == QMessageBox.Yes:
322-
self._set_busy(True, "Flashing firmware…")
323-
self._show_touch_prompt()
324-
self._check_updates_button.setEnabled(False)
325-
self._flash_file_btn.setEnabled(False)
326-
if with_attestation:
327-
self._flash_with_attestation_requested.emit(path)
328-
else:
329-
self._flash_from_file_requested.emit(path)
322+
self._start_flash(path, with_attestation)
323+
324+
def _start_flash(self, path: str, with_attestation: bool) -> None:
325+
self._set_busy(True, "Flashing firmware…")
326+
self._show_touch_prompt()
327+
self._check_updates_button.setEnabled(False)
328+
self._flash_file_btn.setEnabled(False)
329+
if with_attestation:
330+
self._flash_with_attestation_requested.emit(path)
331+
else:
332+
self._flash_from_file_requested.emit(path)
333+
334+
def start_flash_from_file(self, path: str) -> None:
335+
"""Start flashing a firmware file — entry point for the Admin tab's
336+
Danger Zone (already confirmed there, so no extra dialog here)."""
337+
if not self._device or not self._firmware_worker or self._firmware_busy:
338+
return
339+
self._start_flash(path, with_attestation=False)
330340

331341
def _replace_last_log_line(self, message: str) -> None:
332342
"""Overwrite the last line of the log (for in-place progress updates)."""

0 commit comments

Comments
 (0)