From f86915453aa29d9eb4ec196498325a02d686a4a0 Mon Sep 17 00:00:00 2001 From: sudotsu Date: Thu, 6 Aug 2026 08:13:21 -0500 Subject: [PATCH 1/3] feat(gui): complete standalone GUI for mtkclient 12-tab modular PySide6 GUI with plugin system: - Device, Read, Write, Erase, Keys, Bootloader, Memory, RPMB, IMEI, Exploit, eFuse, Server - All buttons wired to mtkclient backend via MtkWrapper facade - GuiSignalProxy bridges mtkclient logging to Qt signals - USB polling and partition loading run off the main thread - Button-disable-during-operations prevents concurrent conflicts - Destructive operations gated through ConfirmationDialog - Dark/light theme support with QSS stylesheets - Plugin system with hot-loadable MtkPlugin subclasses - PyInstaller spec for Windows executable build Co-Authored-By: Claude Opus 4.6 (1M context) --- mtk-gui.spec | 64 ++ mtk_gui/__init__.py | 1 + mtk_gui/app.py | 54 ++ mtk_gui/backend/__init__.py | 1 + mtk_gui/backend/device_manager.py | 206 ++++++ mtk_gui/backend/log_interceptor.py | 57 ++ mtk_gui/backend/mtk_wrapper.py | 716 ++++++++++++++++++ mtk_gui/backend/worker.py | 42 ++ mtk_gui/constants.py | 27 + mtk_gui/main_window.py | 852 ++++++++++++++++++++++ mtk_gui/plugins/__init__.py | 1 + mtk_gui/plugins/base_plugin.py | 74 ++ mtk_gui/plugins/loader.py | 62 ++ mtk_gui/resources/__init__.py | 0 mtk_gui/theme/__init__.py | 12 + mtk_gui/theme/colors.py | 34 + mtk_gui/theme/dark.qss | 349 +++++++++ mtk_gui/theme/light.qss | 317 ++++++++ mtk_gui/ui/__init__.py | 1 + mtk_gui/ui/tabs/__init__.py | 1 + mtk_gui/ui/tabs/bootloader_tab.py | 81 ++ mtk_gui/ui/tabs/device_tab.py | 218 ++++++ mtk_gui/ui/tabs/efuse_tab.py | 45 ++ mtk_gui/ui/tabs/erase_tab.py | 105 +++ mtk_gui/ui/tabs/exploit_tab.py | 168 +++++ mtk_gui/ui/tabs/imei_tab.py | 159 ++++ mtk_gui/ui/tabs/keys_tab.py | 73 ++ mtk_gui/ui/tabs/memory_tab.py | 168 +++++ mtk_gui/ui/tabs/read_tab.py | 194 +++++ mtk_gui/ui/tabs/rpmb_tab.py | 123 ++++ mtk_gui/ui/tabs/server_tab.py | 70 ++ mtk_gui/ui/tabs/write_tab.py | 205 ++++++ mtk_gui/ui/widgets/__init__.py | 1 + mtk_gui/ui/widgets/confirmation_dialog.py | 71 ++ mtk_gui/ui/widgets/hex_viewer.py | 47 ++ mtk_gui/ui/widgets/log_panel.py | 116 +++ mtk_gui/ui/widgets/partition_list.py | 109 +++ mtk_gui/ui/widgets/progress_panel.py | 119 +++ mtk_gui/ui/widgets/serial_port_dialog.py | 59 ++ plugins/example_plugin.py | 48 ++ run.py | 19 + 41 files changed, 5069 insertions(+) create mode 100644 mtk-gui.spec create mode 100644 mtk_gui/__init__.py create mode 100644 mtk_gui/app.py create mode 100644 mtk_gui/backend/__init__.py create mode 100644 mtk_gui/backend/device_manager.py create mode 100644 mtk_gui/backend/log_interceptor.py create mode 100644 mtk_gui/backend/mtk_wrapper.py create mode 100644 mtk_gui/backend/worker.py create mode 100644 mtk_gui/constants.py create mode 100644 mtk_gui/main_window.py create mode 100644 mtk_gui/plugins/__init__.py create mode 100644 mtk_gui/plugins/base_plugin.py create mode 100644 mtk_gui/plugins/loader.py create mode 100644 mtk_gui/resources/__init__.py create mode 100644 mtk_gui/theme/__init__.py create mode 100644 mtk_gui/theme/colors.py create mode 100644 mtk_gui/theme/dark.qss create mode 100644 mtk_gui/theme/light.qss create mode 100644 mtk_gui/ui/__init__.py create mode 100644 mtk_gui/ui/tabs/__init__.py create mode 100644 mtk_gui/ui/tabs/bootloader_tab.py create mode 100644 mtk_gui/ui/tabs/device_tab.py create mode 100644 mtk_gui/ui/tabs/efuse_tab.py create mode 100644 mtk_gui/ui/tabs/erase_tab.py create mode 100644 mtk_gui/ui/tabs/exploit_tab.py create mode 100644 mtk_gui/ui/tabs/imei_tab.py create mode 100644 mtk_gui/ui/tabs/keys_tab.py create mode 100644 mtk_gui/ui/tabs/memory_tab.py create mode 100644 mtk_gui/ui/tabs/read_tab.py create mode 100644 mtk_gui/ui/tabs/rpmb_tab.py create mode 100644 mtk_gui/ui/tabs/server_tab.py create mode 100644 mtk_gui/ui/tabs/write_tab.py create mode 100644 mtk_gui/ui/widgets/__init__.py create mode 100644 mtk_gui/ui/widgets/confirmation_dialog.py create mode 100644 mtk_gui/ui/widgets/hex_viewer.py create mode 100644 mtk_gui/ui/widgets/log_panel.py create mode 100644 mtk_gui/ui/widgets/partition_list.py create mode 100644 mtk_gui/ui/widgets/progress_panel.py create mode 100644 mtk_gui/ui/widgets/serial_port_dialog.py create mode 100644 plugins/example_plugin.py create mode 100644 run.py diff --git a/mtk-gui.spec b/mtk-gui.spec new file mode 100644 index 0000000..edf815b --- /dev/null +++ b/mtk-gui.spec @@ -0,0 +1,64 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec for MTK-GUI.""" +import os +import sys +from PyInstaller.utils.hooks import collect_submodules, collect_data_files + +block_cipher = None + +# Collect all mtkclient submodules — many are loaded dynamically +mtkclient_hiddenimports = collect_submodules('mtkclient') +usb_hiddenimports = collect_submodules('usb') + +# Collect mtkclient data files (DA loaders, configs, etc.) +mtkclient_datas = collect_data_files('mtkclient') + +a = Analysis( + ['run.py'], + pathex=[], + binaries=[], + datas=[ + ('mtk_gui/theme/*.qss', 'mtk_gui/theme'), + ('plugins', 'plugins'), + ] + mtkclient_datas, + hiddenimports=mtkclient_hiddenimports + usb_hiddenimports + [ + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='mtk-gui', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, # windowed app, no console + icon=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='mtk-gui', +) diff --git a/mtk_gui/__init__.py b/mtk_gui/__init__.py new file mode 100644 index 0000000..a4180f1 --- /dev/null +++ b/mtk_gui/__init__.py @@ -0,0 +1 @@ +"""MTK-GUI: Standalone GUI for mtkclient.""" diff --git a/mtk_gui/app.py b/mtk_gui/app.py new file mode 100644 index 0000000..88f4f49 --- /dev/null +++ b/mtk_gui/app.py @@ -0,0 +1,54 @@ +"""QApplication setup, theme init, entry point.""" +import os +import sys + +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import Qt + +from mtk_gui.constants import APP_NAME, ORG_NAME +from mtk_gui.main_window import MainWindow +from mtk_gui.plugins.loader import load_plugins + + +def get_plugin_dirs() -> list: + """Return plugin search directories.""" + dirs = [] + # App-local plugins dir + app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + dirs.append(os.path.join(app_dir, "plugins")) + # User config plugins dir + if sys.platform == "win32": + config_dir = os.path.join(os.environ.get("APPDATA", ""), "mtk-gui", "plugins") + else: + config_dir = os.path.join(os.path.expanduser("~"), ".config", "mtk-gui", "plugins") + dirs.append(config_dir) + return dirs + + +def main(): + QApplication.setOrganizationName(ORG_NAME) + QApplication.setApplicationName(APP_NAME) + + app = QApplication(sys.argv) + + # High DPI support + app.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + + window = MainWindow() + window.apply_initial_theme() + + # Load plugins + class AppContext: + main_window = window + + plugins = load_plugins(AppContext(), get_plugin_dirs()) + window.set_plugins(plugins) + + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/mtk_gui/backend/__init__.py b/mtk_gui/backend/__init__.py new file mode 100644 index 0000000..6690b0d --- /dev/null +++ b/mtk_gui/backend/__init__.py @@ -0,0 +1 @@ +"""Backend layer — wraps mtkclient. No PySide6 imports.""" diff --git a/mtk_gui/backend/device_manager.py b/mtk_gui/backend/device_manager.py new file mode 100644 index 0000000..d290b1a --- /dev/null +++ b/mtk_gui/backend/device_manager.py @@ -0,0 +1,206 @@ +"""Connection state machine and USB polling.""" +import logging +import threading +import time +from enum import Enum, auto +from typing import Optional + +from PySide6.QtCore import QObject, QTimer, Signal + +from mtk_gui.backend.log_interceptor import LogSignalEmitter, GuiSignalProxy +from mtk_gui.backend.mtk_wrapper import MtkWrapper, DeviceInfo +from mtk_gui.backend.worker import Worker +from mtk_gui.constants import USB_POLL_INTERVAL_MS + + +class DeviceState(Enum): + DISCONNECTED = auto() + DETECTING = auto() + PRELOADER = auto() + CONNECTING_DA = auto() + CONNECTED = auto() + ERROR = auto() + + +class DeviceManager(QObject): + """Manages device connection lifecycle.""" + + state_changed = Signal(object) # DeviceState + device_info_updated = Signal(object) # DeviceInfo + log_message = Signal(str, int) # message, level + progress_update = Signal(int, int) # current, total + status_update = Signal(str) + _usb_detected = Signal(int) # PID (internal, thread→main) + + def __init__(self, parent=None): + super().__init__(parent) + self._state = DeviceState.DISCONNECTED + self._wrapper: Optional[MtkWrapper] = None + self._device_info = DeviceInfo() + self._workers: list[Worker] = [] + + # Log signal bridge + self._log_emitter = LogSignalEmitter() + self._log_emitter.log_received.connect(self.log_message) + self._log_emitter.progress_received.connect(self.progress_update) + self._log_emitter.status_received.connect(self.status_update) + + # USB poll thread + self._poll_stop = threading.Event() + self._poll_thread: Optional[threading.Thread] = None + self._usb_detected.connect(self._on_usb_detected) + + # Connection settings + self.preloader_path: Optional[str] = None + self.da_loader_path: Optional[str] = None + self.serial_port: Optional[str] = None + self.iot_mode: bool = False + self.stock_da: bool = False + self.work_dir: str = "." + + @property + def state(self) -> DeviceState: + return self._state + + @property + def device_info(self) -> DeviceInfo: + return self._device_info + + @property + def wrapper(self) -> Optional[MtkWrapper]: + return self._wrapper + + def _set_state(self, new_state: DeviceState): + if new_state != self._state: + self._state = new_state + self.state_changed.emit(new_state) + + def start_polling(self): + """Start USB device polling in a background thread.""" + if self._poll_thread and self._poll_thread.is_alive(): + return + self._poll_stop.clear() + self._poll_thread = threading.Thread( + target=self._poll_usb_loop, daemon=True) + self._poll_thread.start() + + def stop_polling(self): + """Stop USB device polling.""" + self._poll_stop.set() + + def _poll_usb_loop(self): + """Background thread: poll USB until device found or stopped.""" + try: + import usb.core + from mtk_gui.constants import MTK_USB_VID, MTK_PRELOADER_PIDS + except ImportError: + self.log_message.emit( + "pyusb not available — use manual connect", logging.WARNING) + return + + interval = USB_POLL_INTERVAL_MS / 1000.0 + while not self._poll_stop.is_set(): + if self._state not in (DeviceState.DISCONNECTED, DeviceState.DETECTING): + self._poll_stop.wait(interval) + continue + try: + dev = usb.core.find(idVendor=MTK_USB_VID) + if dev is not None: + pid = dev.idProduct + if pid in MTK_PRELOADER_PIDS: + self._usb_detected.emit(pid) + return + except Exception: + pass + self._poll_stop.wait(interval) + + def _on_usb_detected(self, pid: int): + """Handle USB detection on the main thread (via signal).""" + from mtk_gui.constants import MTK_USB_VID + self._set_state(DeviceState.PRELOADER) + self.log_message.emit( + f"MTK device detected: VID={MTK_USB_VID:#06x} PID={pid:#06x}", + logging.INFO, + ) + + def connect_device(self): + """Initiate connection to detected device.""" + if self._state == DeviceState.CONNECTED: + return + + self._set_state(DeviceState.CONNECTING_DA) + + # Create signal proxy for mtkclient + proxy = GuiSignalProxy(self._log_emitter) + + self._wrapper = MtkWrapper( + gui_signal=proxy, + gui_progress=proxy.progress_callback, + status_signal=proxy.update_status_text, + ) + + worker = Worker(self._do_connect) + worker.finished.connect(self._on_connect_finished) + worker.error.connect(self._on_connect_error) + self._track_worker(worker) + worker.start() + + def _do_connect(self, worker=None): + """Run connection in worker thread.""" + self._wrapper.init( + preloader=self.preloader_path, + loader=self.da_loader_path, + serial_port=self.serial_port, + iot=self.iot_mode, + stock=self.stock_da, + work_dir=self.work_dir, + ) + success = self._wrapper.connect(self.work_dir) + return success + + def _on_connect_finished(self, success): + if success: + self._device_info = self._wrapper.get_device_info() + self._set_state(DeviceState.CONNECTED) + self.device_info_updated.emit(self._device_info) + self.log_message.emit("Device connected successfully.", logging.INFO) + else: + self._set_state(DeviceState.ERROR) + self.log_message.emit("Connection failed.", logging.ERROR) + + def _on_connect_error(self, msg: str): + self._set_state(DeviceState.ERROR) + self.log_message.emit(f"Connection error: {msg}", logging.ERROR) + + def disconnect_device(self): + """Disconnect from device.""" + self._cancel_all_workers() + if self._wrapper: + self._wrapper.disconnect() + self._wrapper = None + self._device_info = DeviceInfo() + self._set_state(DeviceState.DISCONNECTED) + self.device_info_updated.emit(self._device_info) + self.log_message.emit("Device disconnected.", logging.INFO) + # Resume polling for next device + self.start_polling() + + def create_worker(self, func, *args, **kwargs) -> Worker: + """Create and track a worker for a backend operation.""" + w = Worker(func, *args, **kwargs) + self._track_worker(w) + return w + + def _track_worker(self, worker: Worker): + self._workers.append(worker) + worker.finished.connect(lambda _: self._untrack_worker(worker)) + worker.error.connect(lambda _: self._untrack_worker(worker)) + + def _untrack_worker(self, worker: Worker): + if worker in self._workers: + self._workers.remove(worker) + + def _cancel_all_workers(self): + for w in list(self._workers): + w.cancel() + self._workers.clear() diff --git a/mtk_gui/backend/log_interceptor.py b/mtk_gui/backend/log_interceptor.py new file mode 100644 index 0000000..0ef78d4 --- /dev/null +++ b/mtk_gui/backend/log_interceptor.py @@ -0,0 +1,57 @@ +"""Bridge mtkclient's logging to Qt signals.""" +import logging +from PySide6.QtCore import QObject, Signal + + +class LogSignalEmitter(QObject): + """Emits Qt signals when log records arrive.""" + log_received = Signal(str, int) # message, level + progress_received = Signal(int, int) # current, total + status_received = Signal(str) + + +class LogSignalHandler(logging.Handler): + """logging.Handler that forwards records to a LogSignalEmitter.""" + + def __init__(self, emitter: LogSignalEmitter): + super().__init__() + self.emitter = emitter + + def emit(self, record: logging.LogRecord): + try: + msg = self.format(record) + self.emitter.log_received.emit(msg, record.levelno) + except RuntimeError: + pass # Qt object deleted + + +class GuiSignalProxy(QObject): + """Drop-in replacement for mtkclient's gui/guiprogress/update_status_text signals. + + MtkConfig expects: + - gui: object with .emit(str) for log lines + - guiprogress: callable(current, total) + - update_status_text: object with .emit(str) for status bar + """ + sendToLogSignal = Signal(str) + sendUpdateSignal = Signal() + update_status_text = Signal(str) + + def __init__(self, emitter: LogSignalEmitter): + super().__init__() + self.emitter = emitter + self.sendToLogSignal.connect(self._on_log) + self.update_status_text.connect(self._on_status) + + def _on_log(self, msg: str): + self.emitter.log_received.emit(msg, logging.INFO) + + def _on_status(self, msg: str): + self.emitter.status_received.emit(msg) + + def emit(self, msg: str): + """Direct emit — mtkclient's logsetup() calls gui.emit(string).""" + self.sendToLogSignal.emit(msg) + + def progress_callback(self, current: int, total: int): + self.emitter.progress_received.emit(current, total) diff --git a/mtk_gui/backend/mtk_wrapper.py b/mtk_gui/backend/mtk_wrapper.py new file mode 100644 index 0000000..0d5dcd3 --- /dev/null +++ b/mtk_gui/backend/mtk_wrapper.py @@ -0,0 +1,716 @@ +"""Clean facade over mtkclient API. No Qt dependency.""" +import logging +import os +import sys +from dataclasses import dataclass, field +from typing import Optional + +from mtkclient.Library.DA.mtk_da_handler import DaHandler +from mtkclient.Library.mtk_class import Mtk +from mtkclient.config.mtk_config import MtkConfig +from mtkclient.Library.DA.mtk_daloader import DAloader + + +class OperationAborted(Exception): + """Raised instead of sys.exit() inside library code.""" + + +@dataclass +class DeviceInfo: + chipset: str = "" + hwcode: str = "" + hwver: str = "" + swver: str = "" + boot_mode: str = "" + flash_type: str = "" + flash_size: str = "" + meid: str = "" + socid: str = "" + da_mode: str = "" + is_brom: bool = False + target_config: Optional[dict] = None + extra: dict = field(default_factory=dict) + + +@dataclass +class PartitionInfo: + name: str + sector: int + sectors: int + size: int + fs_type: str = "" + + +class MtkWrapper: + """Wraps mtkclient init/connect and all DA operations.""" + + def __init__(self, gui_signal=None, gui_progress=None, status_signal=None): + self.mtk: Optional[Mtk] = None + self.da_handler: Optional[DaHandler] = None + self.config: Optional[MtkConfig] = None + self._gui_signal = gui_signal + self._gui_progress = gui_progress + self._status_signal = status_signal + self._original_exit = sys.exit + + def _patch_sys_exit(self): + """Monkey-patch sys.exit() to raise OperationAborted instead.""" + def patched_exit(code=0): + raise OperationAborted(f"sys.exit({code}) intercepted") + sys.exit = patched_exit + + def _restore_sys_exit(self): + sys.exit = self._original_exit + + def init(self, preloader: str = None, loader: str = None, + serial_port: str = None, iot: bool = False, + stock: bool = False, work_dir: str = ".") -> Mtk: + """Initialize MtkConfig and Mtk instance.""" + loglevel = logging.INFO + self.config = MtkConfig( + loglevel=loglevel, + gui=self._gui_signal, + guiprogress=self._gui_progress, + update_status_text=self._status_signal, + ) + self.config.loader = loader + self.config.iot = iot + self.config.stock = stock + self.config.hwparam_path = work_dir + + if preloader and os.path.exists(preloader): + self.config.preloader_filename = preloader + with open(preloader, "rb") as f: + self.config.preloader = f.read() + + self.mtk = Mtk(config=self.config, loglevel=loglevel, serialportname=serial_port) + return self.mtk + + def connect(self, work_dir: str = ".") -> bool: + """Connect to device via DA. Returns True on success.""" + if self.mtk is None: + return False + self._patch_sys_exit() + try: + self.da_handler = DaHandler(self.mtk, logging.INFO) + mtk = self.da_handler.connect(self.mtk, work_dir) + if mtk is None: + return False + self.mtk = mtk + self.mtk = self.da_handler.configure_da(self.mtk) + return True + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def disconnect(self): + """Clean up connection state.""" + self.da_handler = None + self.mtk = None + self.config = None + + def get_device_info(self) -> DeviceInfo: + """Gather device info from current connection.""" + info = DeviceInfo() + if self.mtk is None or self.config is None: + return info + + info.hwcode = hex(self.config.hwcode) if self.config.hwcode else "" + info.hwver = hex(self.config.hwver) if self.config.hwver else "" + info.swver = hex(self.config.swver) if self.config.swver else "" + info.is_brom = self.config.is_brom + info.boot_mode = "BROM" if self.config.is_brom else "Preloader" + info.chipset = f"MT{self.config.hwcode:04X}" if self.config.hwcode else "Unknown" + + if self.config.meid: + info.meid = self.config.meid.hex() if isinstance(self.config.meid, (bytes, bytearray)) else str(self.config.meid) + if self.config.socid: + info.socid = self.config.socid.hex() if isinstance(self.config.socid, (bytes, bytearray)) else str(self.config.socid) + + if hasattr(self.mtk, 'daloader') and self.mtk.daloader is not None: + dl = self.mtk.daloader + if hasattr(dl, 'daconfig') and dl.daconfig is not None: + storage = dl.daconfig.storage + if storage: + info.flash_type = getattr(storage, 'flashtype', '') + flash_size = getattr(storage, 'flashsize', 0) + if flash_size: + info.flash_size = self._format_size(flash_size) + + if self.config.target_config: + info.target_config = self.config.target_config + + return info + + def get_partitions(self, worker=None) -> list: + """Get list of partitions from GPT.""" + partitions = [] + if self.da_handler is None: + return partitions + + try: + gpt = self.mtk.daloader.get_gpt() + if gpt is None: + return partitions + gpt_data, gpt_guid = gpt + for partition in gpt_data: + p = PartitionInfo( + name=partition.name, + sector=partition.sector, + sectors=partition.sectors, + size=partition.sectors * self.config.SECTOR_SIZE_IN_BYTES, + ) + partitions.append(p) + except Exception: + pass + return partitions + + # ── Flash read operations ───────────────────────────── + + def read_partition(self, partition_name: str, filename: str, + parttype: str = "user", worker=None) -> bool: + """Read a single partition to file.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_read( + partition_name, parttype, filename, display=True + ) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def read_partitions(self, directory: str, parttype: str = "user", + skip: list = None, worker=None) -> bool: + """Read all partitions to directory.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_rl(directory, parttype, skip or [], display=True) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def read_flash(self, filename: str, parttype: str = "user", + offset: int = None, length: int = None, worker=None) -> bool: + """Read full flash or offset range to file.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_rf(filename, parttype, offset, length, display=True) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def read_sectors(self, start: int, sectors: int, filename: str, + parttype: str = "user", worker=None) -> bool: + """Read by sector range.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_rs(start, sectors, filename, parttype, display=True) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def read_offset(self, start: int, length: int, filename: str, + parttype: str = "user", worker=None) -> bool: + """Read by byte offset range.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_ro(start, length, filename, parttype, display=True) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── Flash write operations ──────────────────────────── + + def write_partitions(self, parttype: str, filenames: list, + partitions: list, worker=None) -> bool: + """Write files to named partitions.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_write(parttype, filenames, partitions) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def write_flash(self, filenames: list, parttype: str = "user", worker=None) -> bool: + """Write full flash image(s).""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_wf(filenames, parttype) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def write_from_directory(self, parttype: str, directory: str, worker=None) -> bool: + """Write all matching partition files from directory.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_wl(parttype, directory) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def write_offset(self, start: int, length: int, filename: str, + parttype: str = "user", worker=None) -> bool: + """Write to a byte offset.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_wo(start, length, filename, parttype) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── Erase operations ────────────────────────────────── + + def erase_partitions(self, partitions: list, parttype: str = "user", worker=None) -> bool: + """Erase named partitions.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_erase(partitions, parttype) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def erase_sectors(self, sector: int, sectors: int, + parttype: str = "user", worker=None) -> bool: + """Erase by sector range.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_ess(sector, sectors, parttype) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── Security operations ─────────────────────────────── + + def generate_keys(self, directory: str = ".", worker=None) -> bool: + """Generate hardware keys.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + self.config.hwparam_path = directory + return self.mtk.daloader.keys() + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def seccfg_unlock(self, lock: bool = False, critical: bool = False, worker=None) -> bool: + """Lock or unlock bootloader via seccfg.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + lockflag = "lock" if lock else "unlock" + return self.mtk.daloader.seccfg(lockflag, critical) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def vbmeta_patch(self, vbmode: int = 3, worker=None) -> bool: + """Patch vbmeta for verified boot bypass.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_vbmeta(vbmode, display=True) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── Memory operations ───────────────────────────────── + + def peek(self, addr: int, length: int, filename: str = "", + registers: bool = False, worker=None): + """Read memory at address.""" + if self.da_handler is None: + return None + self._patch_sys_exit() + try: + return self.da_handler.da_peek(addr, length, filename, registers) + except OperationAborted: + return None + finally: + self._restore_sys_exit() + + def poke(self, addr: int, data: str, filename: str = "", worker=None) -> bool: + """Write memory at address.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_poke(addr, data, filename) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def dump_brom(self, filename: str, worker=None) -> bool: + """Dump BROM.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_brom(filename) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── RPMB operations ─────────────────────────────────── + + def read_rpmb(self, filename: str = None, sector: str = None, + sectors: str = None, worker=None): + """Read RPMB.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return None + self._patch_sys_exit() + try: + return self.mtk.daloader.read_rpmb(filename, sector, sectors) + except OperationAborted: + return None + finally: + self._restore_sys_exit() + + def write_rpmb(self, filename: str = None, sector: int = 0, + sectors: int = None, worker=None) -> bool: + """Write RPMB.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.write_rpmb(filename, sector, sectors) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── eFuse ───────────────────────────────────────────── + + def read_efuses(self, worker=None): + """Read eFuse values.""" + if self.da_handler is None: + return None + self._patch_sys_exit() + try: + return self.da_handler.da_efuse() + except OperationAborted: + return None + finally: + self._restore_sys_exit() + + # ── GPT ─────────────────────────────────────────────── + + def dump_gpt(self, directory: str, worker=None) -> bool: + """Dump GPT to directory.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_gpt(directory, display=True) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def erase_rpmb(self, sector: int = 0, sectors: int = None, worker=None) -> bool: + """Erase RPMB.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.erase_rpmb(sector, sectors) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def auth_rpmb(self, rpmbkey: bytes = None, worker=None) -> bool: + """Authenticate RPMB with key.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.auth_rpmb(rpmbkey) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── IMEI ────────────────────────────────────────────── + + def read_imei(self, seed: bytes = b"", aeskey: bytes = b"" * 32, + worker=None) -> list: + """Read IMEI(s) from nvdata partition. Returns list of (imei, valid) tuples.""" + if self.da_handler is None: + return [] + self._patch_sys_exit() + try: + from mtkclient.Library.utils import find_binary + from mtkclient.Library.mtk_crypto import decode_imei, calc_checksum, is_luhn_valid + + nvdata = self.da_handler.da_read_partition(partitionname="nvdata") + if not nvdata or nvdata == b"": + return [] + + pos = find_binary(nvdata, b"\x4C\x44\x49\x00\x10\xEF\x0A\x00\x0A") + if pos == -1: + return [] + + nvitem_data = nvdata[pos:pos + 0x180] + result = self.mtk.daloader.nvitem( + data=nvitem_data, encrypt=False, + otp=self.mtk.config.get_otp(), + seed=seed, aeskey=aeskey, display=False, + ) + if result is None: + return [] + + imeis = [] + for i in range(len(result) // 0x20): + data = bytearray(result[i * 0x20:i * 0x20 + 0x20]) + if data[:0xA] == b"\xFF" * 0xA: + continue + csum = calc_checksum(data, 0xA) + if csum == data[0xA:0xA + 8]: + imei = decode_imei(data[:0xA]) + valid = is_luhn_valid(imei) + imeis.append((imei, valid)) + return imeis + except OperationAborted: + return [] + finally: + self._restore_sys_exit() + + def write_imei(self, imeis: list, seed: bytes = b"", + aeskey: bytes = b"" * 32, product: str = "", + worker=None) -> bool: + """Write IMEI(s) to nvdata partition. imeis: list of IMEI strings.""" + if self.da_handler is None or not imeis: + return False + self._patch_sys_exit() + try: + from mtkclient.Library.utils import find_binary + from mtkclient.Library.mtk_crypto import ( + encode_imei, calc_checksum, make_luhn_checksum, + ) + + # Fix checksums + fixed = [] + for imei_str in imeis: + pre = imei_str[:14] + "0" + fixed.append(pre[:14] + str(make_luhn_checksum(pre))) + + nvdata = bytearray( + self.da_handler.da_read_partition(partitionname="nvdata") + ) + if not nvdata or nvdata == b"": + return False + + pos = 0 + while pos != -1: + pos = nvdata.find(b"\x4C\x44\x49\x00\x10\xEF\x0A\x00\x0A", pos + 1) + if pos != -1: + old_nvitem_data = nvdata[pos:pos + 0x180] + nvitem_data = bytearray() + x = 0 + for imei in fixed: + data = encode_imei(imei) + b"\x00\x00" + csum = calc_checksum(data, 0xA) + nvitem_data.extend(data + csum + b"\x00" * 0xE) + x += 1 + for _ in range(10 - x): + data = b"\xFF" * 0xA + csum = calc_checksum(data, 0xA) + nvitem_data.extend(data + csum + b"\x00" * 0xE) + + header = old_nvitem_data[:0x40] + result = self.mtk.daloader.nvitem( + data=header + nvitem_data, encrypt=True, + otp=self.mtk.config.get_otp(), + seed=seed, aeskey=aeskey, display=False, + ) + nvdata[pos:pos + 0x180] = header + result + + return self.da_handler.da_write_partition( + partitionname="nvdata", data=bytes(nvdata) + ) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def nvitem_crypt(self, filename: str, encrypt: bool = False, + worker=None): + """Encrypt or decrypt an NVItem file. Returns processed bytes or None.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return None + self._patch_sys_exit() + try: + with open(filename, "rb") as rf: + data = rf.read() + return self.mtk.daloader.nvitem( + data=data, encrypt=encrypt, + otp=self.mtk.config.get_otp(), + seed=b"", aeskey=b"" * 32, display=False, + ) + except OperationAborted: + return None + finally: + self._restore_sys_exit() + + # ── Exploit ─────────────────────────────────────────── + + def crash_preloader(self, mode: int = None, worker=None) -> bool: + """Crash preloader/DA to get into BROM mode. mode=None iterates all modes.""" + if self.mtk is None: + return False + self._patch_sys_exit() + try: + self.mtk.crasher(mode=mode) + return True + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def run_payload(self, filename: str, worker=None) -> bool: + """Load and run a payload binary via boot_to.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + with open(filename, "rb") as f: + data = f.read() + return self.mtk.daloader.boot_to(addr=0x200000, data=data) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def run_stage2(self, filename: str, addr: int = 0x200000, worker=None) -> bool: + """Load stage2 payload at address.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + with open(filename, "rb") as f: + data = f.read() + return self.mtk.daloader.boot_to(addr=addr, data=data) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def set_meta_mode(self, porttype: str = "off", worker=None) -> bool: + """Set meta mode (off/usb/uart).""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.setmetamode(porttype) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def shutdown(self, worker=None) -> bool: + """Shutdown device.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.shutdown() + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def reset_device(self, worker=None) -> bool: + """Reset/reboot device.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.shutdown( + bootmode=self.mtk.daloader.ShutDownModes.HOME_SCREEN + ) + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def keyserver(self, worker=None) -> bool: + """Run key server.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + self._patch_sys_exit() + try: + return self.mtk.daloader.keyserver() + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + def patch_modem(self, worker=None) -> bool: + """Patch modem partition.""" + if self.da_handler is None: + return False + self._patch_sys_exit() + try: + return self.da_handler.da_patch_modem() + except OperationAborted: + return False + finally: + self._restore_sys_exit() + + # ── Helpers ─────────────────────────────────────────── + + @staticmethod + def _format_size(size_bytes: int) -> str: + """Format bytes to human-readable string.""" + if size_bytes <= 0: + return "0 B" + units = ("B", "KB", "MB", "GB", "TB") + import math + i = int(math.floor(math.log(size_bytes, 1024))) + i = min(i, len(units) - 1) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {units[i]}" diff --git a/mtk_gui/backend/worker.py b/mtk_gui/backend/worker.py new file mode 100644 index 0000000..13f9558 --- /dev/null +++ b/mtk_gui/backend/worker.py @@ -0,0 +1,42 @@ +"""QThread-based worker for running backend operations off the GUI thread.""" +import traceback +from PySide6.QtCore import QThread, Signal + + +class OperationAborted(Exception): + """Raised when a library call hits sys.exit() (monkey-patched).""" + + +class Worker(QThread): + """Runs a callable in a background thread, emitting progress and result signals.""" + + progress = Signal(int, int) # current, total + log_message = Signal(str, int) # message, level + status_text = Signal(str) + finished = Signal(object) # result (any) + error = Signal(str) # error message + + def __init__(self, func, *args, **kwargs): + super().__init__() + self._func = func + self._args = args + self._kwargs = kwargs + self._cancelled = False + + def cancel(self): + self._cancelled = True + + @property + def is_cancelled(self): + return self._cancelled + + def run(self): + try: + result = self._func(*self._args, worker=self, **self._kwargs) + if not self._cancelled: + self.finished.emit(result) + except OperationAborted: + self.error.emit("Operation aborted by library.") + except Exception as e: + tb = traceback.format_exc() + self.error.emit(f"{e}\n{tb}") diff --git a/mtk_gui/constants.py b/mtk_gui/constants.py new file mode 100644 index 0000000..340a7c8 --- /dev/null +++ b/mtk_gui/constants.py @@ -0,0 +1,27 @@ +"""Application constants.""" + +APP_NAME = "MTK-GUI" +APP_VERSION = "0.1.0" +APP_TITLE = f"{APP_NAME} v{APP_VERSION}" +ORG_NAME = "mtk-gui" + +# MediaTek USB IDs +MTK_USB_VID = 0x0E8D +MTK_PRELOADER_PIDS = [0x0003, 0x2000, 0x2001, 0x20FF] +MTK_DA_PIDS = [0x2000, 0x2001] + +# USB polling interval (ms) +USB_POLL_INTERVAL_MS = 2000 + +# Default paths +DEFAULT_DA_LOADER = None +DEFAULT_PRELOADER = None +DEFAULT_WORK_DIR = "." + +# Partition types +PART_TYPES = ["user", "boot1", "boot2", "rpmb"] + +# Theme names +THEME_DARK = "dark" +THEME_LIGHT = "light" +DEFAULT_THEME = THEME_DARK diff --git a/mtk_gui/main_window.py b/mtk_gui/main_window.py new file mode 100644 index 0000000..c9d5109 --- /dev/null +++ b/mtk_gui/main_window.py @@ -0,0 +1,852 @@ +"""Main window — connects backend to UI.""" +import logging +import os +from PySide6.QtCore import Qt, QSettings +from PySide6.QtWidgets import ( + QMainWindow, QTabWidget, QSplitter, QWidget, QVBoxLayout, + QMessageBox, QMenu, +) + +from mtk_gui.constants import APP_TITLE, ORG_NAME, APP_NAME, THEME_DARK, THEME_LIGHT, DEFAULT_THEME +from mtk_gui.theme import load_stylesheet +from mtk_gui.backend.device_manager import DeviceManager, DeviceState +from mtk_gui.ui.widgets.log_panel import LogPanel +from mtk_gui.ui.widgets.progress_panel import ProgressPanel +from mtk_gui.ui.widgets.confirmation_dialog import ConfirmationDialog + +# Tabs +from mtk_gui.ui.tabs.device_tab import DeviceTab +from mtk_gui.ui.tabs.read_tab import ReadTab +from mtk_gui.ui.tabs.write_tab import WriteTab +from mtk_gui.ui.tabs.erase_tab import EraseTab +from mtk_gui.ui.tabs.keys_tab import KeysTab +from mtk_gui.ui.tabs.bootloader_tab import BootloaderTab +from mtk_gui.ui.tabs.memory_tab import MemoryTab +from mtk_gui.ui.tabs.rpmb_tab import RpmbTab +from mtk_gui.ui.tabs.imei_tab import ImeiTab +from mtk_gui.ui.tabs.exploit_tab import ExploitTab +from mtk_gui.ui.tabs.efuse_tab import EfuseTab +from mtk_gui.ui.tabs.server_tab import ServerTab + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle(APP_TITLE) + self.setMinimumSize(900, 650) + + self._settings = QSettings(ORG_NAME, APP_NAME) + self._current_theme = self._settings.value("theme", DEFAULT_THEME) + self._plugins = [] + + # Backend + self.device_manager = DeviceManager(self) + + self._setup_ui() + self._setup_menus() + self._connect_signals() + self._restore_geometry() + + # Start USB polling + self.device_manager.start_polling() + + def _setup_ui(self): + # Central splitter: tabs on top, log panel on bottom + splitter = QSplitter(Qt.Orientation.Vertical) + + # Tab widget + self._tabs = QTabWidget() + self._tabs.setTabPosition(QTabWidget.TabPosition.North) + + # Create all tabs + self._device_tab = DeviceTab() + self._read_tab = ReadTab() + self._write_tab = WriteTab() + self._erase_tab = EraseTab() + self._keys_tab = KeysTab() + self._bootloader_tab = BootloaderTab() + self._memory_tab = MemoryTab() + self._rpmb_tab = RpmbTab() + self._imei_tab = ImeiTab() + self._exploit_tab = ExploitTab() + self._efuse_tab = EfuseTab() + self._server_tab = ServerTab() + + self._tabs.addTab(self._device_tab, "Device") + self._tabs.addTab(self._read_tab, "Read") + self._tabs.addTab(self._write_tab, "Write") + self._tabs.addTab(self._erase_tab, "Erase") + self._tabs.addTab(self._keys_tab, "Keys") + self._tabs.addTab(self._bootloader_tab, "Bootloader") + self._tabs.addTab(self._memory_tab, "Memory") + self._tabs.addTab(self._rpmb_tab, "RPMB") + self._tabs.addTab(self._imei_tab, "IMEI") + self._tabs.addTab(self._exploit_tab, "Exploit") + self._tabs.addTab(self._efuse_tab, "eFuse") + self._tabs.addTab(self._server_tab, "Server") + + splitter.addWidget(self._tabs) + + # Bottom panel: progress + log + bottom = QWidget() + bottom_layout = QVBoxLayout(bottom) + bottom_layout.setContentsMargins(0, 0, 0, 0) + bottom_layout.setSpacing(2) + + self._progress_panel = ProgressPanel() + bottom_layout.addWidget(self._progress_panel) + + self._log_panel = LogPanel() + bottom_layout.addWidget(self._log_panel) + + splitter.addWidget(bottom) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + + self.setCentralWidget(splitter) + + # Status bar + self.statusBar().showMessage("Ready") + + def _setup_menus(self): + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("File") + file_menu.addAction("Exit", self.close) + + # View menu + view_menu = menubar.addMenu("View") + self._theme_action = view_menu.addAction("Toggle Theme", self._toggle_theme) + + # Plugins menu (populated by plugins) + self._plugins_menu = menubar.addMenu("Plugins") + self._plugins_menu.addAction("Reload Plugins", self._reload_plugins) + self._plugins_menu.addSeparator() + + # Help menu + help_menu = menubar.addMenu("Help") + help_menu.addAction("About", self._show_about) + + def _connect_signals(self): + dm = self.device_manager + + # Connection state + dm.state_changed.connect(self._on_state_changed) + dm.device_info_updated.connect(self._on_device_info_updated) + + # Logging + dm.log_message.connect(self._log_panel.append_log) + dm.progress_update.connect(self._progress_panel.update_progress) + dm.status_update.connect(self._progress_panel.set_status) + dm.status_update.connect(self.statusBar().showMessage) + + # Device tab buttons + self._device_tab.connect_button.clicked.connect(self._on_connect) + self._device_tab.disconnect_button.clicked.connect(self._on_disconnect) + + # Read tab + self._read_tab.read_button.clicked.connect(self._on_read) + + # Write tab + self._write_tab.write_button.clicked.connect(self._on_write) + + # Erase tab + self._erase_tab.erase_button.clicked.connect(self._on_erase) + + # Keys tab + self._keys_tab.generate_button.clicked.connect(self._on_generate_keys) + + # Bootloader tab + self._bootloader_tab.unlock_button.clicked.connect( + lambda: self._on_seccfg(lock=False)) + self._bootloader_tab.lock_button.clicked.connect( + lambda: self._on_seccfg(lock=True)) + self._bootloader_tab.vbmeta_button.clicked.connect(self._on_vbmeta) + + # Memory tab + self._memory_tab.peek_button.clicked.connect(self._on_peek) + self._memory_tab.poke_button.clicked.connect(self._on_poke) + self._memory_tab.brom_button.clicked.connect(self._on_dump_brom) + + # RPMB tab + self._rpmb_tab.read_button.clicked.connect(self._on_rpmb_read) + self._rpmb_tab.write_button.clicked.connect(self._on_rpmb_write) + self._rpmb_tab.erase_button.clicked.connect(self._on_rpmb_erase) + + # IMEI tab + self._imei_tab.read_button.clicked.connect(self._on_imei_read) + self._imei_tab.write_button.clicked.connect(self._on_imei_write) + self._imei_tab.patch_modem_button.clicked.connect(self._on_patch_modem) + self._imei_tab.decrypt_button.clicked.connect(self._on_nvitem_decrypt) + self._imei_tab.encrypt_button.clicked.connect(self._on_nvitem_encrypt) + + # Exploit tab + self._exploit_tab.crash_button.clicked.connect(self._on_crash) + self._exploit_tab.brute_button.clicked.connect(self._on_brute) + self._exploit_tab.payload_button.clicked.connect(self._on_payload) + self._exploit_tab.stage_button.clicked.connect(self._on_stage2) + self._exploit_tab.meta_button.clicked.connect(self._on_meta_mode) + self._exploit_tab.reset_button.clicked.connect(self._on_reset) + self._exploit_tab.shutdown_button.clicked.connect(self._on_shutdown) + + # Server tab + self._server_tab.start_button.clicked.connect(self._on_server_start) + + # eFuse tab + self._efuse_tab.read_button.clicked.connect(self._on_read_efuses) + + # ── Connection handlers ─────────────────────────────── + + def _on_connect(self): + settings = self._device_tab.get_settings() + dm = self.device_manager + dm.preloader_path = settings["preloader"] + dm.da_loader_path = settings["da_loader"] + dm.serial_port = settings["serial_port"] + dm.iot_mode = settings["iot"] + dm.stock_da = settings["stock"] + dm.work_dir = settings["work_dir"] + self._progress_panel.start("Connecting...") + dm.connect_device() + + def _on_disconnect(self): + self.device_manager.disconnect_device() + self._progress_panel.reset() + + def _on_state_changed(self, state: DeviceState): + self._device_tab.update_connection_state(state) + connected = state == DeviceState.CONNECTED + + # Enable/disable operation tabs + self._read_tab.read_button.setEnabled(connected) + self._write_tab.write_button.setEnabled(connected) + self._erase_tab.erase_button.setEnabled(connected) + self._keys_tab.generate_button.setEnabled(connected) + self._bootloader_tab.unlock_button.setEnabled(connected) + self._bootloader_tab.lock_button.setEnabled(connected) + self._bootloader_tab.vbmeta_button.setEnabled(connected) + self._memory_tab.set_enabled_all(connected) + self._rpmb_tab.set_enabled_all(connected) + self._imei_tab.set_enabled_all(connected) + self._exploit_tab.set_enabled_all(connected) + self._efuse_tab.read_button.setEnabled(connected) + self._server_tab.start_button.setEnabled(connected) + + if state == DeviceState.CONNECTED: + self._progress_panel.finish("Connected") + self._load_partitions() + elif state == DeviceState.ERROR: + self._progress_panel.finish("Connection failed") + + # Notify plugins + for plugin in self._plugins: + try: + if connected: + plugin.on_device_connected(self.device_manager.device_info.__dict__) + elif state == DeviceState.DISCONNECTED: + plugin.on_device_disconnected() + except Exception: + pass + + def _on_device_info_updated(self, info): + self._device_tab.update_device_info(info) + + def _load_partitions(self): + """Load partition list after connection (off main thread).""" + wrapper = self.device_manager.wrapper + if not wrapper: + return + w = self.device_manager.create_worker(wrapper.get_partitions) + w.finished.connect(self._on_partitions_loaded) + w.error.connect(lambda e: self._log_panel.append_log( + f"Failed to load partitions: {e}", logging.WARNING)) + w.start() + + def _on_partitions_loaded(self, partitions): + if partitions: + self._read_tab.partition_list.set_partitions(partitions) + self._write_tab.set_partitions(partitions) + self._erase_tab.partition_list.set_partitions(partitions) + + # ── Operation handlers ──────────────────────────────── + + def _on_read(self): + params = self._read_tab.get_read_params() + wrapper = self.device_manager.wrapper + if not wrapper: + return + + self._progress_panel.start("Reading...") + mode = params["mode"] + + if mode == 0: # Partitions + if not params.get("partitions"): + QMessageBox.warning(self, "No Selection", "Select at least one partition.") + self._progress_panel.reset() + return + directory = params["directory"] + if not directory: + QMessageBox.warning(self, "No Directory", "Select an output directory.") + self._progress_panel.reset() + return + if params.get("dump_gpt"): + wrapper.dump_gpt(directory) + for part_name in params["partitions"]: + filename = os.path.join(directory, f"{part_name}.bin") + w = self.device_manager.create_worker( + wrapper.read_partition, part_name, filename, params["parttype"]) + w.finished.connect(lambda r, n=part_name: self._log_panel.append_log( + f"Read {n}: {'OK' if r else 'FAILED'}", logging.INFO)) + w.error.connect(lambda e: self._on_op_error("Read", e)) + w.start() + elif mode == 1: # Full Flash + w = self.device_manager.create_worker( + wrapper.read_flash, params["filename"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Read flash", r)) + w.error.connect(lambda e: self._on_op_error("Read flash", e)) + w.start() + elif mode == 2: # By Offset + w = self.device_manager.create_worker( + wrapper.read_offset, params["offset"], params["length"], + params["filename"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Read offset", r)) + w.error.connect(lambda e: self._on_op_error("Read offset", e)) + w.start() + elif mode == 3: # By Sector + w = self.device_manager.create_worker( + wrapper.read_sectors, params["start"], params["sectors"], + params["filename"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Read sectors", r)) + w.error.connect(lambda e: self._on_op_error("Read sectors", e)) + w.start() + + def _on_write(self): + params = self._write_tab.get_write_params() + wrapper = self.device_manager.wrapper + if not wrapper: + return + + if not ConfirmationDialog.confirm("Confirm Write", "This will overwrite flash data.", self): + return + + self._progress_panel.start("Writing...") + mode = params["mode"] + + if mode == 0: # Partitions + w = self.device_manager.create_worker( + wrapper.write_partitions, params["parttype"], + params["filenames"], params["partitions"]) + w.finished.connect(lambda r: self._on_op_done("Write partitions", r)) + w.error.connect(lambda e: self._on_op_error("Write", e)) + w.start() + elif mode == 1: # Full Flash + w = self.device_manager.create_worker( + wrapper.write_flash, params["filenames"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Write flash", r)) + w.error.connect(lambda e: self._on_op_error("Write flash", e)) + w.start() + elif mode == 2: # By Offset + w = self.device_manager.create_worker( + wrapper.write_offset, params["offset"], params["length"], + params["filename"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Write offset", r)) + w.error.connect(lambda e: self._on_op_error("Write offset", e)) + w.start() + elif mode == 3: # From Directory + w = self.device_manager.create_worker( + wrapper.write_from_directory, params["parttype"], params["directory"]) + w.finished.connect(lambda r: self._on_op_done("Write from dir", r)) + w.error.connect(lambda e: self._on_op_error("Write dir", e)) + w.start() + + def _on_erase(self): + params = self._erase_tab.get_erase_params() + wrapper = self.device_manager.wrapper + if not wrapper: + return + + if not ConfirmationDialog.confirm("Confirm Erase", "This will permanently erase data.", self): + return + + self._progress_panel.start("Erasing...") + + if params["mode"] == 0: # Partitions + if not params.get("partitions"): + QMessageBox.warning(self, "No Selection", "Select at least one partition.") + self._progress_panel.reset() + return + w = self.device_manager.create_worker( + wrapper.erase_partitions, params["partitions"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Erase partitions", r)) + w.error.connect(lambda e: self._on_op_error("Erase", e)) + w.start() + else: # By Sector + w = self.device_manager.create_worker( + wrapper.erase_sectors, params["sector"], params["sectors"], params["parttype"]) + w.finished.connect(lambda r: self._on_op_done("Erase sectors", r)) + w.error.connect(lambda e: self._on_op_error("Erase sectors", e)) + w.start() + + def _on_generate_keys(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + directory = self._keys_tab.output_directory + self._progress_panel.start("Generating keys...") + w = self.device_manager.create_worker(wrapper.generate_keys, directory) + w.finished.connect(self._on_keys_done) + w.error.connect(lambda e: self._on_op_error("Keys", e)) + w.start() + + def _on_keys_done(self, result): + self._progress_panel.finish("Keys generated") + self._keys_tab.set_status("Keys saved to hwparam.json") + # Try to read and display the keys + directory = self._keys_tab.output_directory + import json + hwparam_path = os.path.join(directory, "hwparam.json") + if os.path.exists(hwparam_path): + try: + with open(hwparam_path, "r") as f: + keys = json.load(f) + self._keys_tab.set_keys(keys) + except Exception: + pass + + def _on_seccfg(self, lock: bool): + wrapper = self.device_manager.wrapper + if not wrapper: + return + action = "Lock" if lock else "Unlock" + if not ConfirmationDialog.confirm( + f"Confirm {action}", + f"This will {action.lower()} the bootloader via seccfg.", self): + return + critical = self._bootloader_tab.critical_mode + self._progress_panel.start(f"{action}ing bootloader...") + w = self.device_manager.create_worker(wrapper.seccfg_unlock, lock, critical) + w.finished.connect(lambda r: self._on_op_done(f"Bootloader {action.lower()}", r)) + w.error.connect(lambda e: self._on_op_error(action, e)) + w.start() + + def _on_vbmeta(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + if not ConfirmationDialog.confirm( + "Confirm vbmeta Patch", + "This will patch vbmeta to disable verified boot.", self): + return + mode = self._bootloader_tab.vbmeta_mode + self._progress_panel.start("Patching vbmeta...") + w = self.device_manager.create_worker(wrapper.vbmeta_patch, mode) + w.finished.connect(lambda r: self._on_op_done("vbmeta patch", r)) + w.error.connect(lambda e: self._on_op_error("vbmeta", e)) + w.start() + + def _on_peek(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + params = self._memory_tab.get_peek_params() + self._progress_panel.start("Reading memory...") + w = self.device_manager.create_worker( + wrapper.peek, params["addr"], params["length"], "", params["registers"]) + w.finished.connect(self._on_peek_done) + w.error.connect(lambda e: self._on_op_error("Peek", e)) + w.start() + + def _on_peek_done(self, data): + self._progress_panel.finish("Memory read complete") + if data and isinstance(data, (bytes, bytearray)): + params = self._memory_tab.get_peek_params() + self._memory_tab.hex_viewer.set_data(data, params["addr"]) + + def _on_poke(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + params = self._memory_tab.get_poke_params() + if not ConfirmationDialog.confirm( + "Confirm Memory Write", + f"Write to address 0x{params['addr']:X}?", self): + return + self._progress_panel.start("Writing memory...") + w = self.device_manager.create_worker( + wrapper.poke, params["addr"], params["data"]) + w.finished.connect(lambda r: self._on_op_done("Memory write", r)) + w.error.connect(lambda e: self._on_op_error("Poke", e)) + w.start() + + def _on_dump_brom(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + directory = self._memory_tab.dump_directory + filename = os.path.join(directory, "brom.bin") + self._progress_panel.start("Dumping BROM...") + w = self.device_manager.create_worker(wrapper.dump_brom, filename) + w.finished.connect(lambda r: self._on_op_done("BROM dump", r)) + w.error.connect(lambda e: self._on_op_error("BROM dump", e)) + w.start() + + def _on_rpmb_read(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + self._progress_panel.start("Reading RPMB...") + w = self._start_op(self._rpmb_tab.read_button, wrapper.read_rpmb) + w.finished.connect(lambda r: self._on_op_done("RPMB read", r)) + w.error.connect(lambda e: self._on_op_error("RPMB read", e)) + + def _on_rpmb_write(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + if not ConfirmationDialog.confirm( + "Confirm RPMB Write", "Write to RPMB?", self): + return + self._progress_panel.start("Writing RPMB...") + w = self._start_op(self._rpmb_tab.write_button, wrapper.write_rpmb) + w.finished.connect(lambda r: self._on_op_done("RPMB write", r)) + w.error.connect(lambda e: self._on_op_error("RPMB write", e)) + + def _on_read_efuses(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + self._progress_panel.start("Reading eFuses...") + w = self.device_manager.create_worker(wrapper.read_efuses) + w.finished.connect(self._on_efuses_done) + w.error.connect(lambda e: self._on_op_error("eFuse read", e)) + w.start() + + def _on_efuses_done(self, data): + self._progress_panel.finish("eFuses read") + if data and isinstance(data, list): + self._efuse_tab.set_efuses(data) + + # ── IMEI handlers ──────────────────────────────────── + + def _on_imei_read(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + params = self._imei_tab.get_imei_params() + seed = bytes.fromhex(params["seed"]) if params["seed"] else b"" + aeskey = bytes.fromhex(params["aeskey"]) if params["aeskey"] else b"\x00" * 32 + self._progress_panel.start("Reading IMEIs...") + w = self._start_op(self._imei_tab.read_button, + wrapper.read_imei, seed, aeskey) + w.finished.connect(self._on_imei_read_done) + w.error.connect(lambda e: self._on_op_error("IMEI read", e)) + + def _on_imei_read_done(self, result): + self._progress_panel.finish("IMEI read complete") + if result and isinstance(result, list): + imei1 = result[0][0] if len(result) > 0 else "" + imei2 = result[1][0] if len(result) > 1 else "" + self._imei_tab.set_imei_display(imei1, imei2) + for imei, valid in result: + state = "valid" if valid else "INVALID checksum" + self._log_panel.append_log(f"IMEI: {imei} ({state})", logging.INFO) + else: + self._log_panel.append_log("No IMEIs found on device.", logging.WARNING) + + def _on_imei_write(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + params = self._imei_tab.get_imei_params() + imeis = [i for i in [params["imei1"], params["imei2"]] if i] + if not imeis: + QMessageBox.warning(self, "No IMEI", "Enter at least one IMEI.") + return + if not ConfirmationDialog.confirm( + "Confirm IMEI Write", + "This will overwrite IMEI data on the device.", self): + return + seed = bytes.fromhex(params["seed"]) if params["seed"] else b"" + aeskey = bytes.fromhex(params["aeskey"]) if params["aeskey"] else b"\x00" * 32 + self._progress_panel.start("Writing IMEIs...") + w = self._start_op(self._imei_tab.write_button, + wrapper.write_imei, imeis, seed, aeskey, params["product"]) + w.finished.connect(lambda r: self._on_op_done("IMEI write", r)) + w.error.connect(lambda e: self._on_op_error("IMEI write", e)) + + def _on_patch_modem(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + if not ConfirmationDialog.confirm( + "Confirm Modem Patch", + "This will patch the modem partition.", self): + return + self._progress_panel.start("Patching modem...") + w = self._start_op(self._imei_tab.patch_modem_button, wrapper.patch_modem) + w.finished.connect(lambda r: self._on_op_done("Modem patch", r)) + w.error.connect(lambda e: self._on_op_error("Modem patch", e)) + + def _on_nvitem_decrypt(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + filename = self._imei_tab.nv_file + if not filename: + QMessageBox.warning(self, "No File", "Select an NV file first.") + return + self._progress_panel.start("Decrypting NVItem...") + w = self._start_op(self._imei_tab.decrypt_button, + wrapper.nvitem_crypt, filename, False) + w.finished.connect(lambda r: self._on_nvitem_done("Decrypt", r, filename)) + w.error.connect(lambda e: self._on_op_error("NVItem decrypt", e)) + + def _on_nvitem_encrypt(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + filename = self._imei_tab.nv_file + if not filename: + QMessageBox.warning(self, "No File", "Select an NV file first.") + return + self._progress_panel.start("Encrypting NVItem...") + w = self._start_op(self._imei_tab.encrypt_button, + wrapper.nvitem_crypt, filename, True) + w.finished.connect(lambda r: self._on_nvitem_done("Encrypt", r, filename)) + w.error.connect(lambda e: self._on_op_error("NVItem encrypt", e)) + + def _on_nvitem_done(self, op: str, result, filename: str): + self._progress_panel.finish(f"NVItem {op.lower()} complete") + if result: + out_path = filename + f".{op.lower()}ed" + with open(out_path, "wb") as f: + f.write(result) + self._log_panel.append_log(f"NVItem {op.lower()}ed → {out_path}", logging.INFO) + else: + self._log_panel.append_log(f"NVItem {op.lower()} failed.", logging.ERROR) + + # ── Exploit handlers ───────────────────────────────── + + def _on_crash(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + if not ConfirmationDialog.confirm( + "Confirm Crash", + "This will crash the preloader to enter BROM mode.", self): + return + crash_mode = self._exploit_tab.crash_mode + self._progress_panel.start(f"Crashing preloader (mode {crash_mode})...") + w = self._start_op(self._exploit_tab.crash_button, + wrapper.crash_preloader, crash_mode) + w.finished.connect(lambda r: self._on_op_done("Crash preloader", r)) + w.error.connect(lambda e: self._on_op_error("Crash", e)) + + def _on_brute(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + if not ConfirmationDialog.confirm( + "Confirm Brute Force", + "This will iterate all crash modes to reach BROM.", self): + return + self._progress_panel.start("Brute forcing (iterating crash modes)...") + w = self._start_op(self._exploit_tab.brute_button, + wrapper.crash_preloader) # mode=None → iterate all + w.finished.connect(lambda r: self._on_op_done("Brute force", r)) + w.error.connect(lambda e: self._on_op_error("Brute force", e)) + + def _on_payload(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + filename = self._exploit_tab.payload_file + if not filename: + QMessageBox.warning(self, "No File", "Select a payload file.") + return + self._progress_panel.start("Running payload...") + w = self._start_op(self._exploit_tab.payload_button, + wrapper.run_payload, filename) + w.finished.connect(lambda r: self._on_op_done("Payload", r)) + w.error.connect(lambda e: self._on_op_error("Payload", e)) + + def _on_stage2(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + filename = self._exploit_tab.stage_file + if not filename: + QMessageBox.warning(self, "No File", "Select a stage2 file.") + return + addr = self._exploit_tab.stage_addr + self._progress_panel.start("Running stage2...") + w = self._start_op(self._exploit_tab.stage_button, + wrapper.run_stage2, filename, addr) + w.finished.connect(lambda r: self._on_op_done("Stage2", r)) + w.error.connect(lambda e: self._on_op_error("Stage2", e)) + + def _on_meta_mode(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + mode = self._exploit_tab.meta_mode + self._progress_panel.start(f"Setting meta mode to {mode}...") + w = self._start_op(self._exploit_tab.meta_button, + wrapper.set_meta_mode, mode) + w.finished.connect(lambda r: self._on_op_done(f"Meta mode ({mode})", r)) + w.error.connect(lambda e: self._on_op_error("Meta mode", e)) + + def _on_reset(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + self._progress_panel.start("Resetting device...") + w = self._start_op(self._exploit_tab.reset_button, wrapper.reset_device) + w.finished.connect(lambda r: self._on_op_done("Reset", r)) + w.error.connect(lambda e: self._on_op_error("Reset", e)) + + def _on_shutdown(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + self._progress_panel.start("Shutting down device...") + w = self._start_op(self._exploit_tab.shutdown_button, wrapper.shutdown) + w.finished.connect(lambda r: self._on_op_done("Shutdown", r)) + w.error.connect(lambda e: self._on_op_error("Shutdown", e)) + + # ── RPMB erase handler ─────────────────────────────── + + def _on_rpmb_erase(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + if not ConfirmationDialog.confirm( + "Confirm RPMB Erase", "This will erase RPMB data.", self): + return + self._progress_panel.start("Erasing RPMB...") + w = self._start_op(self._rpmb_tab.erase_button, wrapper.erase_rpmb) + w.finished.connect(lambda r: self._on_op_done("RPMB erase", r)) + w.error.connect(lambda e: self._on_op_error("RPMB erase", e)) + + # ── Server handler ─────────────────────────────────── + + def _on_server_start(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + self._progress_panel.start("Running key server...") + self._server_tab.set_status(True, "Key exchange in progress...") + w = self._start_op(self._server_tab.start_button, wrapper.keyserver) + w.finished.connect(self._on_server_done) + w.error.connect(self._on_server_error) + + def _on_server_done(self, result): + success = bool(result) + self._server_tab.set_status(False, + "Complete" if success else "Failed") + self._progress_panel.finish( + "Key server complete" if success else "Key server failed") + self._log_panel.append_log( + "Key exchange complete." if success else "Key exchange failed.", + logging.INFO if success else logging.ERROR) + + def _on_server_error(self, error: str): + self._server_tab.set_status(False, "Error") + self._on_op_error("Key server", error) + + # ── Button-disable helper ──────────────────────────── + + def _start_op(self, button, func, *args, **kwargs): + """Create a worker, disable the triggering button, re-enable on completion.""" + button.setEnabled(False) + w = self.device_manager.create_worker(func, *args, **kwargs) + w.finished.connect(lambda _: button.setEnabled(True)) + w.error.connect(lambda _: button.setEnabled(True)) + w.start() + return w + + # ── Operation result handlers ───────────────────────── + + def _on_op_done(self, name: str, result): + success = bool(result) + status = f"{name}: {'OK' if success else 'FAILED'}" + self._progress_panel.finish(status) + self._log_panel.append_log(status, logging.INFO if success else logging.ERROR) + + def _on_op_error(self, name: str, error: str): + self._progress_panel.finish(f"{name}: ERROR") + self._log_panel.append_log(f"{name} error: {error}", logging.ERROR) + QMessageBox.warning(self, f"{name} Error", str(error)) + + # ── Theme ───────────────────────────────────────────── + + def _toggle_theme(self): + if self._current_theme == THEME_DARK: + self._current_theme = THEME_LIGHT + else: + self._current_theme = THEME_DARK + self._apply_theme() + self._settings.setValue("theme", self._current_theme) + + def apply_initial_theme(self): + self._apply_theme() + + def _apply_theme(self): + qss = load_stylesheet(self._current_theme) + self.setStyleSheet(qss) + + # ── Plugin support ──────────────────────────────────── + + def add_plugin_tab(self, widget, title: str): + self._tabs.addTab(widget, title) + + def add_plugin_menu_item(self, menu_path: str, action): + parts = menu_path.split("/") + menu = self._plugins_menu + for part in parts[:-1]: + found = None + for a in menu.actions(): + if a.menu() and a.text() == part: + found = a.menu() + break + if found: + menu = found + else: + menu = menu.addMenu(part) + menu.addAction(parts[-1], action) + + def set_plugins(self, plugins: list): + self._plugins = plugins + + def _reload_plugins(self): + self._log_panel.append_log("Plugin reload requires restart.", logging.WARNING) + + # ── Window state ────────────────────────────────────── + + def _restore_geometry(self): + geo = self._settings.value("geometry") + if geo: + self.restoreGeometry(geo) + state = self._settings.value("windowState") + if state: + self.restoreState(state) + + def closeEvent(self, event): + self._settings.setValue("geometry", self.saveGeometry()) + self._settings.setValue("windowState", self.saveState()) + self.device_manager.stop_polling() + self.device_manager.disconnect_device() + for plugin in self._plugins: + try: + plugin.cleanup() + except Exception: + pass + super().closeEvent(event) + + def _show_about(self): + QMessageBox.about(self, "About MTK-GUI", + f"{APP_TITLE}\n\n" + "Standalone GUI for mtkclient.\n" + "License: GPLv3\n\n" + "Built with PySide6.") diff --git a/mtk_gui/plugins/__init__.py b/mtk_gui/plugins/__init__.py new file mode 100644 index 0000000..0f5593d --- /dev/null +++ b/mtk_gui/plugins/__init__.py @@ -0,0 +1 @@ +"""Plugin system.""" diff --git a/mtk_gui/plugins/base_plugin.py b/mtk_gui/plugins/base_plugin.py new file mode 100644 index 0000000..9ed0d52 --- /dev/null +++ b/mtk_gui/plugins/base_plugin.py @@ -0,0 +1,74 @@ +"""Abstract base class for MTK-GUI plugins.""" +from abc import ABC, abstractmethod + + +class PluginContext: + """Provides plugin access to the application.""" + + def __init__(self, app): + self._app = app + + def add_tab(self, widget, title: str, icon=None): + """Add a tab to the main window's tab widget.""" + self._app.main_window.add_plugin_tab(widget, title) + + def add_menu_item(self, menu_path: str, action): + """Add a menu item. menu_path: 'Plugins/My Action'.""" + self._app.main_window.add_plugin_menu_item(menu_path, action) + + def get_backend(self): + """Get the DeviceManager instance.""" + return self._app.main_window.device_manager + + def get_device_info(self) -> dict: + """Get current device info as a dict.""" + dm = self.get_backend() + if dm and dm.device_info: + info = dm.device_info + return { + "chipset": info.chipset, + "hwcode": info.hwcode, + "flash_type": info.flash_type, + "flash_size": info.flash_size, + "meid": info.meid, + "socid": info.socid, + } + return {} + + def log(self, message: str, level: int = 20): + """Log a message (level: 10=DEBUG, 20=INFO, 30=WARNING, 40=ERROR).""" + dm = self.get_backend() + if dm: + dm.log_message.emit(message, level) + + def create_worker(self, func, *args): + """Create a tracked Worker thread.""" + from mtk_gui.backend.worker import Worker + dm = self.get_backend() + return dm.create_worker(func, *args) + + +class MtkPlugin(ABC): + """Base class for MTK-GUI plugins.""" + + name: str = "Unnamed Plugin" + version: str = "0.1.0" + author: str = "Unknown" + description: str = "" + + @abstractmethod + def register(self, ctx: PluginContext) -> None: + """Called when the plugin is loaded. Use ctx to add tabs, menus, etc.""" + ... + + def on_device_connected(self, device_info: dict) -> None: + """Called when a device connects.""" + pass + + def on_device_disconnected(self) -> None: + """Called when a device disconnects.""" + pass + + def cleanup(self) -> None: + """Called when the application exits.""" + pass diff --git a/mtk_gui/plugins/loader.py b/mtk_gui/plugins/loader.py new file mode 100644 index 0000000..a2062c9 --- /dev/null +++ b/mtk_gui/plugins/loader.py @@ -0,0 +1,62 @@ +"""Plugin discovery and loading.""" +import importlib.util +import inspect +import logging +import os +import sys + +from mtk_gui.plugins.base_plugin import MtkPlugin, PluginContext + +logger = logging.getLogger(__name__) + + +def discover_plugins(search_dirs: list) -> list: + """Find all .py files containing MtkPlugin subclasses in search_dirs.""" + plugins = [] + + for directory in search_dirs: + if not os.path.isdir(directory): + continue + + for filename in os.listdir(directory): + if not filename.endswith(".py") or filename.startswith("_"): + continue + + filepath = os.path.join(directory, filename) + module_name = f"mtk_plugin_{filename[:-3]}" + + try: + spec = importlib.util.spec_from_file_location(module_name, filepath) + if spec is None or spec.loader is None: + continue + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, MtkPlugin) and obj is not MtkPlugin: + plugins.append(obj()) + logger.info(f"Discovered plugin: {obj.name} v{obj.version}") + + except Exception as e: + logger.error(f"Failed to load plugin {filename}: {e}") + + return plugins + + +def load_plugins(app, search_dirs: list) -> list: + """Discover, instantiate, and register all plugins.""" + plugins = discover_plugins(search_dirs) + ctx = PluginContext(app) + + loaded = [] + for plugin in plugins: + try: + plugin.register(ctx) + loaded.append(plugin) + logger.info(f"Registered plugin: {plugin.name}") + except Exception as e: + logger.error(f"Failed to register plugin {plugin.name}: {e}") + + return loaded diff --git a/mtk_gui/resources/__init__.py b/mtk_gui/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mtk_gui/theme/__init__.py b/mtk_gui/theme/__init__.py new file mode 100644 index 0000000..69fc0f0 --- /dev/null +++ b/mtk_gui/theme/__init__.py @@ -0,0 +1,12 @@ +"""Theme management.""" +from mtk_gui.theme.colors import Colors + + +def load_stylesheet(theme_name: str) -> str: + """Load a QSS stylesheet by theme name.""" + import os + qss_path = os.path.join(os.path.dirname(__file__), f"{theme_name}.qss") + if os.path.isfile(qss_path): + with open(qss_path, "r") as f: + return f.read() + return "" diff --git a/mtk_gui/theme/colors.py b/mtk_gui/theme/colors.py new file mode 100644 index 0000000..b0c832e --- /dev/null +++ b/mtk_gui/theme/colors.py @@ -0,0 +1,34 @@ +"""Color constants for theming.""" + + +class Colors: + # Dark theme + BG_PRIMARY = "#1e1e1e" + BG_SECONDARY = "#2b2b2b" + BG_TERTIARY = "#3c3f41" + BG_HOVER = "#4c5052" + + ACCENT = "#4a86c8" + ACCENT_HOVER = "#5a96d8" + ACCENT_PRESSED = "#3a76b8" + + TEXT_PRIMARY = "#ffffff" + TEXT_SECONDARY = "#bbbbbb" + TEXT_DIM = "#888888" + TEXT_MUTED = "#dddddd" + + BORDER = "#555555" + BORDER_DARK = "#3a3a3a" + + ERROR = "#cc4444" + SUCCESS = "#44aa44" + WARNING = "#cc8844" + + SCROLLBAR = "#606060" + SCROLLBAR_HOVER = "#808080" + + # Status LED + LED_CONNECTED = "#44aa44" + LED_CONNECTING = "#cc8844" + LED_DISCONNECTED = "#cc4444" + LED_DETECTING = "#4a86c8" diff --git a/mtk_gui/theme/dark.qss b/mtk_gui/theme/dark.qss new file mode 100644 index 0000000..62a2ee2 --- /dev/null +++ b/mtk_gui/theme/dark.qss @@ -0,0 +1,349 @@ +/* ── MTK-GUI Dark Theme ──────────────────────────────────── */ + +/* ── Base ─────────────────────────────────────────────────── */ +QMainWindow, QDialog, QWidget { + background-color: #2b2b2b; + color: #ffffff; + font-size: 13px; +} + +/* ── Menu bar ─────────────────────────────────────────────── */ +QMenuBar { + background-color: #3c3f41; + color: #bbbbbb; + border-bottom: 1px solid #555555; + padding: 2px; +} +QMenuBar::item { + padding: 4px 8px; + border-radius: 2px; +} +QMenuBar::item:selected { + background-color: #4c5052; +} +QMenu { + background-color: #3c3f41; + color: #bbbbbb; + border: 1px solid #555555; +} +QMenu::item { + padding: 4px 20px; +} +QMenu::item:selected { + background-color: #4a86c8; + color: #ffffff; +} +QMenu::separator { + height: 1px; + background-color: #555555; + margin: 4px 8px; +} + +/* ── Status bar ───────────────────────────────────────────── */ +QStatusBar { + background-color: #3c3f41; + color: #bbbbbb; + border-top: 1px solid #555555; +} +QStatusBar::item { + border: none; +} + +/* ── Tab widget ───────────────────────────────────────────── */ +QTabWidget::pane { + background-color: #2b2b2b; + border: 1px solid #555555; + border-top: none; +} +QTabBar::tab { + background-color: #3c3f41; + color: #bbbbbb; + padding: 6px 16px; + border: 1px solid #555555; + border-bottom: none; + margin-right: 1px; +} +QTabBar::tab:selected { + background-color: #2b2b2b; + color: #ffffff; + border-bottom: 2px solid #4a86c8; +} +QTabBar::tab:hover:!selected { + background-color: #4c5052; +} +QTabBar::tab:disabled { + color: #666666; +} + +/* ── Buttons ──────────────────────────────────────────────── */ +QPushButton { + background-color: #4a86c8; + color: #ffffff; + border: 1px solid #3a76b8; + border-radius: 3px; + padding: 5px 14px; + min-height: 20px; +} +QPushButton:hover { + background-color: #5a96d8; +} +QPushButton:pressed { + background-color: #3a76b8; +} +QPushButton:disabled { + background-color: #4a4a4a; + color: #888888; + border: 1px solid #3a3a3a; +} +QPushButton[destructive="true"] { + background-color: #cc4444; + border-color: #aa3333; +} +QPushButton[destructive="true"]:hover { + background-color: #dd5555; +} +QPushButton[success="true"] { + background-color: #44aa44; + border-color: #339933; +} + +/* ── Inputs ───────────────────────────────────────────────── */ +QLineEdit, QSpinBox, QDoubleSpinBox { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #555555; + border-radius: 2px; + padding: 4px 6px; + selection-background-color: #4a86c8; +} +QLineEdit:focus, QSpinBox:focus { + border-color: #4a86c8; +} +QLineEdit:disabled, QSpinBox:disabled { + background-color: #353535; + color: #666666; +} + +QTextEdit, QPlainTextEdit { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #555555; + selection-background-color: #4a86c8; +} + +/* ── Combo box ────────────────────────────────────────────── */ +QComboBox { + background-color: #3c3f41; + color: #dddddd; + border: 1px solid #555555; + border-radius: 2px; + padding: 4px 8px; + min-height: 20px; +} +QComboBox:hover { + border-color: #4a86c8; +} +QComboBox::drop-down { + border: none; + width: 20px; +} +QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 6px solid #bbbbbb; + margin-right: 6px; +} +QComboBox QAbstractItemView { + background-color: #3c3f41; + color: #dddddd; + border: 1px solid #555555; + selection-background-color: #4a86c8; +} + +/* ── Labels ───────────────────────────────────────────────── */ +QLabel { + color: #dddddd; + background-color: transparent; +} +QLabel[heading="true"] { + font-size: 15px; + font-weight: bold; + color: #ffffff; +} + +/* ── Group box ────────────────────────────────────────────── */ +QGroupBox { + border: 1px solid #555555; + border-radius: 4px; + margin-top: 8px; + padding-top: 8px; + font-weight: bold; + color: #dddddd; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 4px; + color: #4a86c8; +} + +/* ── Checkboxes ───────────────────────────────────────────── */ +QCheckBox { + color: #dddddd; + background-color: transparent; + spacing: 6px; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + background-color: #5a5a5a; + border: 1px solid #909090; + border-radius: 2px; +} +QCheckBox::indicator:checked { + background-color: #4a86c8; + border: 1px solid #3a76b8; +} +QCheckBox::indicator:hover { + border-color: #4a86c8; +} +QCheckBox::indicator:disabled { + background-color: #3a3a3a; + border: 1px solid #555555; +} + +/* ── Radio buttons ────────────────────────────────────────── */ +QRadioButton { + color: #dddddd; + background-color: transparent; + spacing: 6px; +} +QRadioButton::indicator { + width: 16px; + height: 16px; + background-color: #5a5a5a; + border: 1px solid #909090; + border-radius: 8px; +} +QRadioButton::indicator:checked { + background-color: #4a86c8; + border: 2px solid #3a76b8; +} + +/* ── Progress bars ────────────────────────────────────────── */ +QProgressBar { + background-color: #3c3f41; + color: #ffffff; + border: 1px solid #555555; + border-radius: 3px; + text-align: center; + min-height: 18px; +} +QProgressBar::chunk { + background-color: #4a86c8; + border-radius: 2px; +} + +/* ── Scroll bars ──────────────────────────────────────────── */ +QScrollBar:vertical { + background-color: #2b2b2b; + width: 10px; + margin: 0; +} +QScrollBar::handle:vertical { + background-color: #606060; + min-height: 20px; + border-radius: 4px; + margin: 2px; +} +QScrollBar::handle:vertical:hover { + background-color: #808080; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; +} +QScrollBar:horizontal { + background-color: #2b2b2b; + height: 10px; + margin: 0; +} +QScrollBar::handle:horizontal { + background-color: #606060; + min-width: 20px; + border-radius: 4px; + margin: 2px; +} +QScrollBar::handle:horizontal:hover { + background-color: #808080; +} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; +} +QScrollBar::add-page, QScrollBar::sub-page { + background: none; +} + +/* ── Scroll areas ─────────────────────────────────────────── */ +QScrollArea { + background-color: #2b2b2b; + border: none; +} + +/* ── Tables ───────────────────────────────────────────────── */ +QTableWidget, QTableView { + background-color: #1e1e1e; + color: #dddddd; + gridline-color: #3c3f41; + border: 1px solid #555555; + selection-background-color: #4a86c8; + alternate-background-color: #252525; +} +QHeaderView::section { + background-color: #3c3f41; + color: #bbbbbb; + border: none; + border-right: 1px solid #555555; + border-bottom: 1px solid #555555; + padding: 4px 8px; + font-weight: bold; +} + +/* ── List widgets ─────────────────────────────────────────── */ +QListWidget { + background-color: #1e1e1e; + color: #dddddd; + border: 1px solid #555555; + selection-background-color: #4a86c8; +} +QListWidget::item { + padding: 2px 4px; +} +QListWidget::item:hover { + background-color: #3c3f41; +} + +/* ── Splitter ─────────────────────────────────────────────── */ +QSplitter::handle { + background-color: #555555; +} +QSplitter::handle:horizontal { + width: 2px; +} +QSplitter::handle:vertical { + height: 2px; +} + +/* ── Separator frames ─────────────────────────────────────── */ +QFrame[frameShape="4"], +QFrame[frameShape="5"] { + color: #555555; +} + +/* ── Tool tips ────────────────────────────────────────────── */ +QToolTip { + background-color: #3c3f41; + color: #dddddd; + border: 1px solid #555555; + padding: 4px; +} diff --git a/mtk_gui/theme/light.qss b/mtk_gui/theme/light.qss new file mode 100644 index 0000000..4fa9ae4 --- /dev/null +++ b/mtk_gui/theme/light.qss @@ -0,0 +1,317 @@ +/* ── MTK-GUI Light Theme ─────────────────────────────────── */ + +/* ── Base ─────────────────────────────────────────────────── */ +QMainWindow, QDialog, QWidget { + background-color: #f5f5f5; + color: #333333; + font-size: 13px; +} + +/* ── Menu bar ─────────────────────────────────────────────── */ +QMenuBar { + background-color: #e8e8e8; + color: #333333; + border-bottom: 1px solid #cccccc; + padding: 2px; +} +QMenuBar::item { + padding: 4px 8px; + border-radius: 2px; +} +QMenuBar::item:selected { + background-color: #d0d0d0; +} +QMenu { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; +} +QMenu::item { + padding: 4px 20px; +} +QMenu::item:selected { + background-color: #4a86c8; + color: #ffffff; +} +QMenu::separator { + height: 1px; + background-color: #cccccc; + margin: 4px 8px; +} + +/* ── Status bar ───────────────────────────────────────────── */ +QStatusBar { + background-color: #e8e8e8; + color: #555555; + border-top: 1px solid #cccccc; +} +QStatusBar::item { + border: none; +} + +/* ── Tab widget ───────────────────────────────────────────── */ +QTabWidget::pane { + background-color: #f5f5f5; + border: 1px solid #cccccc; + border-top: none; +} +QTabBar::tab { + background-color: #e8e8e8; + color: #555555; + padding: 6px 16px; + border: 1px solid #cccccc; + border-bottom: none; + margin-right: 1px; +} +QTabBar::tab:selected { + background-color: #f5f5f5; + color: #333333; + border-bottom: 2px solid #4a86c8; +} +QTabBar::tab:hover:!selected { + background-color: #d8d8d8; +} + +/* ── Buttons ──────────────────────────────────────────────── */ +QPushButton { + background-color: #4a86c8; + color: #ffffff; + border: 1px solid #3a76b8; + border-radius: 3px; + padding: 5px 14px; + min-height: 20px; +} +QPushButton:hover { + background-color: #5a96d8; +} +QPushButton:pressed { + background-color: #3a76b8; +} +QPushButton:disabled { + background-color: #b0c4de; + color: #ffffff; + border: 1px solid #9ab4ce; +} +QPushButton[destructive="true"] { + background-color: #cc4444; + border-color: #aa3333; +} +QPushButton[destructive="true"]:hover { + background-color: #dd5555; +} + +/* ── Inputs ───────────────────────────────────────────────── */ +QLineEdit, QSpinBox, QDoubleSpinBox { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + border-radius: 2px; + padding: 4px 6px; + selection-background-color: #4a86c8; +} +QLineEdit:focus, QSpinBox:focus { + border-color: #4a86c8; +} + +QTextEdit, QPlainTextEdit { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + selection-background-color: #4a86c8; +} + +/* ── Combo box ────────────────────────────────────────────── */ +QComboBox { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + border-radius: 2px; + padding: 4px 8px; + min-height: 20px; +} +QComboBox:hover { + border-color: #4a86c8; +} +QComboBox::drop-down { + border: none; + width: 20px; +} +QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 6px solid #555555; + margin-right: 6px; +} +QComboBox QAbstractItemView { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + selection-background-color: #4a86c8; +} + +/* ── Labels ───────────────────────────────────────────────── */ +QLabel { + color: #333333; + background-color: transparent; +} +QLabel[heading="true"] { + font-size: 15px; + font-weight: bold; + color: #222222; +} + +/* ── Group box ────────────────────────────────────────────── */ +QGroupBox { + border: 1px solid #cccccc; + border-radius: 4px; + margin-top: 8px; + padding-top: 8px; + font-weight: bold; + color: #333333; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 4px; + color: #4a86c8; +} + +/* ── Checkboxes ───────────────────────────────────────────── */ +QCheckBox { + color: #333333; + background-color: transparent; + spacing: 6px; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + background-color: #ffffff; + border: 1px solid #cccccc; + border-radius: 2px; +} +QCheckBox::indicator:checked { + background-color: #4a86c8; + border: 1px solid #3a76b8; +} + +/* ── Radio buttons ────────────────────────────────────────── */ +QRadioButton { + color: #333333; + background-color: transparent; + spacing: 6px; +} +QRadioButton::indicator { + width: 16px; + height: 16px; + background-color: #ffffff; + border: 1px solid #cccccc; + border-radius: 8px; +} +QRadioButton::indicator:checked { + background-color: #4a86c8; + border: 2px solid #3a76b8; +} + +/* ── Progress bars ────────────────────────────────────────── */ +QProgressBar { + background-color: #e0e0e0; + color: #333333; + border: 1px solid #cccccc; + border-radius: 3px; + text-align: center; + min-height: 18px; +} +QProgressBar::chunk { + background-color: #4a86c8; + border-radius: 2px; +} + +/* ── Scroll bars ──────────────────────────────────────────── */ +QScrollBar:vertical { + background-color: #f0f0f0; + width: 10px; +} +QScrollBar::handle:vertical { + background-color: #c0c0c0; + min-height: 20px; + border-radius: 4px; + margin: 2px; +} +QScrollBar::handle:vertical:hover { + background-color: #a0a0a0; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; +} +QScrollBar:horizontal { + background-color: #f0f0f0; + height: 10px; +} +QScrollBar::handle:horizontal { + background-color: #c0c0c0; + min-width: 20px; + border-radius: 4px; + margin: 2px; +} +QScrollBar::handle:horizontal:hover { + background-color: #a0a0a0; +} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; +} +QScrollBar::add-page, QScrollBar::sub-page { + background: none; +} + +/* ── Tables ───────────────────────────────────────────────── */ +QTableWidget, QTableView { + background-color: #ffffff; + color: #333333; + gridline-color: #e0e0e0; + border: 1px solid #cccccc; + selection-background-color: #4a86c8; + alternate-background-color: #f8f8f8; +} +QHeaderView::section { + background-color: #e8e8e8; + color: #333333; + border: none; + border-right: 1px solid #cccccc; + border-bottom: 1px solid #cccccc; + padding: 4px 8px; + font-weight: bold; +} + +/* ── List widgets ─────────────────────────────────────────── */ +QListWidget { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + selection-background-color: #4a86c8; +} +QListWidget::item { + padding: 2px 4px; +} +QListWidget::item:hover { + background-color: #e8e8e8; +} + +/* ── Splitter ─────────────────────────────────────────────── */ +QSplitter::handle { + background-color: #cccccc; +} +QSplitter::handle:horizontal { + width: 2px; +} +QSplitter::handle:vertical { + height: 2px; +} + +/* ── Tool tips ────────────────────────────────────────────── */ +QToolTip { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + padding: 4px; +} diff --git a/mtk_gui/ui/__init__.py b/mtk_gui/ui/__init__.py new file mode 100644 index 0000000..462088d --- /dev/null +++ b/mtk_gui/ui/__init__.py @@ -0,0 +1 @@ +"""UI layer — PySide6 widgets. No direct mtkclient imports.""" diff --git a/mtk_gui/ui/tabs/__init__.py b/mtk_gui/ui/tabs/__init__.py new file mode 100644 index 0000000..31f1915 --- /dev/null +++ b/mtk_gui/ui/tabs/__init__.py @@ -0,0 +1 @@ +"""Tab widgets for the main window.""" diff --git a/mtk_gui/ui/tabs/bootloader_tab.py b/mtk_gui/ui/tabs/bootloader_tab.py new file mode 100644 index 0000000..6687f43 --- /dev/null +++ b/mtk_gui/ui/tabs/bootloader_tab.py @@ -0,0 +1,81 @@ +"""Bootloader tab — seccfg lock/unlock, vbmeta patch.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QPushButton, QCheckBox, QComboBox, +) + + +class BootloaderTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(12) + + # ── Bootloader unlock/lock ──────────────────────── + bl_group = QGroupBox("Bootloader Lock State") + bl_layout = QVBoxLayout(bl_group) + + self._critical_check = QCheckBox("Critical mode (use with caution)") + bl_layout.addWidget(self._critical_check) + + btn_row = QHBoxLayout() + self._unlock_btn = QPushButton("Unlock Bootloader") + self._unlock_btn.setProperty("destructive", True) + self._unlock_btn.setEnabled(False) + btn_row.addWidget(self._unlock_btn) + + self._lock_btn = QPushButton("Lock Bootloader") + self._lock_btn.setProperty("destructive", True) + self._lock_btn.setEnabled(False) + btn_row.addWidget(self._lock_btn) + + btn_row.addStretch() + bl_layout.addLayout(btn_row) + layout.addWidget(bl_group) + + # ── vbmeta patch ────────────────────────────────── + vb_group = QGroupBox("vbmeta Patch") + vb_layout = QVBoxLayout(vb_group) + + mode_row = QHBoxLayout() + mode_row.addWidget(QLabel("Patch Mode:")) + self._vbmeta_mode = QComboBox() + self._vbmeta_mode.addItem("Disable verity + verify", 3) + self._vbmeta_mode.addItem("Disable verity only", 1) + self._vbmeta_mode.addItem("Disable verify only", 2) + mode_row.addWidget(self._vbmeta_mode, 1) + vb_layout.addLayout(mode_row) + + vb_btn_row = QHBoxLayout() + self._vbmeta_btn = QPushButton("Patch vbmeta") + self._vbmeta_btn.setProperty("destructive", True) + self._vbmeta_btn.setEnabled(False) + vb_btn_row.addWidget(self._vbmeta_btn) + vb_btn_row.addStretch() + vb_layout.addLayout(vb_btn_row) + + layout.addWidget(vb_group) + layout.addStretch() + + @property + def unlock_button(self) -> QPushButton: + return self._unlock_btn + + @property + def lock_button(self) -> QPushButton: + return self._lock_btn + + @property + def vbmeta_button(self) -> QPushButton: + return self._vbmeta_btn + + @property + def critical_mode(self) -> bool: + return self._critical_check.isChecked() + + @property + def vbmeta_mode(self) -> int: + return self._vbmeta_mode.currentData() diff --git a/mtk_gui/ui/tabs/device_tab.py b/mtk_gui/ui/tabs/device_tab.py new file mode 100644 index 0000000..87b7cbb --- /dev/null +++ b/mtk_gui/ui/tabs/device_tab.py @@ -0,0 +1,218 @@ +"""Device connection and info tab (landing page).""" +import logging +from PySide6.QtCore import Qt, Slot +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, + QLabel, QLineEdit, QPushButton, QCheckBox, QComboBox, QFileDialog, +) + +from mtk_gui.theme.colors import Colors + + +class StatusLed(QLabel): + """Colored status indicator dot.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(16, 16) + self.set_color(Colors.LED_DISCONNECTED) + + def set_color(self, color: str): + self.setStyleSheet( + f"background-color: {color}; border-radius: 8px; border: 1px solid #555;" + ) + + +class DeviceTab(QWidget): + """Device connection settings and info display.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(12) + + # ── Status section ──────────────────────────────── + status_group = QGroupBox("Connection Status") + status_layout = QHBoxLayout(status_group) + + self._status_led = StatusLed() + status_layout.addWidget(self._status_led) + + self._status_label = QLabel("Disconnected") + self._status_label.setProperty("heading", True) + status_layout.addWidget(self._status_label) + status_layout.addStretch() + + self._connect_btn = QPushButton("Connect") + self._connect_btn.setFixedWidth(100) + status_layout.addWidget(self._connect_btn) + + self._disconnect_btn = QPushButton("Disconnect") + self._disconnect_btn.setFixedWidth(100) + self._disconnect_btn.setEnabled(False) + status_layout.addWidget(self._disconnect_btn) + + layout.addWidget(status_group) + + # ── Device info section ─────────────────────────── + info_group = QGroupBox("Device Information") + info_grid = QGridLayout(info_group) + info_grid.setColumnStretch(1, 1) + info_grid.setColumnStretch(3, 1) + + self._info_fields = {} + fields = [ + ("Chipset:", "chipset", 0, 0), + ("Boot Mode:", "boot_mode", 0, 2), + ("HW Code:", "hwcode", 1, 0), + ("HW Ver:", "hwver", 1, 2), + ("SW Ver:", "swver", 2, 0), + ("DA Mode:", "da_mode", 2, 2), + ("Flash Type:", "flash_type", 3, 0), + ("Flash Size:", "flash_size", 3, 2), + ("MEID:", "meid", 4, 0), + ("SoC ID:", "socid", 5, 0), + ] + + for label_text, key, row, col in fields: + label = QLabel(label_text) + label.setStyleSheet("font-weight: bold;") + info_grid.addWidget(label, row, col) + + value = QLineEdit() + value.setReadOnly(True) + value.setPlaceholderText("—") + colspan = 3 if col == 0 and key in ("meid", "socid") else 1 + info_grid.addWidget(value, row, col + 1, 1, colspan) + self._info_fields[key] = value + + layout.addWidget(info_group) + + # ── Connection settings section ─────────────────── + settings_group = QGroupBox("Connection Settings") + settings_grid = QGridLayout(settings_group) + settings_grid.setColumnStretch(1, 1) + + # DA loader path + settings_grid.addWidget(QLabel("DA Loader:"), 0, 0) + self._da_loader_input = QLineEdit() + self._da_loader_input.setPlaceholderText("(auto-detect)") + settings_grid.addWidget(self._da_loader_input, 0, 1) + da_browse = QPushButton("Browse") + da_browse.setFixedWidth(70) + da_browse.clicked.connect(lambda: self._browse_file(self._da_loader_input)) + settings_grid.addWidget(da_browse, 0, 2) + + # Preloader path + settings_grid.addWidget(QLabel("Preloader:"), 1, 0) + self._preloader_input = QLineEdit() + self._preloader_input.setPlaceholderText("(optional)") + settings_grid.addWidget(self._preloader_input, 1, 1) + pl_browse = QPushButton("Browse") + pl_browse.setFixedWidth(70) + pl_browse.clicked.connect(lambda: self._browse_file(self._preloader_input)) + settings_grid.addWidget(pl_browse, 1, 2) + + # Serial port + settings_grid.addWidget(QLabel("Serial Port:"), 2, 0) + self._serial_combo = QComboBox() + self._serial_combo.addItem("(auto-detect)", None) + self._serial_combo.setEditable(True) + settings_grid.addWidget(self._serial_combo, 2, 1, 1, 2) + + # Working directory + settings_grid.addWidget(QLabel("Working Dir:"), 3, 0) + self._workdir_input = QLineEdit(".") + settings_grid.addWidget(self._workdir_input, 3, 1) + wd_browse = QPushButton("Browse") + wd_browse.setFixedWidth(70) + wd_browse.clicked.connect(self._browse_workdir) + settings_grid.addWidget(wd_browse, 3, 2) + + # Checkboxes + cb_row = QHBoxLayout() + self._iot_check = QCheckBox("IoT Mode") + cb_row.addWidget(self._iot_check) + self._stock_check = QCheckBox("Stock DA") + cb_row.addWidget(self._stock_check) + cb_row.addStretch() + settings_grid.addLayout(cb_row, 4, 0, 1, 3) + + layout.addWidget(settings_group) + layout.addStretch() + + def _browse_file(self, target: QLineEdit): + path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Binary (*.bin);;All (*)") + if path: + target.setText(path) + + def _browse_workdir(self): + d = QFileDialog.getExistingDirectory(self, "Select Working Directory") + if d: + self._workdir_input.setText(d) + + # ── Public API ──────────────────────────────────────── + + @property + def connect_button(self) -> QPushButton: + return self._connect_btn + + @property + def disconnect_button(self) -> QPushButton: + return self._disconnect_btn + + def get_settings(self) -> dict: + """Return current connection settings.""" + serial = self._serial_combo.currentData() + if serial is None and self._serial_combo.currentText() != "(auto-detect)": + serial = self._serial_combo.currentText() + return { + "da_loader": self._da_loader_input.text().strip() or None, + "preloader": self._preloader_input.text().strip() or None, + "serial_port": serial, + "work_dir": self._workdir_input.text().strip() or ".", + "iot": self._iot_check.isChecked(), + "stock": self._stock_check.isChecked(), + } + + @Slot(object) + def update_device_info(self, info): + """Update device info fields from DeviceInfo object.""" + mapping = { + "chipset": info.chipset, + "hwcode": info.hwcode, + "hwver": info.hwver, + "swver": info.swver, + "boot_mode": info.boot_mode, + "da_mode": info.da_mode, + "flash_type": info.flash_type, + "flash_size": info.flash_size, + "meid": info.meid, + "socid": info.socid, + } + for key, value in mapping.items(): + if key in self._info_fields: + self._info_fields[key].setText(str(value) if value else "") + + @Slot(object) + def update_connection_state(self, state): + """Update status LED and label based on DeviceState.""" + from mtk_gui.backend.device_manager import DeviceState + state_map = { + DeviceState.DISCONNECTED: ("Disconnected", Colors.LED_DISCONNECTED), + DeviceState.DETECTING: ("Detecting...", Colors.LED_DETECTING), + DeviceState.PRELOADER: ("Preloader detected", Colors.LED_CONNECTING), + DeviceState.CONNECTING_DA: ("Connecting DA...", Colors.LED_CONNECTING), + DeviceState.CONNECTED: ("Connected", Colors.LED_CONNECTED), + DeviceState.ERROR: ("Error", Colors.LED_DISCONNECTED), + } + label, color = state_map.get(state, ("Unknown", Colors.LED_DISCONNECTED)) + self._status_label.setText(label) + self._status_led.set_color(color) + + connected = state == DeviceState.CONNECTED + self._connect_btn.setEnabled(not connected) + self._disconnect_btn.setEnabled(connected) diff --git a/mtk_gui/ui/tabs/efuse_tab.py b/mtk_gui/ui/tabs/efuse_tab.py new file mode 100644 index 0000000..cf81808 --- /dev/null +++ b/mtk_gui/ui/tabs/efuse_tab.py @@ -0,0 +1,45 @@ +"""eFuse tab — read eFuse values.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QTableWidget, QTableWidgetItem, QHeaderView, +) + + +class EfuseTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + btn_row = QHBoxLayout() + self._read_btn = QPushButton("Read eFuses") + self._read_btn.setFixedWidth(140) + self._read_btn.setEnabled(False) + btn_row.addWidget(self._read_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + self._table = QTableWidget(0, 3) + self._table.setHorizontalHeaderLabels(["Index", "Address", "Value"]) + self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._table.setAlternatingRowColors(True) + header = self._table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + layout.addWidget(self._table, 1) + + @property + def read_button(self) -> QPushButton: + return self._read_btn + + def set_efuses(self, efuse_data: list): + """Set eFuse table. Each item: (index, address, value).""" + self._table.setRowCount(len(efuse_data)) + for row, (idx, addr, val) in enumerate(efuse_data): + self._table.setItem(row, 0, QTableWidgetItem(str(idx))) + self._table.setItem(row, 1, QTableWidgetItem(f"0x{addr:08X}" if isinstance(addr, int) else str(addr))) + self._table.setItem(row, 2, QTableWidgetItem(f"0x{val:08X}" if isinstance(val, int) else str(val))) diff --git a/mtk_gui/ui/tabs/erase_tab.py b/mtk_gui/ui/tabs/erase_tab.py new file mode 100644 index 0000000..008b88e --- /dev/null +++ b/mtk_gui/ui/tabs/erase_tab.py @@ -0,0 +1,105 @@ +"""Erase tab — partition/sector erase operations.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QComboBox, QLineEdit, QPushButton, + QStackedWidget, QRadioButton, QButtonGroup, +) + +from mtk_gui.ui.widgets.partition_list import PartitionList +from mtk_gui.constants import PART_TYPES + + +class EraseTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # Warning + warn = QLabel("WARNING: Erasing partitions is irreversible. Data will be permanently lost.") + warn.setStyleSheet( + "background-color: #440000; color: #ff4444; padding: 6px; " + "border: 1px solid #cc4444; border-radius: 3px; font-weight: bold;" + ) + warn.setWordWrap(True) + layout.addWidget(warn) + + # Mode selector + mode_group = QGroupBox("Erase Mode") + mode_layout = QHBoxLayout(mode_group) + + self._mode_group = QButtonGroup(self) + for i, name in enumerate(["Partitions", "By Sector"]): + rb = QRadioButton(name) + if i == 0: + rb.setChecked(True) + self._mode_group.addButton(rb, i) + mode_layout.addWidget(rb) + mode_layout.addStretch() + + mode_layout.addWidget(QLabel("Part Type:")) + self._parttype_combo = QComboBox() + self._parttype_combo.addItems(PART_TYPES) + mode_layout.addWidget(self._parttype_combo) + + layout.addWidget(mode_group) + + # Stacked content + self._stack = QStackedWidget() + + # Page 0: Partitions + part_page = QWidget() + part_layout = QVBoxLayout(part_page) + self._partition_list = PartitionList() + part_layout.addWidget(self._partition_list) + self._stack.addWidget(part_page) + + # Page 1: By Sector + sector_page = QWidget() + sector_layout = QVBoxLayout(sector_page) + sg = QHBoxLayout() + sg.addWidget(QLabel("Start Sector:")) + self._sector_start = QLineEdit() + self._sector_start.setPlaceholderText("0") + sg.addWidget(self._sector_start, 1) + sg.addWidget(QLabel("Sector Count:")) + self._sector_count = QLineEdit() + self._sector_count.setPlaceholderText("1") + sg.addWidget(self._sector_count, 1) + sector_layout.addLayout(sg) + sector_layout.addStretch() + self._stack.addWidget(sector_page) + + layout.addWidget(self._stack, 1) + self._mode_group.idClicked.connect(self._stack.setCurrentIndex) + + # Erase button + btn_row = QHBoxLayout() + btn_row.addStretch() + self._erase_btn = QPushButton("Erase") + self._erase_btn.setFixedWidth(120) + self._erase_btn.setProperty("destructive", True) + self._erase_btn.setEnabled(False) + btn_row.addWidget(self._erase_btn) + layout.addLayout(btn_row) + + @property + def erase_button(self) -> QPushButton: + return self._erase_btn + + @property + def partition_list(self) -> PartitionList: + return self._partition_list + + def get_erase_params(self) -> dict: + mode = self._mode_group.checkedId() + params = {"mode": mode, "parttype": self._parttype_combo.currentText()} + if mode == 0: + params["partitions"] = self._partition_list.get_selected() + else: + params["sector"] = int(self._sector_start.text() or "0") + params["sectors"] = int(self._sector_count.text() or "0") + return params diff --git a/mtk_gui/ui/tabs/exploit_tab.py b/mtk_gui/ui/tabs/exploit_tab.py new file mode 100644 index 0000000..db2be21 --- /dev/null +++ b/mtk_gui/ui/tabs/exploit_tab.py @@ -0,0 +1,168 @@ +"""Exploit tab — crash, brute, payload, stage, meta mode.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QComboBox, QFileDialog, +) + + +class ExploitTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # ── Preloader crash ─────────────────────────────── + crash_group = QGroupBox("Preloader Crash") + crash_layout = QHBoxLayout(crash_group) + crash_layout.addWidget(QLabel("Mode:")) + self._crash_mode = QComboBox() + self._crash_mode.addItems(["0 (default)", "1", "2"]) + crash_layout.addWidget(self._crash_mode) + self._crash_btn = QPushButton("Crash") + self._crash_btn.setProperty("destructive", True) + self._crash_btn.setEnabled(False) + crash_layout.addWidget(self._crash_btn) + crash_layout.addStretch() + layout.addWidget(crash_group) + + # ── Brute force ────────────────────────────────── + brute_group = QGroupBox("Kamakiri Brute Force") + brute_layout = QHBoxLayout(brute_group) + self._brute_btn = QPushButton("Start Brute Force") + self._brute_btn.setProperty("destructive", True) + self._brute_btn.setEnabled(False) + brute_layout.addWidget(self._brute_btn) + brute_layout.addStretch() + layout.addWidget(brute_group) + + # ── Payload runner ──────────────────────────────── + payload_group = QGroupBox("Payload") + payload_layout = QVBoxLayout(payload_group) + p1 = QHBoxLayout() + p1.addWidget(QLabel("Payload File:")) + self._payload_file = QLineEdit() + p1.addWidget(self._payload_file, 1) + pb = QPushButton("Browse") + pb.setFixedWidth(70) + pb.clicked.connect(lambda: self._browse(self._payload_file)) + p1.addWidget(pb) + self._payload_btn = QPushButton("Run") + self._payload_btn.setEnabled(False) + p1.addWidget(self._payload_btn) + payload_layout.addLayout(p1) + layout.addWidget(payload_group) + + # ── Stage2 runner ───────────────────────────────── + stage_group = QGroupBox("Stage2") + stage_layout = QVBoxLayout(stage_group) + s1 = QHBoxLayout() + s1.addWidget(QLabel("Stage2 File:")) + self._stage_file = QLineEdit() + s1.addWidget(self._stage_file, 1) + sb = QPushButton("Browse") + sb.setFixedWidth(70) + sb.clicked.connect(lambda: self._browse(self._stage_file)) + s1.addWidget(sb) + stage_layout.addLayout(s1) + + s2 = QHBoxLayout() + s2.addWidget(QLabel("Address (hex):")) + self._stage_addr = QLineEdit() + self._stage_addr.setPlaceholderText("0x200000") + s2.addWidget(self._stage_addr, 1) + self._stage_btn = QPushButton("Run") + self._stage_btn.setEnabled(False) + s2.addWidget(self._stage_btn) + stage_layout.addLayout(s2) + layout.addWidget(stage_group) + + # ── Meta mode ──────────────────────────────────── + meta_group = QGroupBox("Meta Mode") + meta_layout = QHBoxLayout(meta_group) + meta_layout.addWidget(QLabel("Mode:")) + self._meta_mode = QComboBox() + self._meta_mode.addItems(["off", "usb", "uart"]) + meta_layout.addWidget(self._meta_mode) + self._meta_btn = QPushButton("Set Meta Mode") + self._meta_btn.setEnabled(False) + meta_layout.addWidget(self._meta_btn) + meta_layout.addStretch() + layout.addWidget(meta_group) + + # ── Device control ──────────────────────────────── + ctrl_group = QGroupBox("Device Control") + ctrl_layout = QHBoxLayout(ctrl_group) + self._reset_btn = QPushButton("Reset Device") + self._reset_btn.setEnabled(False) + ctrl_layout.addWidget(self._reset_btn) + self._shutdown_btn = QPushButton("Shutdown Device") + self._shutdown_btn.setEnabled(False) + ctrl_layout.addWidget(self._shutdown_btn) + ctrl_layout.addStretch() + layout.addWidget(ctrl_group) + + layout.addStretch() + + def _browse(self, target): + path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Binary (*.bin);;All (*)") + if path: + target.setText(path) + + @property + def crash_button(self) -> QPushButton: + return self._crash_btn + + @property + def brute_button(self) -> QPushButton: + return self._brute_btn + + @property + def payload_button(self) -> QPushButton: + return self._payload_btn + + @property + def stage_button(self) -> QPushButton: + return self._stage_btn + + @property + def meta_button(self) -> QPushButton: + return self._meta_btn + + @property + def reset_button(self) -> QPushButton: + return self._reset_btn + + @property + def shutdown_button(self) -> QPushButton: + return self._shutdown_btn + + @property + def crash_mode(self) -> int: + return self._crash_mode.currentIndex() + + @property + def payload_file(self) -> str: + return self._payload_file.text().strip() + + @property + def stage_file(self) -> str: + return self._stage_file.text().strip() + + @property + def stage_addr(self) -> int: + text = self._stage_addr.text().strip() + if not text: + return 0x200000 + return int(text, 16) if text.startswith("0x") else int(text) + + @property + def meta_mode(self) -> str: + return self._meta_mode.currentText() + + def set_enabled_all(self, enabled: bool): + for btn in (self._crash_btn, self._brute_btn, self._payload_btn, + self._stage_btn, self._meta_btn, self._reset_btn, self._shutdown_btn): + btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/imei_tab.py b/mtk_gui/ui/tabs/imei_tab.py new file mode 100644 index 0000000..034e41e --- /dev/null +++ b/mtk_gui/ui/tabs/imei_tab.py @@ -0,0 +1,159 @@ +"""IMEI tab — read/write IMEI, NVItem, modem patch.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QFileDialog, +) + + +class ImeiTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # ── Read IMEI ───────────────────────────────────── + read_group = QGroupBox("Read IMEI") + read_layout = QVBoxLayout(read_group) + + r1 = QHBoxLayout() + r1.addWidget(QLabel("Seed:")) + self._seed_input = QLineEdit() + self._seed_input.setPlaceholderText("(optional)") + r1.addWidget(self._seed_input, 1) + r1.addWidget(QLabel("AES Key:")) + self._aeskey_input = QLineEdit() + self._aeskey_input.setPlaceholderText("(optional)") + r1.addWidget(self._aeskey_input, 1) + read_layout.addLayout(r1) + + r2 = QHBoxLayout() + self._read_btn = QPushButton("Read IMEIs") + self._read_btn.setFixedWidth(120) + self._read_btn.setEnabled(False) + r2.addWidget(self._read_btn) + r2.addStretch() + read_layout.addLayout(r2) + + # IMEI display + self._imei_display = QLabel("IMEI1: —\nIMEI2: —") + self._imei_display.setStyleSheet("font-family: Consolas; font-size: 14px; padding: 8px;") + read_layout.addWidget(self._imei_display) + layout.addWidget(read_group) + + # ── Write IMEI ──────────────────────────────────── + write_group = QGroupBox("Write IMEI") + write_layout = QVBoxLayout(write_group) + + w1 = QHBoxLayout() + w1.addWidget(QLabel("IMEI 1:")) + self._imei1_input = QLineEdit() + self._imei1_input.setPlaceholderText("15-digit IMEI") + self._imei1_input.setMaxLength(15) + w1.addWidget(self._imei1_input, 1) + w1.addWidget(QLabel("IMEI 2:")) + self._imei2_input = QLineEdit() + self._imei2_input.setPlaceholderText("15-digit IMEI (optional)") + self._imei2_input.setMaxLength(15) + w1.addWidget(self._imei2_input, 1) + write_layout.addLayout(w1) + + w2 = QHBoxLayout() + w2.addWidget(QLabel("Product:")) + self._product_input = QLineEdit() + self._product_input.setPlaceholderText("(optional)") + w2.addWidget(self._product_input, 1) + self._write_btn = QPushButton("Write IMEIs") + self._write_btn.setProperty("destructive", True) + self._write_btn.setFixedWidth(120) + self._write_btn.setEnabled(False) + w2.addWidget(self._write_btn) + write_layout.addLayout(w2) + layout.addWidget(write_group) + + # ── Modem patch ─────────────────────────────────── + modem_group = QGroupBox("Modem") + modem_layout = QHBoxLayout(modem_group) + self._patch_modem_btn = QPushButton("Patch Modem") + self._patch_modem_btn.setProperty("destructive", True) + self._patch_modem_btn.setEnabled(False) + modem_layout.addWidget(self._patch_modem_btn) + modem_layout.addStretch() + layout.addWidget(modem_group) + + # ── NVItem ──────────────────────────────────────── + nv_group = QGroupBox("NVItem") + nv_layout = QVBoxLayout(nv_group) + nv1 = QHBoxLayout() + nv1.addWidget(QLabel("NV File:")) + self._nv_file = QLineEdit() + nv1.addWidget(self._nv_file, 1) + nv_browse = QPushButton("Browse") + nv_browse.setFixedWidth(70) + nv_browse.clicked.connect(self._browse_nv) + nv1.addWidget(nv_browse) + nv_layout.addLayout(nv1) + + nv2 = QHBoxLayout() + self._decrypt_btn = QPushButton("Decrypt") + self._decrypt_btn.setEnabled(False) + nv2.addWidget(self._decrypt_btn) + self._encrypt_btn = QPushButton("Encrypt") + self._encrypt_btn.setEnabled(False) + nv2.addWidget(self._encrypt_btn) + nv2.addStretch() + nv_layout.addLayout(nv2) + layout.addWidget(nv_group) + + layout.addStretch() + + def _browse_nv(self): + path, _ = QFileDialog.getOpenFileName(self, "Select NV File", "", "All (*)") + if path: + self._nv_file.setText(path) + + @property + def read_button(self) -> QPushButton: + return self._read_btn + + @property + def write_button(self) -> QPushButton: + return self._write_btn + + @property + def patch_modem_button(self) -> QPushButton: + return self._patch_modem_btn + + def set_imei_display(self, imei1: str, imei2: str = ""): + text = f"IMEI1: {imei1 or '—'}" + if imei2: + text += f"\nIMEI2: {imei2}" + self._imei_display.setText(text) + + @property + def decrypt_button(self) -> QPushButton: + return self._decrypt_btn + + @property + def encrypt_button(self) -> QPushButton: + return self._encrypt_btn + + @property + def nv_file(self) -> str: + return self._nv_file.text().strip() + + def get_imei_params(self) -> dict: + return { + "imei1": self._imei1_input.text().strip(), + "imei2": self._imei2_input.text().strip(), + "seed": self._seed_input.text().strip(), + "aeskey": self._aeskey_input.text().strip(), + "product": self._product_input.text().strip(), + } + + def set_enabled_all(self, enabled: bool): + for btn in (self._read_btn, self._write_btn, self._patch_modem_btn, + self._decrypt_btn, self._encrypt_btn): + btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/keys_tab.py b/mtk_gui/ui/tabs/keys_tab.py new file mode 100644 index 0000000..519aab5 --- /dev/null +++ b/mtk_gui/ui/tabs/keys_tab.py @@ -0,0 +1,73 @@ +"""Keys tab — hardware key generation and display.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QFileDialog, QTableWidget, QTableWidgetItem, + QHeaderView, +) + + +class KeysTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # Output directory + dir_group = QGroupBox("Output") + dir_layout = QHBoxLayout(dir_group) + dir_layout.addWidget(QLabel("Directory:")) + self._dir_input = QLineEdit(".") + dir_layout.addWidget(self._dir_input, 1) + browse = QPushButton("Browse") + browse.setFixedWidth(70) + browse.clicked.connect(self._browse_dir) + dir_layout.addWidget(browse) + layout.addWidget(dir_group) + + # Generate button + btn_row = QHBoxLayout() + btn_row.addStretch() + self._gen_btn = QPushButton("Generate Keys") + self._gen_btn.setFixedWidth(140) + self._gen_btn.setEnabled(False) + btn_row.addWidget(self._gen_btn) + layout.addLayout(btn_row) + + # Key table + self._table = QTableWidget(0, 2) + self._table.setHorizontalHeaderLabels(["Key", "Value"]) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._table.setAlternatingRowColors(True) + layout.addWidget(self._table, 1) + + # Status + self._status = QLabel("") + layout.addWidget(self._status) + + def _browse_dir(self): + d = QFileDialog.getExistingDirectory(self, "Select Output Directory") + if d: + self._dir_input.setText(d) + + @property + def generate_button(self) -> QPushButton: + return self._gen_btn + + @property + def output_directory(self) -> str: + return self._dir_input.text().strip() or "." + + def set_keys(self, keys: dict): + """Display generated keys in the table.""" + self._table.setRowCount(len(keys)) + for row, (key, value) in enumerate(keys.items()): + self._table.setItem(row, 0, QTableWidgetItem(str(key))) + self._table.setItem(row, 1, QTableWidgetItem(str(value))) + + def set_status(self, msg: str): + self._status.setText(msg) diff --git a/mtk_gui/ui/tabs/memory_tab.py b/mtk_gui/ui/tabs/memory_tab.py new file mode 100644 index 0000000..b97480b --- /dev/null +++ b/mtk_gui/ui/tabs/memory_tab.py @@ -0,0 +1,168 @@ +"""Memory tab — peek/poke, memdump, BROM/SRAM dump.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QCheckBox, QFileDialog, +) + +from mtk_gui.ui.widgets.hex_viewer import HexViewer + + +class MemoryTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # ── Peek ────────────────────────────────────────── + peek_group = QGroupBox("Peek (Read Memory)") + peek_layout = QVBoxLayout(peek_group) + + addr_row = QHBoxLayout() + addr_row.addWidget(QLabel("Address (hex):")) + self._peek_addr = QLineEdit() + self._peek_addr.setPlaceholderText("0x100000") + addr_row.addWidget(self._peek_addr, 1) + addr_row.addWidget(QLabel("Length (hex):")) + self._peek_len = QLineEdit() + self._peek_len.setPlaceholderText("0x100") + addr_row.addWidget(self._peek_len, 1) + peek_layout.addLayout(addr_row) + + opt_row = QHBoxLayout() + self._peek_regs = QCheckBox("Register mode") + opt_row.addWidget(self._peek_regs) + opt_row.addStretch() + + self._peek_btn = QPushButton("Read") + self._peek_btn.setFixedWidth(80) + self._peek_btn.setEnabled(False) + opt_row.addWidget(self._peek_btn) + + self._peek_save_btn = QPushButton("Read to File") + self._peek_save_btn.setFixedWidth(100) + self._peek_save_btn.setEnabled(False) + opt_row.addWidget(self._peek_save_btn) + peek_layout.addLayout(opt_row) + + self._hex_viewer = HexViewer() + peek_layout.addWidget(self._hex_viewer, 1) + layout.addWidget(peek_group, 1) + + # ── Poke ────────────────────────────────────────── + poke_group = QGroupBox("Poke (Write Memory)") + poke_layout = QVBoxLayout(poke_group) + + poke_row = QHBoxLayout() + poke_row.addWidget(QLabel("Address (hex):")) + self._poke_addr = QLineEdit() + self._poke_addr.setPlaceholderText("0x100000") + poke_row.addWidget(self._poke_addr, 1) + poke_row.addWidget(QLabel("Data (hex):")) + self._poke_data = QLineEdit() + self._poke_data.setPlaceholderText("DEADBEEF") + poke_row.addWidget(self._poke_data, 1) + poke_layout.addLayout(poke_row) + + poke_btn_row = QHBoxLayout() + poke_btn_row.addStretch() + self._poke_btn = QPushButton("Write") + self._poke_btn.setProperty("destructive", True) + self._poke_btn.setFixedWidth(80) + self._poke_btn.setEnabled(False) + poke_btn_row.addWidget(self._poke_btn) + poke_layout.addLayout(poke_btn_row) + layout.addWidget(poke_group) + + # ── Dump buttons ────────────────────────────────── + dump_group = QGroupBox("Memory Dumps") + dump_layout = QVBoxLayout(dump_group) + + dir_row = QHBoxLayout() + dir_row.addWidget(QLabel("Output Dir:")) + self._dump_dir = QLineEdit(".") + dir_row.addWidget(self._dump_dir, 1) + dir_browse = QPushButton("Browse") + dir_browse.setFixedWidth(70) + dir_browse.clicked.connect(self._browse_dump_dir) + dir_row.addWidget(dir_browse) + dump_layout.addLayout(dir_row) + + btn_row = QHBoxLayout() + self._brom_btn = QPushButton("Dump BROM") + self._brom_btn.setEnabled(False) + btn_row.addWidget(self._brom_btn) + self._sram_btn = QPushButton("Dump SRAM") + self._sram_btn.setEnabled(False) + btn_row.addWidget(self._sram_btn) + self._dram_btn = QPushButton("Dump DRAM") + self._dram_btn.setEnabled(False) + btn_row.addWidget(self._dram_btn) + self._memdump_btn = QPushButton("Full Memdump") + self._memdump_btn.setEnabled(False) + btn_row.addWidget(self._memdump_btn) + dump_layout.addLayout(btn_row) + layout.addWidget(dump_group) + + def _browse_dump_dir(self): + d = QFileDialog.getExistingDirectory(self, "Select Output Directory") + if d: + self._dump_dir.setText(d) + + # ── Public API ──────────────────────────────────────── + + @property + def peek_button(self) -> QPushButton: + return self._peek_btn + + @property + def peek_save_button(self) -> QPushButton: + return self._peek_save_btn + + @property + def poke_button(self) -> QPushButton: + return self._poke_btn + + @property + def brom_button(self) -> QPushButton: + return self._brom_btn + + @property + def sram_button(self) -> QPushButton: + return self._sram_btn + + @property + def dram_button(self) -> QPushButton: + return self._dram_btn + + @property + def memdump_button(self) -> QPushButton: + return self._memdump_btn + + @property + def hex_viewer(self) -> HexViewer: + return self._hex_viewer + + def get_peek_params(self) -> dict: + return { + "addr": int(self._peek_addr.text() or "0", 16), + "length": int(self._peek_len.text() or "0", 16), + "registers": self._peek_regs.isChecked(), + } + + def get_poke_params(self) -> dict: + return { + "addr": int(self._poke_addr.text() or "0", 16), + "data": self._poke_data.text().strip(), + } + + @property + def dump_directory(self) -> str: + return self._dump_dir.text().strip() or "." + + def set_enabled_all(self, enabled: bool): + for btn in (self._peek_btn, self._peek_save_btn, self._poke_btn, + self._brom_btn, self._sram_btn, self._dram_btn, self._memdump_btn): + btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/read_tab.py b/mtk_gui/ui/tabs/read_tab.py new file mode 100644 index 0000000..7cd4c4f --- /dev/null +++ b/mtk_gui/ui/tabs/read_tab.py @@ -0,0 +1,194 @@ +"""Read tab — partition/flash/offset/sector read operations.""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QComboBox, QLineEdit, QPushButton, QFileDialog, QCheckBox, + QStackedWidget, QRadioButton, QButtonGroup, +) + +from mtk_gui.ui.widgets.partition_list import PartitionList +from mtk_gui.constants import PART_TYPES + + +class ReadTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # ── Mode selector ───────────────────────────────── + mode_group = QGroupBox("Read Mode") + mode_layout = QHBoxLayout(mode_group) + + self._mode_group = QButtonGroup(self) + modes = ["Partitions", "Full Flash", "By Offset", "By Sector"] + for i, name in enumerate(modes): + rb = QRadioButton(name) + if i == 0: + rb.setChecked(True) + self._mode_group.addButton(rb, i) + mode_layout.addWidget(rb) + mode_layout.addStretch() + + # Part type + mode_layout.addWidget(QLabel("Part Type:")) + self._parttype_combo = QComboBox() + self._parttype_combo.addItems(PART_TYPES) + mode_layout.addWidget(self._parttype_combo) + + layout.addWidget(mode_group) + + # ── Stacked content ─────────────────────────────── + self._stack = QStackedWidget() + + # Page 0: Partitions + part_page = QWidget() + part_layout = QVBoxLayout(part_page) + self._partition_list = PartitionList() + part_layout.addWidget(self._partition_list) + + self._dump_gpt_check = QCheckBox("Also dump GPT") + part_layout.addWidget(self._dump_gpt_check) + + dir_row = QHBoxLayout() + dir_row.addWidget(QLabel("Output Directory:")) + self._part_dir_input = QLineEdit() + self._part_dir_input.setPlaceholderText("Select output directory...") + dir_row.addWidget(self._part_dir_input, 1) + part_dir_btn = QPushButton("Browse") + part_dir_btn.setFixedWidth(70) + part_dir_btn.clicked.connect(lambda: self._browse_dir(self._part_dir_input)) + dir_row.addWidget(part_dir_btn) + part_layout.addLayout(dir_row) + + self._stack.addWidget(part_page) + + # Page 1: Full Flash + flash_page = QWidget() + flash_layout = QVBoxLayout(flash_page) + file_row = QHBoxLayout() + file_row.addWidget(QLabel("Output File:")) + self._flash_file_input = QLineEdit() + self._flash_file_input.setPlaceholderText("Select output file...") + file_row.addWidget(self._flash_file_input, 1) + flash_browse = QPushButton("Browse") + flash_browse.setFixedWidth(70) + flash_browse.clicked.connect(lambda: self._browse_save(self._flash_file_input)) + file_row.addWidget(flash_browse) + flash_layout.addLayout(file_row) + flash_layout.addStretch() + self._stack.addWidget(flash_page) + + # Page 2: By Offset + offset_page = QWidget() + offset_layout = QVBoxLayout(offset_page) + og = QHBoxLayout() + og.addWidget(QLabel("Offset (hex):")) + self._offset_input = QLineEdit() + self._offset_input.setPlaceholderText("0x0") + og.addWidget(self._offset_input, 1) + og.addWidget(QLabel("Length (hex):")) + self._offset_len_input = QLineEdit() + self._offset_len_input.setPlaceholderText("0x1000") + og.addWidget(self._offset_len_input, 1) + offset_layout.addLayout(og) + + of_row = QHBoxLayout() + of_row.addWidget(QLabel("Output File:")) + self._offset_file_input = QLineEdit() + of_row.addWidget(self._offset_file_input, 1) + off_browse = QPushButton("Browse") + off_browse.setFixedWidth(70) + off_browse.clicked.connect(lambda: self._browse_save(self._offset_file_input)) + of_row.addWidget(off_browse) + offset_layout.addLayout(of_row) + offset_layout.addStretch() + self._stack.addWidget(offset_page) + + # Page 3: By Sector + sector_page = QWidget() + sector_layout = QVBoxLayout(sector_page) + sg = QHBoxLayout() + sg.addWidget(QLabel("Start Sector:")) + self._sector_start_input = QLineEdit() + self._sector_start_input.setPlaceholderText("0") + sg.addWidget(self._sector_start_input, 1) + sg.addWidget(QLabel("Sector Count:")) + self._sector_count_input = QLineEdit() + self._sector_count_input.setPlaceholderText("1") + sg.addWidget(self._sector_count_input, 1) + sector_layout.addLayout(sg) + + sf_row = QHBoxLayout() + sf_row.addWidget(QLabel("Output File:")) + self._sector_file_input = QLineEdit() + sf_row.addWidget(self._sector_file_input, 1) + sec_browse = QPushButton("Browse") + sec_browse.setFixedWidth(70) + sec_browse.clicked.connect(lambda: self._browse_save(self._sector_file_input)) + sf_row.addWidget(sec_browse) + sector_layout.addLayout(sf_row) + sector_layout.addStretch() + self._stack.addWidget(sector_page) + + layout.addWidget(self._stack, 1) + + # Wire mode selector to stack + self._mode_group.idClicked.connect(self._stack.setCurrentIndex) + + # ── Read button ─────────────────────────────────── + btn_row = QHBoxLayout() + btn_row.addStretch() + self._read_btn = QPushButton("Read") + self._read_btn.setFixedWidth(120) + self._read_btn.setEnabled(False) + btn_row.addWidget(self._read_btn) + layout.addLayout(btn_row) + + def _browse_dir(self, target: QLineEdit): + d = QFileDialog.getExistingDirectory(self, "Select Output Directory") + if d: + target.setText(d) + + def _browse_save(self, target: QLineEdit): + path, _ = QFileDialog.getSaveFileName(self, "Select Output File", "", "Binary (*.bin);;All (*)") + if path: + target.setText(path) + + # ── Public API ──────────────────────────────────────── + + @property + def read_button(self) -> QPushButton: + return self._read_btn + + @property + def partition_list(self) -> PartitionList: + return self._partition_list + + def get_mode(self) -> int: + return self._mode_group.checkedId() + + def get_parttype(self) -> str: + return self._parttype_combo.currentText() + + def get_read_params(self) -> dict: + mode = self.get_mode() + params = {"mode": mode, "parttype": self.get_parttype()} + if mode == 0: + params["partitions"] = self._partition_list.get_selected() + params["directory"] = self._part_dir_input.text() + params["dump_gpt"] = self._dump_gpt_check.isChecked() + elif mode == 1: + params["filename"] = self._flash_file_input.text() + elif mode == 2: + params["offset"] = int(self._offset_input.text() or "0", 16) + params["length"] = int(self._offset_len_input.text() or "0", 16) + params["filename"] = self._offset_file_input.text() + elif mode == 3: + params["start"] = int(self._sector_start_input.text() or "0") + params["sectors"] = int(self._sector_count_input.text() or "0") + params["filename"] = self._sector_file_input.text() + return params diff --git a/mtk_gui/ui/tabs/rpmb_tab.py b/mtk_gui/ui/tabs/rpmb_tab.py new file mode 100644 index 0000000..14f8172 --- /dev/null +++ b/mtk_gui/ui/tabs/rpmb_tab.py @@ -0,0 +1,123 @@ +"""RPMB tab — read/write/erase RPMB partitions.""" +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QFileDialog, +) + + +class RpmbTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # ── Read RPMB ───────────────────────────────────── + read_group = QGroupBox("Read RPMB") + read_layout = QVBoxLayout(read_group) + + r1 = QHBoxLayout() + r1.addWidget(QLabel("Sector:")) + self._read_sector = QLineEdit() + self._read_sector.setPlaceholderText("0") + r1.addWidget(self._read_sector, 1) + r1.addWidget(QLabel("Sectors:")) + self._read_sectors = QLineEdit() + self._read_sectors.setPlaceholderText("1") + r1.addWidget(self._read_sectors, 1) + read_layout.addLayout(r1) + + r2 = QHBoxLayout() + r2.addWidget(QLabel("Output File:")) + self._read_file = QLineEdit() + r2.addWidget(self._read_file, 1) + rb = QPushButton("Browse") + rb.setFixedWidth(70) + rb.clicked.connect(lambda: self._browse_save(self._read_file)) + r2.addWidget(rb) + self._read_btn = QPushButton("Read") + self._read_btn.setFixedWidth(80) + self._read_btn.setEnabled(False) + r2.addWidget(self._read_btn) + read_layout.addLayout(r2) + layout.addWidget(read_group) + + # ── Write RPMB ──────────────────────────────────── + write_group = QGroupBox("Write RPMB") + write_layout = QVBoxLayout(write_group) + + w1 = QHBoxLayout() + w1.addWidget(QLabel("Sector:")) + self._write_sector = QLineEdit() + self._write_sector.setPlaceholderText("0") + w1.addWidget(self._write_sector, 1) + w1.addWidget(QLabel("Sectors:")) + self._write_sectors = QLineEdit() + self._write_sectors.setPlaceholderText("1") + w1.addWidget(self._write_sectors, 1) + write_layout.addLayout(w1) + + w2 = QHBoxLayout() + w2.addWidget(QLabel("Input File:")) + self._write_file = QLineEdit() + w2.addWidget(self._write_file, 1) + wb = QPushButton("Browse") + wb.setFixedWidth(70) + wb.clicked.connect(lambda: self._browse_open(self._write_file)) + w2.addWidget(wb) + self._write_btn = QPushButton("Write") + self._write_btn.setProperty("destructive", True) + self._write_btn.setFixedWidth(80) + self._write_btn.setEnabled(False) + w2.addWidget(self._write_btn) + write_layout.addLayout(w2) + layout.addWidget(write_group) + + # ── Erase RPMB ──────────────────────────────────── + erase_group = QGroupBox("Erase RPMB") + erase_layout = QHBoxLayout(erase_group) + erase_layout.addStretch() + self._erase_btn = QPushButton("Erase RPMB") + self._erase_btn.setProperty("destructive", True) + self._erase_btn.setEnabled(False) + erase_layout.addWidget(self._erase_btn) + layout.addWidget(erase_group) + + # ── Auth ────────────────────────────────────────── + auth_group = QGroupBox("RPMB Authentication") + auth_layout = QHBoxLayout(auth_group) + auth_layout.addWidget(QLabel("Auth Key (32-byte hex):")) + self._auth_key = QLineEdit() + self._auth_key.setPlaceholderText("00" * 32) + auth_layout.addWidget(self._auth_key, 1) + layout.addWidget(auth_group) + + layout.addStretch() + + def _browse_save(self, target): + path, _ = QFileDialog.getSaveFileName(self, "Output File", "", "Binary (*.bin);;All (*)") + if path: + target.setText(path) + + def _browse_open(self, target): + path, _ = QFileDialog.getOpenFileName(self, "Input File", "", "Binary (*.bin);;All (*)") + if path: + target.setText(path) + + @property + def read_button(self) -> QPushButton: + return self._read_btn + + @property + def write_button(self) -> QPushButton: + return self._write_btn + + @property + def erase_button(self) -> QPushButton: + return self._erase_btn + + def set_enabled_all(self, enabled: bool): + for btn in (self._read_btn, self._write_btn, self._erase_btn): + btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/server_tab.py b/mtk_gui/ui/tabs/server_tab.py new file mode 100644 index 0000000..f3c5f0a --- /dev/null +++ b/mtk_gui/ui/tabs/server_tab.py @@ -0,0 +1,70 @@ +"""Server tab — key server and key generation.""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QPushButton, QPlainTextEdit, +) + + +class ServerTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # ── Key Server ───────────────────────────────────── + server_group = QGroupBox("Key Server") + server_layout = QVBoxLayout(server_group) + + desc = QLabel("Runs the mtkclient key exchange server. " + "This is a one-shot operation that completes when the key exchange finishes.") + desc.setWordWrap(True) + server_layout.addWidget(desc) + + btn_row = QHBoxLayout() + self._run_btn = QPushButton("Run Key Server") + self._run_btn.setProperty("success", True) + self._run_btn.setEnabled(False) + btn_row.addWidget(self._run_btn) + btn_row.addStretch() + server_layout.addLayout(btn_row) + + layout.addWidget(server_group) + + # ── Status ──────────────────────────────────────── + status_group = QGroupBox("Status") + status_layout = QHBoxLayout(status_group) + + self._status_label = QLabel("Idle") + self._status_label.setStyleSheet("font-weight: bold; font-size: 14px;") + status_layout.addWidget(self._status_label) + status_layout.addStretch() + + layout.addWidget(status_group) + + # ── Server log ──────────────────────────────────── + self._server_log = QPlainTextEdit() + self._server_log.setReadOnly(True) + from PySide6.QtGui import QFont + font = QFont("Consolas", 9) + font.setStyleHint(QFont.StyleHint.Monospace) + self._server_log.setFont(font) + layout.addWidget(self._server_log, 1) + + @property + def start_button(self) -> QPushButton: + return self._run_btn + + def set_status(self, running: bool, message: str = ""): + if running: + self._status_label.setText(message or "Running...") + self._status_label.setStyleSheet("font-weight: bold; font-size: 14px; color: #44aa44;") + else: + self._status_label.setText(message or "Idle") + self._status_label.setStyleSheet("font-weight: bold; font-size: 14px;") + + def append_server_log(self, message: str): + self._server_log.appendPlainText(message) diff --git a/mtk_gui/ui/tabs/write_tab.py b/mtk_gui/ui/tabs/write_tab.py new file mode 100644 index 0000000..012bb1c --- /dev/null +++ b/mtk_gui/ui/tabs/write_tab.py @@ -0,0 +1,205 @@ +"""Write tab — partition/flash/offset/directory write operations.""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QComboBox, QLineEdit, QPushButton, QFileDialog, + QStackedWidget, QRadioButton, QButtonGroup, QTreeWidget, + QTreeWidgetItem, QHeaderView, +) + +from mtk_gui.constants import PART_TYPES + + +class WriteTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + # Warning banner + warn = QLabel("WARNING: Writing to flash is destructive. Ensure you have backups.") + warn.setStyleSheet( + "background-color: #442200; color: #ffaa44; padding: 6px; " + "border: 1px solid #cc8844; border-radius: 3px; font-weight: bold;" + ) + warn.setWordWrap(True) + layout.addWidget(warn) + + # ── Mode selector ───────────────────────────────── + mode_group = QGroupBox("Write Mode") + mode_layout = QHBoxLayout(mode_group) + + self._mode_group = QButtonGroup(self) + modes = ["Partitions", "Full Flash", "By Offset", "From Directory"] + for i, name in enumerate(modes): + rb = QRadioButton(name) + if i == 0: + rb.setChecked(True) + self._mode_group.addButton(rb, i) + mode_layout.addWidget(rb) + mode_layout.addStretch() + + mode_layout.addWidget(QLabel("Part Type:")) + self._parttype_combo = QComboBox() + self._parttype_combo.addItems(PART_TYPES) + mode_layout.addWidget(self._parttype_combo) + + layout.addWidget(mode_group) + + # ── Stacked content ─────────────────────────────── + self._stack = QStackedWidget() + + # Page 0: Partitions with per-partition file selectors + part_page = QWidget() + part_layout = QVBoxLayout(part_page) + + self._part_tree = QTreeWidget() + self._part_tree.setHeaderLabels(["Partition", "File"]) + self._part_tree.setRootIsDecorated(False) + header = self._part_tree.header() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self._part_tree.itemDoubleClicked.connect(self._on_part_file_select) + part_layout.addWidget(self._part_tree) + + gpt_row = QHBoxLayout() + gpt_row.addWidget(QLabel("GPT File (optional):")) + self._gpt_file_input = QLineEdit() + gpt_row.addWidget(self._gpt_file_input, 1) + gpt_browse = QPushButton("Browse") + gpt_browse.setFixedWidth(70) + gpt_browse.clicked.connect(lambda: self._browse_open(self._gpt_file_input)) + gpt_row.addWidget(gpt_browse) + part_layout.addLayout(gpt_row) + + self._stack.addWidget(part_page) + + # Page 1: Full Flash + flash_page = QWidget() + flash_layout = QVBoxLayout(flash_page) + fr = QHBoxLayout() + fr.addWidget(QLabel("Flash Image:")) + self._flash_file_input = QLineEdit() + fr.addWidget(self._flash_file_input, 1) + fb = QPushButton("Browse") + fb.setFixedWidth(70) + fb.clicked.connect(lambda: self._browse_open(self._flash_file_input)) + fr.addWidget(fb) + flash_layout.addLayout(fr) + flash_layout.addStretch() + self._stack.addWidget(flash_page) + + # Page 2: By Offset + offset_page = QWidget() + offset_layout = QVBoxLayout(offset_page) + og = QHBoxLayout() + og.addWidget(QLabel("Offset (hex):")) + self._offset_input = QLineEdit() + self._offset_input.setPlaceholderText("0x0") + og.addWidget(self._offset_input, 1) + og.addWidget(QLabel("Length (hex):")) + self._offset_len_input = QLineEdit() + self._offset_len_input.setPlaceholderText("0x1000") + og.addWidget(self._offset_len_input, 1) + offset_layout.addLayout(og) + + of_row = QHBoxLayout() + of_row.addWidget(QLabel("Input File:")) + self._offset_file_input = QLineEdit() + of_row.addWidget(self._offset_file_input, 1) + ob = QPushButton("Browse") + ob.setFixedWidth(70) + ob.clicked.connect(lambda: self._browse_open(self._offset_file_input)) + of_row.addWidget(ob) + offset_layout.addLayout(of_row) + offset_layout.addStretch() + self._stack.addWidget(offset_page) + + # Page 3: From Directory + dir_page = QWidget() + dir_layout = QVBoxLayout(dir_page) + dr = QHBoxLayout() + dr.addWidget(QLabel("Source Directory:")) + self._dir_input = QLineEdit() + dr.addWidget(self._dir_input, 1) + db = QPushButton("Browse") + db.setFixedWidth(70) + db.clicked.connect(lambda: self._browse_dir(self._dir_input)) + dr.addWidget(db) + dir_layout.addLayout(dr) + dir_layout.addStretch() + self._stack.addWidget(dir_page) + + layout.addWidget(self._stack, 1) + self._mode_group.idClicked.connect(self._stack.setCurrentIndex) + + # ── Write button ────────────────────────────────── + btn_row = QHBoxLayout() + btn_row.addStretch() + self._write_btn = QPushButton("Write") + self._write_btn.setFixedWidth(120) + self._write_btn.setProperty("destructive", True) + self._write_btn.setEnabled(False) + btn_row.addWidget(self._write_btn) + layout.addLayout(btn_row) + + def _browse_open(self, target: QLineEdit): + path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Binary (*.bin);;All (*)") + if path: + target.setText(path) + + def _browse_dir(self, target: QLineEdit): + d = QFileDialog.getExistingDirectory(self, "Select Directory") + if d: + target.setText(d) + + def _on_part_file_select(self, item: QTreeWidgetItem, column: int): + if column == 1: + path, _ = QFileDialog.getOpenFileName(self, f"Select file for {item.text(0)}", "", "Binary (*.bin);;All (*)") + if path: + item.setText(1, path) + + # ── Public API ──────────────────────────────────────── + + @property + def write_button(self) -> QPushButton: + return self._write_btn + + def set_partitions(self, partitions: list): + self._part_tree.clear() + for p in partitions: + item = QTreeWidgetItem([p.name, ""]) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + self._part_tree.addTopLevelItem(item) + + def get_mode(self) -> int: + return self._mode_group.checkedId() + + def get_parttype(self) -> str: + return self._parttype_combo.currentText() + + def get_write_params(self) -> dict: + mode = self.get_mode() + params = {"mode": mode, "parttype": self.get_parttype()} + if mode == 0: + partitions, filenames = [], [] + for i in range(self._part_tree.topLevelItemCount()): + item = self._part_tree.topLevelItem(i) + if item.text(1): + partitions.append(item.text(0)) + filenames.append(item.text(1)) + params["partitions"] = partitions + params["filenames"] = filenames + params["gpt_file"] = self._gpt_file_input.text() or None + elif mode == 1: + params["filenames"] = [self._flash_file_input.text()] + elif mode == 2: + params["offset"] = int(self._offset_input.text() or "0", 16) + params["length"] = int(self._offset_len_input.text() or "0", 16) + params["filename"] = self._offset_file_input.text() + elif mode == 3: + params["directory"] = self._dir_input.text() + return params diff --git a/mtk_gui/ui/widgets/__init__.py b/mtk_gui/ui/widgets/__init__.py new file mode 100644 index 0000000..d31af25 --- /dev/null +++ b/mtk_gui/ui/widgets/__init__.py @@ -0,0 +1 @@ +"""Reusable UI widgets.""" diff --git a/mtk_gui/ui/widgets/confirmation_dialog.py b/mtk_gui/ui/widgets/confirmation_dialog.py new file mode 100644 index 0000000..11798c8 --- /dev/null +++ b/mtk_gui/ui/widgets/confirmation_dialog.py @@ -0,0 +1,71 @@ +"""Confirmation dialog requiring the user to type YES for destructive operations.""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QLineEdit, QHBoxLayout, QPushButton, +) + + +class ConfirmationDialog(QDialog): + """Shows a warning and requires typing 'YES' to confirm destructive ops.""" + + def __init__(self, title: str, message: str, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumWidth(400) + self.setModal(True) + self._confirmed = False + self._setup_ui(message) + + def _setup_ui(self, message: str): + layout = QVBoxLayout(self) + layout.setSpacing(12) + + # Warning icon + message + warn_label = QLabel(f"WARNING: {message}") + warn_label.setWordWrap(True) + warn_label.setStyleSheet("color: #cc4444; font-weight: bold; font-size: 14px;") + layout.addWidget(warn_label) + + hint = QLabel("Type YES to confirm:") + layout.addWidget(hint) + + self._input = QLineEdit() + self._input.setPlaceholderText("Type YES") + self._input.textChanged.connect(self._on_text_changed) + self._input.returnPressed.connect(self._try_accept) + layout.addWidget(self._input) + + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch() + + self._cancel_btn = QPushButton("Cancel") + self._cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self._cancel_btn) + + self._confirm_btn = QPushButton("Confirm") + self._confirm_btn.setEnabled(False) + self._confirm_btn.setProperty("destructive", True) + self._confirm_btn.clicked.connect(self._try_accept) + btn_row.addWidget(self._confirm_btn) + + layout.addLayout(btn_row) + + def _on_text_changed(self, text: str): + self._confirm_btn.setEnabled(text.strip().upper() == "YES") + + def _try_accept(self): + if self._input.text().strip().upper() == "YES": + self._confirmed = True + self.accept() + + @property + def confirmed(self) -> bool: + return self._confirmed + + @staticmethod + def confirm(title: str, message: str, parent=None) -> bool: + """Convenience method. Returns True if confirmed.""" + dialog = ConfirmationDialog(title, message, parent) + dialog.exec() + return dialog.confirmed diff --git a/mtk_gui/ui/widgets/hex_viewer.py b/mtk_gui/ui/widgets/hex_viewer.py new file mode 100644 index 0000000..e56284e --- /dev/null +++ b/mtk_gui/ui/widgets/hex_viewer.py @@ -0,0 +1,47 @@ +"""Hex display widget for memory peek results.""" +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QPlainTextEdit + + +class HexViewer(QPlainTextEdit): + """Displays binary data in hex dump format.""" + + BYTES_PER_LINE = 16 + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + font = QFont("Consolas", 9) + font.setStyleHint(QFont.StyleHint.Monospace) + self.setFont(font) + self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + def set_data(self, data: bytes, base_addr: int = 0): + """Display bytes as hex dump with address and ASCII columns.""" + lines = [] + for offset in range(0, len(data), self.BYTES_PER_LINE): + chunk = data[offset:offset + self.BYTES_PER_LINE] + addr = base_addr + offset + + hex_parts = [] + for i, b in enumerate(chunk): + hex_parts.append(f"{b:02X}") + if i == 7: + hex_parts.append("") # extra space at midpoint + + hex_str = " ".join(hex_parts) + # Pad to fixed width + hex_str = hex_str.ljust(3 * self.BYTES_PER_LINE + 1) + + ascii_str = "".join( + chr(b) if 0x20 <= b < 0x7F else "." + for b in chunk + ) + + lines.append(f"{addr:08X} {hex_str} |{ascii_str}|") + + self.setPlainText("\n".join(lines)) + + def clear_data(self): + self.clear() diff --git a/mtk_gui/ui/widgets/log_panel.py b/mtk_gui/ui/widgets/log_panel.py new file mode 100644 index 0000000..31ab3a2 --- /dev/null +++ b/mtk_gui/ui/widgets/log_panel.py @@ -0,0 +1,116 @@ +"""Filterable log viewer with search and export.""" +import logging +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QColor, QTextCharFormat +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, + QLineEdit, QComboBox, QPushButton, QFileDialog, +) + + +class LogPanel(QWidget): + """Log viewer with level filtering, search, and export.""" + + LEVEL_COLORS = { + logging.DEBUG: "#888888", + logging.INFO: "#d4d4d4", + logging.WARNING: "#cc8844", + logging.ERROR: "#cc4444", + logging.CRITICAL: "#ff4444", + } + + def __init__(self, parent=None): + super().__init__(parent) + self._all_messages = [] + self._min_level = logging.DEBUG + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Toolbar + toolbar = QHBoxLayout() + toolbar.setSpacing(4) + + self._search = QLineEdit() + self._search.setPlaceholderText("Search logs...") + self._search.textChanged.connect(self._apply_filter) + toolbar.addWidget(self._search, 1) + + self._level_filter = QComboBox() + self._level_filter.addItems(["All", "Debug", "Info", "Warning", "Error"]) + self._level_filter.currentIndexChanged.connect(self._on_level_changed) + toolbar.addWidget(self._level_filter) + + clear_btn = QPushButton("Clear") + clear_btn.setFixedWidth(60) + clear_btn.clicked.connect(self.clear) + toolbar.addWidget(clear_btn) + + export_btn = QPushButton("Export") + export_btn.setFixedWidth(60) + export_btn.clicked.connect(self._export) + toolbar.addWidget(export_btn) + + layout.addLayout(toolbar) + + # Log display + self._log_view = QPlainTextEdit() + self._log_view.setReadOnly(True) + self._log_view.setMaximumBlockCount(10000) + self._log_view.setFont(self._monospace_font()) + layout.addWidget(self._log_view) + + @staticmethod + def _monospace_font(): + from PySide6.QtGui import QFont + font = QFont("Consolas", 9) + font.setStyleHint(QFont.StyleHint.Monospace) + return font + + @Slot(str, int) + def append_log(self, message: str, level: int = logging.INFO): + """Add a log message.""" + self._all_messages.append((message, level)) + if level >= self._min_level and self._matches_search(message): + self._append_colored(message, level) + + def _append_colored(self, message: str, level: int): + color = self.LEVEL_COLORS.get(level, "#d4d4d4") + fmt = QTextCharFormat() + fmt.setForeground(QColor(color)) + cursor = self._log_view.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + cursor.insertText(message + "\n", fmt) + self._log_view.setTextCursor(cursor) + self._log_view.ensureCursorVisible() + + def _on_level_changed(self, index: int): + levels = [logging.DEBUG, logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR] + self._min_level = levels[index] + self._apply_filter() + + def _matches_search(self, message: str) -> bool: + query = self._search.text().strip().lower() + if not query: + return True + return query in message.lower() + + def _apply_filter(self): + self._log_view.clear() + for msg, level in self._all_messages: + if level >= self._min_level and self._matches_search(msg): + self._append_colored(msg, level) + + def clear(self): + self._all_messages.clear() + self._log_view.clear() + + def _export(self): + path, _ = QFileDialog.getSaveFileName(self, "Export Log", "mtk_log.txt", "Text (*.txt)") + if path: + with open(path, "w", encoding="utf-8") as f: + for msg, _ in self._all_messages: + f.write(msg + "\n") diff --git a/mtk_gui/ui/widgets/partition_list.py b/mtk_gui/ui/widgets/partition_list.py new file mode 100644 index 0000000..a79fbb6 --- /dev/null +++ b/mtk_gui/ui/widgets/partition_list.py @@ -0,0 +1,109 @@ +"""Reusable checkbox list with partition sizes.""" +import math +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, + QPushButton, QHeaderView, +) + + +class PartitionList(QWidget): + """Partition list with checkboxes, name, and size columns.""" + + selection_changed = Signal(list) # list of partition names + + def __init__(self, parent=None): + super().__init__(parent) + self._partitions = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Buttons + btn_row = QHBoxLayout() + btn_row.setSpacing(4) + + select_all = QPushButton("Select All") + select_all.setFixedWidth(80) + select_all.clicked.connect(self._select_all) + btn_row.addWidget(select_all) + + deselect_all = QPushButton("Deselect All") + deselect_all.setFixedWidth(90) + deselect_all.clicked.connect(self._deselect_all) + btn_row.addWidget(deselect_all) + + btn_row.addStretch() + layout.addLayout(btn_row) + + # Tree widget + self._tree = QTreeWidget() + self._tree.setHeaderLabels(["Partition", "Size", "Sectors"]) + self._tree.setRootIsDecorated(False) + self._tree.setAlternatingRowColors(True) + + header = self._tree.header() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + + self._tree.itemChanged.connect(self._on_item_changed) + layout.addWidget(self._tree) + + def set_partitions(self, partitions: list): + """Set partition list. Each item needs name, size, sectors attributes.""" + self._tree.blockSignals(True) + self._tree.clear() + self._partitions = partitions + + for p in partitions: + item = QTreeWidgetItem() + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, Qt.CheckState.Unchecked) + item.setText(0, p.name) + item.setText(1, self._format_size(p.size)) + item.setText(2, str(p.sectors)) + item.setData(0, Qt.ItemDataRole.UserRole, p.name) + self._tree.addTopLevelItem(item) + + self._tree.blockSignals(False) + + def get_selected(self) -> list: + """Return list of selected partition names.""" + selected = [] + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + if item.checkState(0) == Qt.CheckState.Checked: + selected.append(item.data(0, Qt.ItemDataRole.UserRole)) + return selected + + def _select_all(self): + self._tree.blockSignals(True) + for i in range(self._tree.topLevelItemCount()): + self._tree.topLevelItem(i).setCheckState(0, Qt.CheckState.Checked) + self._tree.blockSignals(False) + self.selection_changed.emit(self.get_selected()) + + def _deselect_all(self): + self._tree.blockSignals(True) + for i in range(self._tree.topLevelItemCount()): + self._tree.topLevelItem(i).setCheckState(0, Qt.CheckState.Unchecked) + self._tree.blockSignals(False) + self.selection_changed.emit(self.get_selected()) + + def _on_item_changed(self, item, column): + if column == 0: + self.selection_changed.emit(self.get_selected()) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes <= 0: + return "0 B" + units = ("B", "KB", "MB", "GB", "TB") + i = min(int(math.floor(math.log(size_bytes, 1024))), len(units) - 1) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {units[i]}" diff --git a/mtk_gui/ui/widgets/progress_panel.py b/mtk_gui/ui/widgets/progress_panel.py new file mode 100644 index 0000000..f28f7e0 --- /dev/null +++ b/mtk_gui/ui/widgets/progress_panel.py @@ -0,0 +1,119 @@ +"""Dual progress bars with ETA and speed display.""" +import time +from PySide6.QtCore import Qt, Slot +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QProgressBar, QLabel + + +class ProgressPanel(QWidget): + """Shows operation and overall progress with ETA/speed.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._start_time = 0 + self._last_update = 0 + self._last_bytes = 0 + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # Operation progress + self._op_bar = QProgressBar() + self._op_bar.setTextVisible(True) + self._op_bar.setFormat("%p%") + layout.addWidget(self._op_bar) + + # Info row + info_row = QHBoxLayout() + info_row.setSpacing(12) + + self._status_label = QLabel("Idle") + info_row.addWidget(self._status_label, 1) + + self._speed_label = QLabel("") + self._speed_label.setAlignment(Qt.AlignmentFlag.AlignRight) + info_row.addWidget(self._speed_label) + + self._eta_label = QLabel("") + self._eta_label.setAlignment(Qt.AlignmentFlag.AlignRight) + self._eta_label.setMinimumWidth(80) + info_row.addWidget(self._eta_label) + + layout.addLayout(info_row) + + def start(self, status: str = "Working..."): + """Reset and start progress tracking.""" + self._start_time = time.time() + self._last_update = self._start_time + self._last_bytes = 0 + self._op_bar.setValue(0) + self._status_label.setText(status) + self._speed_label.setText("") + self._eta_label.setText("") + + @Slot(int, int) + def update_progress(self, current: int, total: int): + """Update progress bar and compute ETA/speed.""" + if total <= 0: + self._op_bar.setRange(0, 0) # indeterminate + return + + self._op_bar.setRange(0, total) + self._op_bar.setValue(current) + + now = time.time() + elapsed = now - self._start_time + + # Speed (bytes/sec over last interval) + dt = now - self._last_update + if dt >= 0.5: + delta_bytes = current - self._last_bytes + if delta_bytes > 0 and dt > 0: + speed = delta_bytes / dt + self._speed_label.setText(self._format_speed(speed)) + self._last_update = now + self._last_bytes = current + + # ETA + if current > 0 and elapsed > 0: + remaining = (elapsed / current) * (total - current) + self._eta_label.setText(f"ETA: {self._format_time(remaining)}") + + @Slot(str) + def set_status(self, text: str): + self._status_label.setText(text) + + def finish(self, status: str = "Done"): + """Mark operation complete.""" + self._op_bar.setValue(self._op_bar.maximum() or 100) + self._status_label.setText(status) + self._speed_label.setText("") + elapsed = time.time() - self._start_time + self._eta_label.setText(f"Took: {self._format_time(elapsed)}") + + def reset(self): + """Reset to idle state.""" + self._op_bar.setRange(0, 100) + self._op_bar.setValue(0) + self._status_label.setText("Idle") + self._speed_label.setText("") + self._eta_label.setText("") + + @staticmethod + def _format_speed(bps: float) -> str: + if bps >= 1024 * 1024: + return f"{bps / (1024*1024):.1f} MB/s" + if bps >= 1024: + return f"{bps / 1024:.1f} KB/s" + return f"{bps:.0f} B/s" + + @staticmethod + def _format_time(seconds: float) -> str: + s = int(seconds) + if s >= 3600: + return f"{s // 3600}h {(s % 3600) // 60}m {s % 60}s" + if s >= 60: + return f"{s // 60}m {s % 60}s" + return f"{s}s" diff --git a/mtk_gui/ui/widgets/serial_port_dialog.py b/mtk_gui/ui/widgets/serial_port_dialog.py new file mode 100644 index 0000000..015cb82 --- /dev/null +++ b/mtk_gui/ui/widgets/serial_port_dialog.py @@ -0,0 +1,59 @@ +"""Serial port selection dialog.""" +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, +) + + +class SerialPortDialog(QDialog): + """Dialog for selecting a serial port.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Serial Port") + self.setMinimumWidth(300) + self._selected_port = None + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Available serial ports:")) + + self._combo = QComboBox() + self._refresh_ports() + layout.addWidget(self._combo) + + btn_row = QHBoxLayout() + btn_row.addStretch() + + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self._refresh_ports) + btn_row.addWidget(refresh_btn) + + ok_btn = QPushButton("OK") + ok_btn.clicked.connect(self._accept) + btn_row.addWidget(ok_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + + layout.addLayout(btn_row) + + def _refresh_ports(self): + self._combo.clear() + self._combo.addItem("(auto-detect)", None) + try: + import serial.tools.list_ports + for port in serial.tools.list_ports.comports(): + self._combo.addItem(f"{port.device} - {port.description}", port.device) + except ImportError: + pass + + def _accept(self): + self._selected_port = self._combo.currentData() + self.accept() + + @property + def selected_port(self): + return self._selected_port diff --git a/plugins/example_plugin.py b/plugins/example_plugin.py new file mode 100644 index 0000000..557c4b1 --- /dev/null +++ b/plugins/example_plugin.py @@ -0,0 +1,48 @@ +"""Example MTK-GUI plugin demonstrating the plugin API.""" +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton + +from mtk_gui.plugins.base_plugin import MtkPlugin, PluginContext + + +class ExamplePlugin(MtkPlugin): + name = "Example Plugin" + version = "0.1.0" + author = "MTK-GUI" + description = "Demonstrates the plugin API with a simple tab." + + def __init__(self): + self._ctx = None + self._widget = None + + def register(self, ctx: PluginContext) -> None: + self._ctx = ctx + + self._widget = QWidget() + layout = QVBoxLayout(self._widget) + layout.addWidget(QLabel("This is an example plugin tab.")) + layout.addWidget(QLabel("Plugins can add tabs, menu items, and interact with the backend.")) + + info_btn = QPushButton("Show Device Info") + info_btn.clicked.connect(self._show_info) + layout.addWidget(info_btn) + + log_btn = QPushButton("Log a Message") + log_btn.clicked.connect(lambda: ctx.log("Hello from Example Plugin!")) + layout.addWidget(log_btn) + + layout.addStretch() + + ctx.add_tab(self._widget, "Example") + + def _show_info(self): + info = self._ctx.get_device_info() + if info.get("chipset"): + self._ctx.log(f"Connected device: {info['chipset']}") + else: + self._ctx.log("No device connected.") + + def on_device_connected(self, device_info: dict) -> None: + self._ctx.log(f"[Example Plugin] Device connected: {device_info.get('chipset', '?')}") + + def on_device_disconnected(self) -> None: + self._ctx.log("[Example Plugin] Device disconnected.") diff --git a/run.py b/run.py new file mode 100644 index 0000000..80b9a1b --- /dev/null +++ b/run.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Entry point for MTK-GUI.""" +import sys +import os + +# Ensure our package dir is first so mtkclient's mtk_gui.py doesn't shadow us +app_dir = os.path.dirname(os.path.abspath(__file__)) +if app_dir not in sys.path: + sys.path.insert(0, app_dir) + +# Add mtkclient to path if installed locally (after our dir, so our mtk_gui package wins) +mtkclient_path = os.path.join(os.path.dirname(app_dir), "mtkclient") +if os.path.isdir(mtkclient_path) and mtkclient_path not in sys.path: + sys.path.append(mtkclient_path) + +from mtk_gui.app import main + +if __name__ == "__main__": + main() From 16e491ebe8cb3a3dfabd3a3324d5d1ea38ebea26 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:03:22 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`fea?= =?UTF-8?q?t/initial-gui`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @sudotsu. The following files were modified: * `mtk_gui/app.py` * `mtk_gui/backend/device_manager.py` * `mtk_gui/backend/log_interceptor.py` * `mtk_gui/backend/mtk_wrapper.py` * `mtk_gui/backend/worker.py` * `mtk_gui/main_window.py` * `mtk_gui/plugins/base_plugin.py` * `mtk_gui/plugins/loader.py` * `mtk_gui/theme/__init__.py` * `mtk_gui/ui/tabs/bootloader_tab.py` * `mtk_gui/ui/tabs/device_tab.py` * `mtk_gui/ui/tabs/efuse_tab.py` * `mtk_gui/ui/tabs/erase_tab.py` * `mtk_gui/ui/tabs/exploit_tab.py` * `mtk_gui/ui/tabs/imei_tab.py` * `mtk_gui/ui/tabs/keys_tab.py` * `mtk_gui/ui/tabs/memory_tab.py` * `mtk_gui/ui/tabs/read_tab.py` * `mtk_gui/ui/tabs/rpmb_tab.py` * `mtk_gui/ui/tabs/server_tab.py` * `mtk_gui/ui/tabs/write_tab.py` * `mtk_gui/ui/widgets/confirmation_dialog.py` * `mtk_gui/ui/widgets/hex_viewer.py` * `mtk_gui/ui/widgets/log_panel.py` * `mtk_gui/ui/widgets/partition_list.py` * `mtk_gui/ui/widgets/progress_panel.py` * `mtk_gui/ui/widgets/serial_port_dialog.py` * `plugins/example_plugin.py` These file types are not supported: * `mtk-gui.spec` * `mtk_gui/theme/dark.qss` * `mtk_gui/theme/light.qss` --- mtk_gui/app.py | 10 +- mtk_gui/backend/device_manager.py | 48 ++- mtk_gui/backend/log_interceptor.py | 33 +- mtk_gui/backend/mtk_wrapper.py | 413 ++++++++++++++++++++-- mtk_gui/backend/worker.py | 15 + mtk_gui/main_window.py | 135 ++++++- mtk_gui/plugins/base_plugin.py | 47 ++- mtk_gui/plugins/loader.py | 20 +- mtk_gui/theme/__init__.py | 10 +- mtk_gui/ui/tabs/bootloader_tab.py | 20 ++ mtk_gui/ui/tabs/device_tab.py | 39 +- mtk_gui/ui/tabs/efuse_tab.py | 13 +- mtk_gui/ui/tabs/erase_tab.py | 23 ++ mtk_gui/ui/tabs/exploit_tab.py | 27 ++ mtk_gui/ui/tabs/imei_tab.py | 38 ++ mtk_gui/ui/tabs/keys_tab.py | 27 +- mtk_gui/ui/tabs/memory_tab.py | 43 +++ mtk_gui/ui/tabs/read_tab.py | 34 ++ mtk_gui/ui/tabs/rpmb_tab.py | 31 ++ mtk_gui/ui/tabs/server_tab.py | 12 + mtk_gui/ui/tabs/write_tab.py | 46 +++ mtk_gui/ui/widgets/confirmation_dialog.py | 34 +- mtk_gui/ui/widgets/hex_viewer.py | 9 +- mtk_gui/ui/widgets/log_panel.py | 42 ++- mtk_gui/ui/widgets/partition_list.py | 21 +- mtk_gui/ui/widgets/progress_panel.py | 40 ++- mtk_gui/ui/widgets/serial_port_dialog.py | 11 + plugins/example_plugin.py | 9 + 28 files changed, 1187 insertions(+), 63 deletions(-) diff --git a/mtk_gui/app.py b/mtk_gui/app.py index 88f4f49..3d342e8 100644 --- a/mtk_gui/app.py +++ b/mtk_gui/app.py @@ -11,7 +11,12 @@ def get_plugin_dirs() -> list: - """Return plugin search directories.""" + """ + Provide the application-local and user configuration directories used to search for plugins. + + Returns: + list[str]: Plugin search directory paths. + """ dirs = [] # App-local plugins dir app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -26,6 +31,9 @@ def get_plugin_dirs() -> list: def main(): + """ + Initialize the application, load plugins, display the main window, and start the Qt event loop. + """ QApplication.setOrganizationName(ORG_NAME) QApplication.setApplicationName(APP_NAME) diff --git a/mtk_gui/backend/device_manager.py b/mtk_gui/backend/device_manager.py index d290b1a..dc8bd69 100644 --- a/mtk_gui/backend/device_manager.py +++ b/mtk_gui/backend/device_manager.py @@ -60,17 +60,35 @@ def __init__(self, parent=None): @property def state(self) -> DeviceState: + """ + Get the device manager's current connection state. + + Returns: + DeviceState: The current device connection state. + """ return self._state @property def device_info(self) -> DeviceInfo: + """Return information about the currently connected device.""" return self._device_info @property def wrapper(self) -> Optional[MtkWrapper]: + """ + Provide the current MediaTek device wrapper. + + Returns: + MtkWrapper or None: The active wrapper, or None if no wrapper is configured. + """ return self._wrapper def _set_state(self, new_state: DeviceState): + """Update the manager's state and emit a change notification when it changes. + + Parameters: + new_state (DeviceState): The state to assign to the manager. + """ if new_state != self._state: self._state = new_state self.state_changed.emit(new_state) @@ -89,7 +107,9 @@ def stop_polling(self): self._poll_stop.set() def _poll_usb_loop(self): - """Background thread: poll USB until device found or stopped.""" + """ + Poll for a configured MediaTek USB preloader device until one is detected or polling is stopped. + """ try: import usb.core from mtk_gui.constants import MTK_USB_VID, MTK_PRELOADER_PIDS @@ -115,7 +135,11 @@ def _poll_usb_loop(self): self._poll_stop.wait(interval) def _on_usb_detected(self, pid: int): - """Handle USB detection on the main thread (via signal).""" + """Update the device state and log the detected MediaTek USB device. + + Parameters: + pid (int): Product ID of the detected USB device. + """ from mtk_gui.constants import MTK_USB_VID self._set_state(DeviceState.PRELOADER) self.log_message.emit( @@ -146,7 +170,12 @@ def connect_device(self): worker.start() def _do_connect(self, worker=None): - """Run connection in worker thread.""" + """ + Initialize the device wrapper and attempt to connect to the device. + + Returns: + bool: `True` if the connection succeeds, `False` otherwise. + """ self._wrapper.init( preloader=self.preloader_path, loader=self.da_loader_path, @@ -159,6 +188,11 @@ def _do_connect(self, worker=None): return success def _on_connect_finished(self, success): + """Update the device state and emit connection results after a connection attempt completes. + + Parameters: + success (bool): Whether the device connection succeeded. + """ if success: self._device_info = self._wrapper.get_device_info() self._set_state(DeviceState.CONNECTED) @@ -169,6 +203,11 @@ def _on_connect_finished(self, success): self.log_message.emit("Connection failed.", logging.ERROR) def _on_connect_error(self, msg: str): + """Set the device state to error and emit the connection error message. + + Parameters: + msg (str): Description of the connection error. + """ self._set_state(DeviceState.ERROR) self.log_message.emit(f"Connection error: {msg}", logging.ERROR) @@ -192,15 +231,18 @@ def create_worker(self, func, *args, **kwargs) -> Worker: return w def _track_worker(self, worker: Worker): + """Track a worker and remove it from the active worker list when it finishes or reports an error.""" self._workers.append(worker) worker.finished.connect(lambda _: self._untrack_worker(worker)) worker.error.connect(lambda _: self._untrack_worker(worker)) def _untrack_worker(self, worker: Worker): + """Remove a worker from the active worker list.""" if worker in self._workers: self._workers.remove(worker) def _cancel_all_workers(self): + """Cancel all active workers and clear the worker registry.""" for w in list(self._workers): w.cancel() self._workers.clear() diff --git a/mtk_gui/backend/log_interceptor.py b/mtk_gui/backend/log_interceptor.py index 0ef78d4..c4930f9 100644 --- a/mtk_gui/backend/log_interceptor.py +++ b/mtk_gui/backend/log_interceptor.py @@ -14,10 +14,21 @@ class LogSignalHandler(logging.Handler): """logging.Handler that forwards records to a LogSignalEmitter.""" def __init__(self, emitter: LogSignalEmitter): + """Initialize the handler with the emitter used to forward log records. + + Parameters: + emitter (LogSignalEmitter): Signal emitter that receives formatted log messages. + """ super().__init__() self.emitter = emitter def emit(self, record: logging.LogRecord): + """ + Forward a formatted log record and its severity level through the associated signal. + + Parameters: + record (logging.LogRecord): The log record to format and emit. + """ try: msg = self.format(record) self.emitter.log_received.emit(msg, record.levelno) @@ -38,20 +49,40 @@ class GuiSignalProxy(QObject): update_status_text = Signal(str) def __init__(self, emitter: LogSignalEmitter): + """Initialize the proxy with an emitter and connect log and status signal handlers. + + Parameters: + emitter (LogSignalEmitter): Emitter providing log and status signals. + """ super().__init__() self.emitter = emitter self.sendToLogSignal.connect(self._on_log) self.update_status_text.connect(self._on_status) def _on_log(self, msg: str): + """Forward a log message as an informational log event. + + Parameters: + msg (str): The message to forward. + """ self.emitter.log_received.emit(msg, logging.INFO) def _on_status(self, msg: str): + """Forward a status message through the emitter's status signal. + + Parameters: + msg (str): The status message to forward. + """ self.emitter.status_received.emit(msg) def emit(self, msg: str): - """Direct emit — mtkclient's logsetup() calls gui.emit(string).""" + """Forwards a log message through the GUI logging signal. + + Parameters: + msg (str): The log message to forward. + """ self.sendToLogSignal.emit(msg) def progress_callback(self, current: int, total: int): + """Emit a progress update with the current and total values.""" self.emitter.progress_received.emit(current, total) diff --git a/mtk_gui/backend/mtk_wrapper.py b/mtk_gui/backend/mtk_wrapper.py index 0d5dcd3..893cd52 100644 --- a/mtk_gui/backend/mtk_wrapper.py +++ b/mtk_gui/backend/mtk_wrapper.py @@ -45,6 +45,7 @@ class MtkWrapper: """Wraps mtkclient init/connect and all DA operations.""" def __init__(self, gui_signal=None, gui_progress=None, status_signal=None): + """Initialize the wrapper with optional GUI and status callbacks.""" self.mtk: Optional[Mtk] = None self.da_handler: Optional[DaHandler] = None self.config: Optional[MtkConfig] = None @@ -56,16 +57,39 @@ def __init__(self, gui_signal=None, gui_progress=None, status_signal=None): def _patch_sys_exit(self): """Monkey-patch sys.exit() to raise OperationAborted instead.""" def patched_exit(code=0): + """ + Intercept a system-exit request by raising an operation-aborted exception. + + Parameters: + code: The requested exit status. + + Raises: + OperationAborted: Always raised with the requested exit status. + """ raise OperationAborted(f"sys.exit({code}) intercepted") sys.exit = patched_exit def _restore_sys_exit(self): + """Restore the original ``sys.exit`` implementation.""" sys.exit = self._original_exit def init(self, preloader: str = None, loader: str = None, serial_port: str = None, iot: bool = False, stock: bool = False, work_dir: str = ".") -> Mtk: - """Initialize MtkConfig and Mtk instance.""" + """ + Initialize the MediaTek client with the specified connection and loader settings. + + Parameters: + preloader (str): Optional path to a preloader file. + loader (str): Optional download agent path. + serial_port (str): Optional serial port name. + iot (bool): Whether to enable IoT mode. + stock (bool): Whether to use stock device settings. + work_dir (str): Directory used for hardware parameter data. + + Returns: + Mtk: The initialized MediaTek client. + """ loglevel = logging.INFO self.config = MtkConfig( loglevel=loglevel, @@ -87,7 +111,15 @@ def init(self, preloader: str = None, loader: str = None, return self.mtk def connect(self, work_dir: str = ".") -> bool: - """Connect to device via DA. Returns True on success.""" + """ + Connect to the device through the Download Agent. + + Parameters: + work_dir (str): Directory used for connection-related files. + + Returns: + bool: `True` if the connection and DA configuration succeed, `False` otherwise. + """ if self.mtk is None: return False self._patch_sys_exit() @@ -111,7 +143,12 @@ def disconnect(self): self.config = None def get_device_info(self) -> DeviceInfo: - """Gather device info from current connection.""" + """ + Gather information about the connected device. + + Returns: + DeviceInfo: Device metadata, or an empty instance when no device is initialized. + """ info = DeviceInfo() if self.mtk is None or self.config is None: return info @@ -144,7 +181,11 @@ def get_device_info(self) -> DeviceInfo: return info def get_partitions(self, worker=None) -> list: - """Get list of partitions from GPT.""" + """Retrieve partition metadata from the device's GPT. + + Returns: + list: Partition information, or an empty list when GPT data is unavailable. + """ partitions = [] if self.da_handler is None: return partitions @@ -170,7 +211,18 @@ def get_partitions(self, worker=None) -> list: def read_partition(self, partition_name: str, filename: str, parttype: str = "user", worker=None) -> bool: - """Read a single partition to file.""" + """ + Read a partition from the connected device into a file. + + Parameters: + partition_name (str): Name of the partition to read. + filename (str): Destination file path. + parttype (str): Partition type used for the read operation. + worker: Optional worker associated with the operation. + + Returns: + bool: `True` if the partition is read successfully, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -185,7 +237,18 @@ def read_partition(self, partition_name: str, filename: str, def read_partitions(self, directory: str, parttype: str = "user", skip: list = None, worker=None) -> bool: - """Read all partitions to directory.""" + """ + Read selected device partitions into a directory. + + Parameters: + directory (str): Destination directory for the partition images. + parttype (str): Partition type to read. + skip (list): Partition names to exclude. + worker: Optional worker used by the underlying read operation. + + Returns: + bool: `True` if the operation succeeds, `False` if the device is unavailable or the operation is aborted. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -198,7 +261,16 @@ def read_partitions(self, directory: str, parttype: str = "user", def read_flash(self, filename: str, parttype: str = "user", offset: int = None, length: int = None, worker=None) -> bool: - """Read full flash or offset range to file.""" + """Read flash data or a specified range to a file. + + Parameters: + parttype (str): Flash region to read. + offset (int): Starting byte offset, when reading a range. + length (int): Number of bytes to read, when reading a range. + + Returns: + bool: `True` if the read succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -211,7 +283,18 @@ def read_flash(self, filename: str, parttype: str = "user", def read_sectors(self, start: int, sectors: int, filename: str, parttype: str = "user", worker=None) -> bool: - """Read by sector range.""" + """ + Read a sector range from the connected device into a file. + + Parameters: + start (int): Starting sector. + sectors (int): Number of sectors to read. + filename (str): Destination file path. + parttype (str): Partition type containing the sectors. + + Returns: + bool: `true` if the read succeeds, `false` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -224,7 +307,18 @@ def read_sectors(self, start: int, sectors: int, filename: str, def read_offset(self, start: int, length: int, filename: str, parttype: str = "user", worker=None) -> bool: - """Read by byte offset range.""" + """ + Read a byte range from the device into a file. + + Parameters: + start (int): The starting byte offset. + length (int): The number of bytes to read. + filename (str): The destination file path. + parttype (str): The partition type containing the byte range. + + Returns: + bool: `True` if the read succeeds, `False` if no device connection is available or the operation is aborted. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -239,7 +333,18 @@ def read_offset(self, start: int, length: int, filename: str, def write_partitions(self, parttype: str, filenames: list, partitions: list, worker=None) -> bool: - """Write files to named partitions.""" + """ + Write files to the specified device partitions. + + Parameters: + parttype (str): Partition type used for the write operation. + filenames (list): Files containing the data to write. + partitions (list): Target partition names. + worker: Optional worker associated with the operation. + + Returns: + bool: `True` if the write succeeds, `False` if no device connection is available or the operation is aborted. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -251,7 +356,15 @@ def write_partitions(self, parttype: str, filenames: list, self._restore_sys_exit() def write_flash(self, filenames: list, parttype: str = "user", worker=None) -> bool: - """Write full flash image(s).""" + """Write one or more full flash images to the device. + + Parameters: + filenames (list): Flash image files to write. + parttype (str): Partition type targeted by the images. + + Returns: + bool: `True` if the write operation succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -263,7 +376,16 @@ def write_flash(self, filenames: list, parttype: str = "user", worker=None) -> b self._restore_sys_exit() def write_from_directory(self, parttype: str, directory: str, worker=None) -> bool: - """Write all matching partition files from directory.""" + """ + Write matching partition image files from a directory to the device. + + Parameters: + parttype (str): Partition type used to select the files. + directory (str): Directory containing the partition files. + + Returns: + bool: `True` if the write operation succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -276,7 +398,19 @@ def write_from_directory(self, parttype: str, directory: str, worker=None) -> bo def write_offset(self, start: int, length: int, filename: str, parttype: str = "user", worker=None) -> bool: - """Write to a byte offset.""" + """ + Write data from a file to a device at a byte offset. + + Parameters: + start (int): Starting byte offset. + length (int): Number of bytes to write. + filename (str): Path to the input file. + parttype (str): Partition type containing the offset. + worker: Optional progress worker. + + Returns: + bool: `True` if the write succeeds, `False` if no device connection is available or the operation is aborted. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -290,7 +424,16 @@ def write_offset(self, start: int, length: int, filename: str, # ── Erase operations ────────────────────────────────── def erase_partitions(self, partitions: list, parttype: str = "user", worker=None) -> bool: - """Erase named partitions.""" + """ + Erase the specified partitions on the connected device. + + Parameters: + partitions (list): Names of the partitions to erase. + parttype (str): Partition type containing the named partitions. + + Returns: + bool: `True` if the erase operation succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -303,7 +446,17 @@ def erase_partitions(self, partitions: list, parttype: str = "user", worker=None def erase_sectors(self, sector: int, sectors: int, parttype: str = "user", worker=None) -> bool: - """Erase by sector range.""" + """Erase a range of sectors on the specified partition type. + + Parameters: + sector (int): The starting sector. + sectors (int): The number of sectors to erase. + parttype (str): The partition type containing the sectors. + worker: An optional operation worker. + + Returns: + bool: `True` if the erase operation succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -317,7 +470,15 @@ def erase_sectors(self, sector: int, sectors: int, # ── Security operations ─────────────────────────────── def generate_keys(self, directory: str = ".", worker=None) -> bool: - """Generate hardware keys.""" + """Generate hardware keys in the specified directory. + + Parameters: + directory (str): Directory where the generated hardware keys are stored. + worker: Optional worker used by the key-generation operation. + + Returns: + bool: `True` if key generation succeeds, `False` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -330,7 +491,15 @@ def generate_keys(self, directory: str = ".", worker=None) -> bool: self._restore_sys_exit() def seccfg_unlock(self, lock: bool = False, critical: bool = False, worker=None) -> bool: - """Lock or unlock bootloader via seccfg.""" + """Set the device's secure configuration to lock or unlock mode. + + Parameters: + lock (bool): Whether to lock the bootloader; otherwise, unlock it. + critical (bool): Whether to apply the operation to critical partitions. + + Returns: + bool: `True` if the operation succeeds, `False` if the device is unavailable or the operation is aborted. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -343,7 +512,15 @@ def seccfg_unlock(self, lock: bool = False, critical: bool = False, worker=None) self._restore_sys_exit() def vbmeta_patch(self, vbmode: int = 3, worker=None) -> bool: - """Patch vbmeta for verified boot bypass.""" + """ + Patch verified boot metadata using the specified mode. + + Parameters: + vbmode (int): Verified boot patch mode. + + Returns: + bool: `True` if the patch succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -358,7 +535,18 @@ def vbmeta_patch(self, vbmode: int = 3, worker=None) -> bool: def peek(self, addr: int, length: int, filename: str = "", registers: bool = False, worker=None): - """Read memory at address.""" + """ + Read a region of device memory. + + Parameters: + addr (int): Starting memory address. + length (int): Number of bytes to read. + filename (str): Optional file path for saving the data. + registers (bool): Whether to include register data. + + Returns: + bytes or None: The memory contents, or `None` if the device is unavailable or the operation is aborted. + """ if self.da_handler is None: return None self._patch_sys_exit() @@ -370,7 +558,17 @@ def peek(self, addr: int, length: int, filename: str = "", self._restore_sys_exit() def poke(self, addr: int, data: str, filename: str = "", worker=None) -> bool: - """Write memory at address.""" + """ + Write data to a device memory address. + + Parameters: + addr (int): Memory address to write. + data (str): Data to write. + filename (str): Optional file containing the data. + + Returns: + bool: `True` if the write succeeds, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -382,7 +580,15 @@ def poke(self, addr: int, data: str, filename: str = "", worker=None) -> bool: self._restore_sys_exit() def dump_brom(self, filename: str, worker=None) -> bool: - """Dump BROM.""" + """Dump the device's Boot ROM contents to a file. + + Parameters: + filename (str): Path of the output file. + worker: Optional worker used by the underlying dump operation. + + Returns: + bool: `True` if the dump succeeds, `False` if the device is unavailable or the operation is aborted. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -397,7 +603,17 @@ def dump_brom(self, filename: str, worker=None) -> bool: def read_rpmb(self, filename: str = None, sector: str = None, sectors: str = None, worker=None): - """Read RPMB.""" + """ + Read data from RPMB storage. + + Parameters: + filename (str, optional): Destination file for the RPMB data. + sector (str, optional): Starting RPMB sector. + sectors (str, optional): Number of sectors to read. + + Returns: + The result of the RPMB read operation, or `None` if the device is unavailable or the operation is aborted. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return None self._patch_sys_exit() @@ -410,7 +626,17 @@ def read_rpmb(self, filename: str = None, sector: str = None, def write_rpmb(self, filename: str = None, sector: int = 0, sectors: int = None, worker=None) -> bool: - """Write RPMB.""" + """ + Write data to RPMB storage. + + Parameters: + filename (str): Path to the file containing data to write. + sector (int): Starting RPMB sector. + sectors (int): Number of sectors to write. + + Returns: + bool: `True` if the write succeeds, `False` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -424,7 +650,15 @@ def write_rpmb(self, filename: str = None, sector: int = 0, # ── eFuse ───────────────────────────────────────────── def read_efuses(self, worker=None): - """Read eFuse values.""" + """ + Read the device's eFuse values. + + Parameters: + worker: Optional worker context used by the eFuse operation. + + Returns: + The eFuse values, or `None` if the device is unavailable or the operation is aborted. + """ if self.da_handler is None: return None self._patch_sys_exit() @@ -438,7 +672,16 @@ def read_efuses(self, worker=None): # ── GPT ─────────────────────────────────────────────── def dump_gpt(self, directory: str, worker=None) -> bool: - """Dump GPT to directory.""" + """ + Dump the device's GPT data to a directory. + + Parameters: + directory (str): Destination directory for the GPT data. + worker: Optional worker associated with the operation. + + Returns: + bool: `True` if the GPT data is dumped successfully, `False` otherwise. + """ if self.da_handler is None: return False self._patch_sys_exit() @@ -450,7 +693,16 @@ def dump_gpt(self, directory: str, worker=None) -> bool: self._restore_sys_exit() def erase_rpmb(self, sector: int = 0, sectors: int = None, worker=None) -> bool: - """Erase RPMB.""" + """ + Erase a range of sectors from RPMB storage. + + Parameters: + sector (int): The first sector to erase. + sectors (int): The number of sectors to erase. + + Returns: + bool: `True` if the operation succeeds, `False` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -462,7 +714,15 @@ def erase_rpmb(self, sector: int = 0, sectors: int = None, worker=None) -> bool: self._restore_sys_exit() def auth_rpmb(self, rpmbkey: bytes = None, worker=None) -> bool: - """Authenticate RPMB with key.""" + """ + Authenticate the device's RPMB using the supplied key. + + Parameters: + rpmbkey (bytes): Key used to authenticate RPMB access. + + Returns: + bool: `true` if authentication succeeds, `false` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -477,7 +737,17 @@ def auth_rpmb(self, rpmbkey: bytes = None, worker=None) -> bool: def read_imei(self, seed: bytes = b"", aeskey: bytes = b"" * 32, worker=None) -> list: - """Read IMEI(s) from nvdata partition. Returns list of (imei, valid) tuples.""" + """ + Read IMEI values from the device's `nvdata` partition. + + Parameters: + seed (bytes): Seed used to process the NVItem data. + aeskey (bytes): AES key used to process the NVItem data. + worker: Optional worker context for the operation. + + Returns: + list: Tuples containing each decoded IMEI and whether its check digit is valid. + """ if self.da_handler is None: return [] self._patch_sys_exit() @@ -521,7 +791,19 @@ def read_imei(self, seed: bytes = b"", aeskey: bytes = b"" * 32, def write_imei(self, imeis: list, seed: bytes = b"", aeskey: bytes = b"" * 32, product: str = "", worker=None) -> bool: - """Write IMEI(s) to nvdata partition. imeis: list of IMEI strings.""" + """ + Write IMEI values to the device's `nvdata` partition. + + Parameters: + imeis (list): IMEI strings to write. + seed (bytes): Optional encryption seed. + aeskey (bytes): Optional AES key. + product (str): Optional product identifier. + worker: Optional worker context for the operation. + + Returns: + bool: `True` if the `nvdata` partition is updated successfully, `False` otherwise. + """ if self.da_handler is None or not imeis: return False self._patch_sys_exit() @@ -578,7 +860,17 @@ def write_imei(self, imeis: list, seed: bytes = b"", def nvitem_crypt(self, filename: str, encrypt: bool = False, worker=None): - """Encrypt or decrypt an NVItem file. Returns processed bytes or None.""" + """ + Encrypt or decrypt the contents of an NVItem file. + + Parameters: + filename (str): Path to the NVItem file. + encrypt (bool): Whether to encrypt the file contents; otherwise, decrypt them. + worker: Optional worker context for the operation. + + Returns: + bytes: The processed NVItem data, or `None` if the operation is unavailable or aborted. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return None self._patch_sys_exit() @@ -598,7 +890,16 @@ def nvitem_crypt(self, filename: str, encrypt: bool = False, # ── Exploit ─────────────────────────────────────────── def crash_preloader(self, mode: int = None, worker=None) -> bool: - """Crash preloader/DA to get into BROM mode. mode=None iterates all modes.""" + """ + Crash the preloader to enter BROM mode. + + Parameters: + mode (int, optional): Crash mode to use. If omitted, the operation selects the available modes. + worker: Optional worker context for the operation. + + Returns: + `True` if the preloader crash succeeds, `False` otherwise. + """ if self.mtk is None: return False self._patch_sys_exit() @@ -611,7 +912,15 @@ def crash_preloader(self, mode: int = None, worker=None) -> bool: self._restore_sys_exit() def run_payload(self, filename: str, worker=None) -> bool: - """Load and run a payload binary via boot_to.""" + """ + Load and execute a payload binary on the connected device. + + Parameters: + filename (str): Path to the payload binary. + + Returns: + bool: `True` if the payload is loaded successfully, `False` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -625,7 +934,16 @@ def run_payload(self, filename: str, worker=None) -> bool: self._restore_sys_exit() def run_stage2(self, filename: str, addr: int = 0x200000, worker=None) -> bool: - """Load stage2 payload at address.""" + """ + Load a stage 2 payload into device memory. + + Parameters: + filename (str): Path to the stage 2 payload. + addr (int): Device memory address at which to load the payload. + + Returns: + bool: `true` if the payload loads successfully, `false` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -639,7 +957,15 @@ def run_stage2(self, filename: str, addr: int = 0x200000, worker=None) -> bool: self._restore_sys_exit() def set_meta_mode(self, porttype: str = "off", worker=None) -> bool: - """Set meta mode (off/usb/uart).""" + """ + Set the device's meta mode. + + Parameters: + porttype (str): Meta mode to configure, such as ``"off"``, ``"usb"``, or ``"uart"``. + + Returns: + bool: ``True`` if the mode was configured successfully, ``False`` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -651,7 +977,14 @@ def set_meta_mode(self, porttype: str = "off", worker=None) -> bool: self._restore_sys_exit() def shutdown(self, worker=None) -> bool: - """Shutdown device.""" + """Powers down the connected device. + + Parameters: + worker: Optional operation worker passed to the device shutdown handler. + + Returns: + `True` if the shutdown operation succeeds, `False` otherwise. + """ if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False self._patch_sys_exit() @@ -689,7 +1022,15 @@ def keyserver(self, worker=None) -> bool: self._restore_sys_exit() def patch_modem(self, worker=None) -> bool: - """Patch modem partition.""" + """ + Patch the modem partition on the connected device. + + Parameters: + worker: Optional worker context used by the modem patching operation. + + Returns: + `True` if patching succeeds, `False` if no device handler is available or the operation is aborted. + """ if self.da_handler is None: return False self._patch_sys_exit() diff --git a/mtk_gui/backend/worker.py b/mtk_gui/backend/worker.py index 13f9558..cce3111 100644 --- a/mtk_gui/backend/worker.py +++ b/mtk_gui/backend/worker.py @@ -17,6 +17,14 @@ class Worker(QThread): error = Signal(str) # error message def __init__(self, func, *args, **kwargs): + """ + Initialize a worker for executing a callable with the supplied arguments. + + Parameters: + func (callable): Function to execute in the worker thread. + *args: Positional arguments passed to the function. + **kwargs: Keyword arguments passed to the function. + """ super().__init__() self._func = func self._args = args @@ -24,13 +32,20 @@ def __init__(self, func, *args, **kwargs): self._cancelled = False def cancel(self): + """Mark the worker as cancelled.""" self._cancelled = True @property def is_cancelled(self): + """Return whether the worker has been cancelled. + + Returns: + bool: `True` if cancellation has been requested, `False` otherwise. + """ return self._cancelled def run(self): + """Execute the worker function and emit its result or an error message.""" try: result = self._func(*self._args, worker=self, **self._kwargs) if not self._cancelled: diff --git a/mtk_gui/main_window.py b/mtk_gui/main_window.py index c9d5109..869e7b6 100644 --- a/mtk_gui/main_window.py +++ b/mtk_gui/main_window.py @@ -31,6 +31,7 @@ class MainWindow(QMainWindow): def __init__(self): + """Initialize the main application window, backend device manager, user interface, menus, signals, and persisted window settings.""" super().__init__() self.setWindowTitle(APP_TITLE) self.setMinimumSize(900, 650) @@ -52,6 +53,7 @@ def __init__(self): def _setup_ui(self): # Central splitter: tabs on top, log panel on bottom + """Set up the main window's operation tabs, progress and log panels, splitter layout, and ready status message.""" splitter = QSplitter(Qt.Orientation.Vertical) # Tab widget @@ -109,6 +111,7 @@ def _setup_ui(self): self.statusBar().showMessage("Ready") def _setup_menus(self): + """Create the application's File, View, Plugins, and Help menus.""" menubar = self.menuBar() # File menu @@ -129,6 +132,7 @@ def _setup_menus(self): help_menu.addAction("About", self._show_about) def _connect_signals(self): + """Connect device-manager signals and operation-tab controls to their handlers.""" dm = self.device_manager # Connection state @@ -199,6 +203,7 @@ def _connect_signals(self): # ── Connection handlers ─────────────────────────────── def _on_connect(self): + """Applies the selected device settings and initiates a device connection.""" settings = self._device_tab.get_settings() dm = self.device_manager dm.preloader_path = settings["preloader"] @@ -211,10 +216,16 @@ def _on_connect(self): dm.connect_device() def _on_disconnect(self): + """Disconnect the device and reset the operation progress display.""" self.device_manager.disconnect_device() self._progress_panel.reset() def _on_state_changed(self, state: DeviceState): + """Update the interface and plugins in response to a device connection state change. + + Parameters: + state (DeviceState): The device's new connection state. + """ self._device_tab.update_connection_state(state) connected = state == DeviceState.CONNECTED @@ -250,10 +261,15 @@ def _on_state_changed(self, state: DeviceState): pass def _on_device_info_updated(self, info): + """Updates the device tab with the latest device information. + + Parameters: + info: Device information to display. + """ self._device_tab.update_device_info(info) def _load_partitions(self): - """Load partition list after connection (off main thread).""" + """Load the device's partition list asynchronously after connection.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -264,6 +280,11 @@ def _load_partitions(self): w.start() def _on_partitions_loaded(self, partitions): + """Update the read, write, and erase tabs with the available device partitions. + + Parameters: + partitions: Partition information to display in the operation tabs. + """ if partitions: self._read_tab.partition_list.set_partitions(partitions) self._write_tab.set_partitions(partitions) @@ -272,6 +293,7 @@ def _on_partitions_loaded(self, partitions): # ── Operation handlers ──────────────────────────────── def _on_read(self): + """Starts the selected read operation and reports validation or worker errors.""" params = self._read_tab.get_read_params() wrapper = self.device_manager.wrapper if not wrapper: @@ -322,6 +344,7 @@ def _on_read(self): w.start() def _on_write(self): + """Starts the selected flash-writing operation after user confirmation.""" params = self._write_tab.get_write_params() wrapper = self.device_manager.wrapper if not wrapper: @@ -361,6 +384,7 @@ def _on_write(self): w.start() def _on_erase(self): + """Erases the selected partitions or sector range from the connected device after user confirmation.""" params = self._erase_tab.get_erase_params() wrapper = self.device_manager.wrapper if not wrapper: @@ -389,6 +413,7 @@ def _on_erase(self): w.start() def _on_generate_keys(self): + """Generate device keys in the selected output directory.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -400,6 +425,7 @@ def _on_generate_keys(self): w.start() def _on_keys_done(self, result): + """Finalize key generation and display the generated keys when available.""" self._progress_panel.finish("Keys generated") self._keys_tab.set_status("Keys saved to hwparam.json") # Try to read and display the keys @@ -415,6 +441,12 @@ def _on_keys_done(self, result): pass def _on_seccfg(self, lock: bool): + """ + Change the bootloader lock state through the seccfg operation after user confirmation. + + Parameters: + lock (bool): Whether to lock the bootloader; `False` unlocks it. + """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -431,6 +463,9 @@ def _on_seccfg(self, lock: bool): w.start() def _on_vbmeta(self): + """ + Patch vbmeta using the selected bootloader mode after user confirmation. + """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -446,6 +481,7 @@ def _on_vbmeta(self): w.start() def _on_peek(self): + """Reads a memory range from the connected device and displays the result.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -458,12 +494,14 @@ def _on_peek(self): w.start() def _on_peek_done(self, data): + """Complete a memory read operation and display the returned data in the memory viewer.""" self._progress_panel.finish("Memory read complete") if data and isinstance(data, (bytes, bytearray)): params = self._memory_tab.get_peek_params() self._memory_tab.hex_viewer.set_data(data, params["addr"]) def _on_poke(self): + """Writes the specified data to the selected memory address after user confirmation.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -480,6 +518,7 @@ def _on_poke(self): w.start() def _on_dump_brom(self): + """Dump the device's Boot ROM to a file named ``brom.bin`` in the selected directory.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -492,6 +531,7 @@ def _on_dump_brom(self): w.start() def _on_rpmb_read(self): + """Read RPMB data from the connected device and report the operation result.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -501,6 +541,7 @@ def _on_rpmb_read(self): w.error.connect(lambda e: self._on_op_error("RPMB read", e)) def _on_rpmb_write(self): + """Confirm and start writing data to the device's RPMB region.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -513,6 +554,7 @@ def _on_rpmb_write(self): w.error.connect(lambda e: self._on_op_error("RPMB write", e)) def _on_read_efuses(self): + """Reads the device's eFuse values asynchronously and updates the progress panel with the result.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -523,6 +565,11 @@ def _on_read_efuses(self): w.start() def _on_efuses_done(self, data): + """Complete eFuse reading and display the retrieved values. + + Parameters: + data (list): The eFuse values returned by the read operation. + """ self._progress_panel.finish("eFuses read") if data and isinstance(data, list): self._efuse_tab.set_efuses(data) @@ -530,6 +577,7 @@ def _on_efuses_done(self, data): # ── IMEI handlers ──────────────────────────────────── def _on_imei_read(self): + """Read the device IMEIs using the configured seed and AES key.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -543,6 +591,12 @@ def _on_imei_read(self): w.error.connect(lambda e: self._on_op_error("IMEI read", e)) def _on_imei_read_done(self, result): + """ + Update the IMEI display and log the validity of each value returned by the device. + + Parameters: + result (list): IMEI values paired with checksum-validity flags. + """ self._progress_panel.finish("IMEI read complete") if result and isinstance(result, list): imei1 = result[0][0] if len(result) > 0 else "" @@ -555,6 +609,7 @@ def _on_imei_read_done(self, result): self._log_panel.append_log("No IMEIs found on device.", logging.WARNING) def _on_imei_write(self): + """Write the selected IMEI values to the connected device after confirmation.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -589,6 +644,10 @@ def _on_patch_modem(self): w.error.connect(lambda e: self._on_op_error("Modem patch", e)) def _on_nvitem_decrypt(self): + """Decrypt the selected NV file using the connected device. + + The operation updates the progress panel and reports completion or errors through the window UI. + """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -603,6 +662,7 @@ def _on_nvitem_decrypt(self): w.error.connect(lambda e: self._on_op_error("NVItem decrypt", e)) def _on_nvitem_encrypt(self): + """Encrypts the selected NV file using the connected device wrapper.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -617,6 +677,13 @@ def _on_nvitem_encrypt(self): w.error.connect(lambda e: self._on_op_error("NVItem encrypt", e)) def _on_nvitem_done(self, op: str, result, filename: str): + """Finalize an NVItem operation and save successful output to a derived file. + + Parameters: + op (str): Operation name used in the completion message and output suffix. + result: Operation output bytes, or a falsey value when the operation fails. + filename (str): Base path for the output file. + """ self._progress_panel.finish(f"NVItem {op.lower()} complete") if result: out_path = filename + f".{op.lower()}ed" @@ -629,6 +696,11 @@ def _on_nvitem_done(self, op: str, result, filename: str): # ── Exploit handlers ───────────────────────────────── def _on_crash(self): + """ + Crash the preloader to enter BROM mode after user confirmation. + + The selected crash mode determines how the preloader crash operation is performed. + """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -672,6 +744,7 @@ def _on_payload(self): w.error.connect(lambda e: self._on_op_error("Payload", e)) def _on_stage2(self): + """Runs the selected stage2 file on the connected device.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -687,6 +760,9 @@ def _on_stage2(self): w.error.connect(lambda e: self._on_op_error("Stage2", e)) def _on_meta_mode(self): + """ + Set the connected device's meta mode. + """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -698,6 +774,7 @@ def _on_meta_mode(self): w.error.connect(lambda e: self._on_op_error("Meta mode", e)) def _on_reset(self): + """Reset the connected device and report the operation status.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -707,6 +784,7 @@ def _on_reset(self): w.error.connect(lambda e: self._on_op_error("Reset", e)) def _on_shutdown(self): + """Shut down the connected device asynchronously.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -718,6 +796,7 @@ def _on_shutdown(self): # ── RPMB erase handler ─────────────────────────────── def _on_rpmb_erase(self): + """Erase the device's RPMB data after user confirmation.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -732,6 +811,7 @@ def _on_rpmb_erase(self): # ── Server handler ─────────────────────────────────── def _on_server_start(self): + """Start the key server operation when a device wrapper is available.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -742,6 +822,7 @@ def _on_server_start(self): w.error.connect(self._on_server_error) def _on_server_done(self, result): + """Update the key server status, progress message, and log entry based on the operation result.""" success = bool(result) self._server_tab.set_status(False, "Complete" if success else "Failed") @@ -752,13 +833,20 @@ def _on_server_done(self, result): logging.INFO if success else logging.ERROR) def _on_server_error(self, error: str): + """Handle a key server operation error by updating its status and reporting the failure.""" self._server_tab.set_status(False, "Error") self._on_op_error("Key server", error) # ── Button-disable helper ──────────────────────────── def _start_op(self, button, func, *args, **kwargs): - """Create a worker, disable the triggering button, re-enable on completion.""" + """ + Start a background operation and disable its triggering button until completion or error. + + Parameters: + button: The button to disable while the operation is running. + func: The operation callable to execute. + """ button.setEnabled(False) w = self.device_manager.create_worker(func, *args, **kwargs) w.finished.connect(lambda _: button.setEnabled(True)) @@ -769,12 +857,25 @@ def _start_op(self, button, func, *args, **kwargs): # ── Operation result handlers ───────────────────────── def _on_op_done(self, name: str, result): + """ + Update the progress and log panels with the outcome of an operation. + + Parameters: + name (str): Operation name used in the status message. + result: Operation result interpreted as successful when truthy. + """ success = bool(result) status = f"{name}: {'OK' if success else 'FAILED'}" self._progress_panel.finish(status) self._log_panel.append_log(status, logging.INFO if success else logging.ERROR) def _on_op_error(self, name: str, error: str): + """Report an operation error through the progress panel, log panel, and warning dialog. + + Parameters: + name (str): Name of the failed operation. + error (str): Error message to report. + """ self._progress_panel.finish(f"{name}: ERROR") self._log_panel.append_log(f"{name} error: {error}", logging.ERROR) QMessageBox.warning(self, f"{name} Error", str(error)) @@ -782,6 +883,7 @@ def _on_op_error(self, name: str, error: str): # ── Theme ───────────────────────────────────────────── def _toggle_theme(self): + """Toggle between the dark and light application themes and persist the selected theme.""" if self._current_theme == THEME_DARK: self._current_theme = THEME_LIGHT else: @@ -790,18 +892,34 @@ def _toggle_theme(self): self._settings.setValue("theme", self._current_theme) def apply_initial_theme(self): + """Apply the persisted application theme to the main window.""" self._apply_theme() def _apply_theme(self): + """Apply the stylesheet for the current application theme.""" qss = load_stylesheet(self._current_theme) self.setStyleSheet(qss) # ── Plugin support ──────────────────────────────────── def add_plugin_tab(self, widget, title: str): + """ + Add a plugin-provided widget as a tab in the main window. + + Parameters: + widget: The widget to display in the new tab. + title (str): The tab title. + """ self._tabs.addTab(widget, title) def add_plugin_menu_item(self, menu_path: str, action): + """ + Add an action to a nested menu within the plugins menu. + + Parameters: + menu_path (str): Slash-separated path identifying the target menu and action location. + action: Action to add to the target menu. + """ parts = menu_path.split("/") menu = self._plugins_menu for part in parts[:-1]: @@ -817,6 +935,12 @@ def add_plugin_menu_item(self, menu_path: str, action): menu.addAction(parts[-1], action) def set_plugins(self, plugins: list): + """ + Store the plugins available to the main window. + + Parameters: + plugins (list): Plugin instances to register. + """ self._plugins = plugins def _reload_plugins(self): @@ -825,6 +949,7 @@ def _reload_plugins(self): # ── Window state ────────────────────────────────────── def _restore_geometry(self): + """Restore the window geometry and state saved in application settings.""" geo = self._settings.value("geometry") if geo: self.restoreGeometry(geo) @@ -833,6 +958,11 @@ def _restore_geometry(self): self.restoreState(state) def closeEvent(self, event): + """Save the window state, stop device activity, clean up plugins, and close the window. + + Parameters: + event: The close event to process. + """ self._settings.setValue("geometry", self.saveGeometry()) self._settings.setValue("windowState", self.saveState()) self.device_manager.stop_polling() @@ -845,6 +975,7 @@ def closeEvent(self, event): super().closeEvent(event) def _show_about(self): + """Display an About dialog containing the application name, description, license, and toolkit information.""" QMessageBox.about(self, "About MTK-GUI", f"{APP_TITLE}\n\n" "Standalone GUI for mtkclient.\n" diff --git a/mtk_gui/plugins/base_plugin.py b/mtk_gui/plugins/base_plugin.py index 9ed0d52..6b48fcd 100644 --- a/mtk_gui/plugins/base_plugin.py +++ b/mtk_gui/plugins/base_plugin.py @@ -6,6 +6,11 @@ class PluginContext: """Provides plugin access to the application.""" def __init__(self, app): + """Initialize the plugin context with an application reference. + + Parameters: + app: The application instance used to access plugin services. + """ self._app = app def add_tab(self, widget, title: str, icon=None): @@ -13,7 +18,12 @@ def add_tab(self, widget, title: str, icon=None): self._app.main_window.add_plugin_tab(widget, title) def add_menu_item(self, menu_path: str, action): - """Add a menu item. menu_path: 'Plugins/My Action'.""" + """Adds an action to the specified plugin menu path. + + Parameters: + menu_path (str): Menu path for the action, such as ``"Plugins/My Action"``. + action: Action to add to the menu. + """ self._app.main_window.add_plugin_menu_item(menu_path, action) def get_backend(self): @@ -21,7 +31,13 @@ def get_backend(self): return self._app.main_window.device_manager def get_device_info(self) -> dict: - """Get current device info as a dict.""" + """Return selected information for the currently connected device. + + Returns: + dict: A mapping containing the device chipset, hardware code, flash type, + flash size, MEID, and SoC ID, or an empty dictionary when device + information is unavailable. + """ dm = self.get_backend() if dm and dm.device_info: info = dm.device_info @@ -36,13 +52,27 @@ def get_device_info(self) -> dict: return {} def log(self, message: str, level: int = 20): - """Log a message (level: 10=DEBUG, 20=INFO, 30=WARNING, 40=ERROR).""" + """ + Emit a message through the plugin backend. + + Parameters: + message (str): The message to emit. + level (int): The log severity, using 10 for debug, 20 for info, 30 for warning, or 40 for error. + """ dm = self.get_backend() if dm: dm.log_message.emit(message, level) def create_worker(self, func, *args): - """Create a tracked Worker thread.""" + """Create a tracked worker for executing a function. + + Parameters: + func: The function to execute. + *args: Positional arguments passed to the function. + + Returns: + Worker: The created worker. + """ from mtk_gui.backend.worker import Worker dm = self.get_backend() return dm.create_worker(func, *args) @@ -62,7 +92,12 @@ def register(self, ctx: PluginContext) -> None: ... def on_device_connected(self, device_info: dict) -> None: - """Called when a device connects.""" + """ + Handle notification that a device has connected. + + Parameters: + device_info (dict): Information about the connected device. + """ pass def on_device_disconnected(self) -> None: @@ -70,5 +105,5 @@ def on_device_disconnected(self) -> None: pass def cleanup(self) -> None: - """Called when the application exits.""" + """Releases plugin resources when the application exits.""" pass diff --git a/mtk_gui/plugins/loader.py b/mtk_gui/plugins/loader.py index a2062c9..92c04b8 100644 --- a/mtk_gui/plugins/loader.py +++ b/mtk_gui/plugins/loader.py @@ -11,7 +11,15 @@ def discover_plugins(search_dirs: list) -> list: - """Find all .py files containing MtkPlugin subclasses in search_dirs.""" + """ + Discover plugin instances from Python files in the specified directories. + + Parameters: + search_dirs (list): Directories to scan for plugin files. + + Returns: + list: Instances of concrete `MtkPlugin` subclasses found in the directories. + """ plugins = [] for directory in search_dirs: @@ -46,7 +54,15 @@ def discover_plugins(search_dirs: list) -> list: def load_plugins(app, search_dirs: list) -> list: - """Discover, instantiate, and register all plugins.""" + """ + Discover and register plugins with the application. + + Parameters: + search_dirs (list): Directories to search for plugin files. + + Returns: + list: Successfully registered plugin instances. + """ plugins = discover_plugins(search_dirs) ctx = PluginContext(app) diff --git a/mtk_gui/theme/__init__.py b/mtk_gui/theme/__init__.py index 69fc0f0..597ffc1 100644 --- a/mtk_gui/theme/__init__.py +++ b/mtk_gui/theme/__init__.py @@ -3,7 +3,15 @@ def load_stylesheet(theme_name: str) -> str: - """Load a QSS stylesheet by theme name.""" + """ + Load the QSS stylesheet associated with a theme name. + + Parameters: + theme_name (str): Name of the theme stylesheet to load. + + Returns: + str: The stylesheet contents, or an empty string if the file is unavailable. + """ import os qss_path = os.path.join(os.path.dirname(__file__), f"{theme_name}.qss") if os.path.isfile(qss_path): diff --git a/mtk_gui/ui/tabs/bootloader_tab.py b/mtk_gui/ui/tabs/bootloader_tab.py index 6687f43..16f90a4 100644 --- a/mtk_gui/ui/tabs/bootloader_tab.py +++ b/mtk_gui/ui/tabs/bootloader_tab.py @@ -11,6 +11,9 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """ + Build the controls for bootloader state changes and vbmeta patching. + """ layout = QVBoxLayout(self) layout.setSpacing(12) @@ -62,20 +65,37 @@ def _setup_ui(self): @property def unlock_button(self) -> QPushButton: + """Provide the button used to unlock the bootloader. + + Returns: + QPushButton: The bootloader unlock button. + """ return self._unlock_btn @property def lock_button(self) -> QPushButton: + """Provides the button used to lock the bootloader. + + Returns: + QPushButton: The bootloader lock button. + """ return self._lock_btn @property def vbmeta_button(self) -> QPushButton: + """Return the button used to patch vbmeta.""" return self._vbmeta_btn @property def critical_mode(self) -> bool: + """Return whether critical mode is enabled.""" return self._critical_check.isChecked() @property def vbmeta_mode(self) -> int: + """Get the selected vbmeta patch mode. + + Returns: + int: The selected vbmeta patch mode. + """ return self._vbmeta_mode.currentData() diff --git a/mtk_gui/ui/tabs/device_tab.py b/mtk_gui/ui/tabs/device_tab.py index 87b7cbb..5cda503 100644 --- a/mtk_gui/ui/tabs/device_tab.py +++ b/mtk_gui/ui/tabs/device_tab.py @@ -18,6 +18,7 @@ def __init__(self, parent=None): self.set_color(Colors.LED_DISCONNECTED) def set_color(self, color: str): + """Set the indicator's background color.""" self.setStyleSheet( f"background-color: {color}; border-radius: 8px; border: 1px solid #555;" ) @@ -27,10 +28,14 @@ class DeviceTab(QWidget): """Device connection settings and info display.""" def __init__(self, parent=None): + """Initialize the device tab and build its user interface.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): + """ + Set up the device tab's status display, device information fields, and connection settings controls. + """ layout = QVBoxLayout(self) layout.setSpacing(12) @@ -145,11 +150,17 @@ def _setup_ui(self): layout.addStretch() def _browse_file(self, target: QLineEdit): + """Open a file chooser and populate the target field with the selected binary file path. + + Parameters: + target (QLineEdit): Field to update with the selected file path. + """ path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) def _browse_workdir(self): + """Selects a working directory and updates the working-directory field when a directory is chosen.""" d = QFileDialog.getExistingDirectory(self, "Select Working Directory") if d: self._workdir_input.setText(d) @@ -158,14 +169,26 @@ def _browse_workdir(self): @property def connect_button(self) -> QPushButton: + """Provide access to the device connection button. + + Returns: + QPushButton: The device connection button. + """ return self._connect_btn @property def disconnect_button(self) -> QPushButton: + """Return the button used to disconnect from the device.""" return self._disconnect_btn def get_settings(self) -> dict: - """Return current connection settings.""" + """ + Return the configured device connection settings. + + Returns: + dict: Settings containing normalized loader and preloader paths, the selected + serial port, working directory, and IoT and Stock DA mode flags. + """ serial = self._serial_combo.currentData() if serial is None and self._serial_combo.currentText() != "(auto-detect)": serial = self._serial_combo.currentText() @@ -180,7 +203,12 @@ def get_settings(self) -> dict: @Slot(object) def update_device_info(self, info): - """Update device info fields from DeviceInfo object.""" + """ + Update the displayed device information from a device-information object. + + Parameters: + info: Object containing the device chipset, hardware, software, boot, flash, MEID, and SoC details. + """ mapping = { "chipset": info.chipset, "hwcode": info.hwcode, @@ -199,7 +227,12 @@ def update_device_info(self, info): @Slot(object) def update_connection_state(self, state): - """Update status LED and label based on DeviceState.""" + """ + Update the connection status display and button availability for a device state. + + Parameters: + state: The current device connection state. + """ from mtk_gui.backend.device_manager import DeviceState state_map = { DeviceState.DISCONNECTED: ("Disconnected", Colors.LED_DISCONNECTED), diff --git a/mtk_gui/ui/tabs/efuse_tab.py b/mtk_gui/ui/tabs/efuse_tab.py index cf81808..ac1ff82 100644 --- a/mtk_gui/ui/tabs/efuse_tab.py +++ b/mtk_gui/ui/tabs/efuse_tab.py @@ -11,6 +11,7 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """Build the widget layout with a disabled read button and a read-only eFuse table.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -34,10 +35,20 @@ def _setup_ui(self): @property def read_button(self) -> QPushButton: + """Expose the button used to initiate eFuse reads. + + Returns: + QPushButton: The eFuse read button. + """ return self._read_btn def set_efuses(self, efuse_data: list): - """Set eFuse table. Each item: (index, address, value).""" + """ + Populate the eFuse table with index, address, and value rows. + + Parameters: + efuse_data (list): Rows containing `(index, address, value)` tuples. Integer addresses and values are displayed as eight-digit hexadecimal strings. + """ self._table.setRowCount(len(efuse_data)) for row, (idx, addr, val) in enumerate(efuse_data): self._table.setItem(row, 0, QTableWidgetItem(str(idx))) diff --git a/mtk_gui/ui/tabs/erase_tab.py b/mtk_gui/ui/tabs/erase_tab.py index 008b88e..7604dcd 100644 --- a/mtk_gui/ui/tabs/erase_tab.py +++ b/mtk_gui/ui/tabs/erase_tab.py @@ -11,10 +11,17 @@ class EraseTab(QWidget): def __init__(self, parent=None): + """ + Initialize the erase control widget and its user interface. + + Parameters: + parent: Optional parent widget. + """ super().__init__(parent) self._setup_ui() def _setup_ui(self): + """Builds the widget layout and initializes its partition and sector erase controls.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -88,13 +95,29 @@ def _setup_ui(self): @property def erase_button(self) -> QPushButton: + """Return the button used to initiate the erase operation. + + Returns: + QPushButton: The erase button. + """ return self._erase_btn @property def partition_list(self) -> PartitionList: + """Provide access to the partition selection widget. + + Returns: + PartitionList: The partition list widget used by the tab. + """ return self._partition_list def get_erase_params(self) -> dict: + """ + Collect the parameters selected for the current erase mode. + + Returns: + dict: A dictionary containing the erase mode and partition type, plus either the selected partitions or the starting sector and sector count. Empty sector fields are represented as zero. + """ mode = self._mode_group.checkedId() params = {"mode": mode, "parttype": self._parttype_combo.currentText()} if mode == 0: diff --git a/mtk_gui/ui/tabs/exploit_tab.py b/mtk_gui/ui/tabs/exploit_tab.py index db2be21..c55b67e 100644 --- a/mtk_gui/ui/tabs/exploit_tab.py +++ b/mtk_gui/ui/tabs/exploit_tab.py @@ -7,10 +7,16 @@ class ExploitTab(QWidget): def __init__(self, parent=None): + """Initialize the exploit operations tab and its controls.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): + """ + Build the exploit controls and add them to the widget layout. + + The controls include preloader crash, Kamakiri brute-force, payload and Stage2 execution, meta mode, and device control actions. Operation buttons are initially disabled. + """ layout = QVBoxLayout(self) layout.setSpacing(8) @@ -107,6 +113,11 @@ def _setup_ui(self): layout.addStretch() def _browse(self, target): + """Open a file chooser and set the selected path on the target field. + + Parameters: + target: The input field to receive the selected file path. + """ path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) @@ -133,6 +144,7 @@ def meta_button(self) -> QPushButton: @property def reset_button(self) -> QPushButton: + """Return the device reset button.""" return self._reset_btn @property @@ -141,18 +153,27 @@ def shutdown_button(self) -> QPushButton: @property def crash_mode(self) -> int: + """Return the selected preloader crash mode index.""" return self._crash_mode.currentIndex() @property def payload_file(self) -> str: + """Return the selected payload file path with surrounding whitespace removed.""" return self._payload_file.text().strip() @property def stage_file(self) -> str: + """Return the selected Stage2 file path with surrounding whitespace removed.""" return self._stage_file.text().strip() @property def stage_addr(self) -> int: + """ + Get the configured Stage2 load address. + + Returns: + int: The address parsed from hexadecimal text with a ``0x`` prefix or decimal text, defaulting to ``0x200000`` when empty. + """ text = self._stage_addr.text().strip() if not text: return 0x200000 @@ -160,9 +181,15 @@ def stage_addr(self) -> int: @property def meta_mode(self) -> str: + """Return the selected meta mode.""" return self._meta_mode.currentText() def set_enabled_all(self, enabled: bool): + """Enable or disable all exploit and device operation controls. + + Parameters: + enabled (bool): Whether the controls should be enabled. + """ for btn in (self._crash_btn, self._brute_btn, self._payload_btn, self._stage_btn, self._meta_btn, self._reset_btn, self._shutdown_btn): btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/imei_tab.py b/mtk_gui/ui/tabs/imei_tab.py index 034e41e..693e4d3 100644 --- a/mtk_gui/ui/tabs/imei_tab.py +++ b/mtk_gui/ui/tabs/imei_tab.py @@ -7,10 +7,12 @@ class ImeiTab(QWidget): def __init__(self, parent=None): + """Initialize the IMEI operation interface and its controls.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): + """Build the widget layout and initialize its IMEI, modem, and NVItem controls.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -110,23 +112,41 @@ def _setup_ui(self): layout.addStretch() def _browse_nv(self): + """Open a file picker and update the NV file path when a file is selected.""" path, _ = QFileDialog.getOpenFileName(self, "Select NV File", "", "All (*)") if path: self._nv_file.setText(path) @property def read_button(self) -> QPushButton: + """Return the button used to initiate IMEI reading. + + Returns: + QPushButton: The IMEI read button. + """ return self._read_btn @property def write_button(self) -> QPushButton: + """Return the button used to write IMEI values.""" return self._write_btn @property def patch_modem_button(self) -> QPushButton: + """Provides access to the modem patch action button. + + Returns: + QPushButton: The modem patch button. + """ return self._patch_modem_btn def set_imei_display(self, imei1: str, imei2: str = ""): + """Update the IMEI display with the primary and optional secondary IMEI values. + + Parameters: + imei1 (str): The primary IMEI value. + imei2 (str): The optional secondary IMEI value. + """ text = f"IMEI1: {imei1 or '—'}" if imei2: text += f"\nIMEI2: {imei2}" @@ -134,17 +154,30 @@ def set_imei_display(self, imei1: str, imei2: str = ""): @property def decrypt_button(self) -> QPushButton: + """Return the button used to decrypt NV files. + + Returns: + QPushButton: The NV file decryption button. + """ return self._decrypt_btn @property def encrypt_button(self) -> QPushButton: + """Return the NV item encryption button.""" return self._encrypt_btn @property def nv_file(self) -> str: + """Return the selected NV file path with surrounding whitespace removed.""" return self._nv_file.text().strip() def get_imei_params(self) -> dict: + """ + Collect the configured IMEI operation parameters. + + Returns: + dict: A dictionary containing trimmed values for IMEI1, IMEI2, seed, AES key, and product. + """ return { "imei1": self._imei1_input.text().strip(), "imei2": self._imei2_input.text().strip(), @@ -154,6 +187,11 @@ def get_imei_params(self) -> dict: } def set_enabled_all(self, enabled: bool): + """Enable or disable all IMEI, modem, and NV file operation buttons. + + Parameters: + enabled (bool): Whether the operation buttons should be enabled. + """ for btn in (self._read_btn, self._write_btn, self._patch_modem_btn, self._decrypt_btn, self._encrypt_btn): btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/keys_tab.py b/mtk_gui/ui/tabs/keys_tab.py index 519aab5..e29f04a 100644 --- a/mtk_gui/ui/tabs/keys_tab.py +++ b/mtk_gui/ui/tabs/keys_tab.py @@ -12,6 +12,9 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """ + Build the widget layout for directory selection, key generation, key display, and status messages. + """ layout = QVBoxLayout(self) layout.setSpacing(8) @@ -50,24 +53,46 @@ def _setup_ui(self): layout.addWidget(self._status) def _browse_dir(self): + """Select an output directory and update the directory input when a directory is chosen.""" d = QFileDialog.getExistingDirectory(self, "Select Output Directory") if d: self._dir_input.setText(d) @property def generate_button(self) -> QPushButton: + """Provide access to the key generation button. + + Returns: + QPushButton: The key generation button. + """ return self._gen_btn @property def output_directory(self) -> str: + """ + Return the selected output directory path. + + Returns: + str: The trimmed directory path, or "." when the input is empty. + """ return self._dir_input.text().strip() or "." def set_keys(self, keys: dict): - """Display generated keys in the table.""" + """ + Display key-value pairs in the table. + + Parameters: + keys (dict): Mapping of key names to their generated values. + """ self._table.setRowCount(len(keys)) for row, (key, value) in enumerate(keys.items()): self._table.setItem(row, 0, QTableWidgetItem(str(key))) self._table.setItem(row, 1, QTableWidgetItem(str(value))) def set_status(self, msg: str): + """Update the status message displayed by the widget. + + Parameters: + msg (str): Text to display as the current status message. + """ self._status.setText(msg) diff --git a/mtk_gui/ui/tabs/memory_tab.py b/mtk_gui/ui/tabs/memory_tab.py index b97480b..a9a799a 100644 --- a/mtk_gui/ui/tabs/memory_tab.py +++ b/mtk_gui/ui/tabs/memory_tab.py @@ -13,6 +13,7 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """Build the user interface for memory inspection, writing, and dump operations.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -107,6 +108,7 @@ def _setup_ui(self): layout.addWidget(dump_group) def _browse_dump_dir(self): + """Select an output directory and update the directory field when one is chosen.""" d = QFileDialog.getExistingDirectory(self, "Select Output Directory") if d: self._dump_dir.setText(d) @@ -119,26 +121,41 @@ def peek_button(self) -> QPushButton: @property def peek_save_button(self) -> QPushButton: + """Return the button used to read memory contents and save them to a file.""" return self._peek_save_btn @property def poke_button(self) -> QPushButton: + """Return the button used to write data to memory. + + Returns: + QPushButton: The memory write button. + """ return self._poke_btn @property def brom_button(self) -> QPushButton: + """Return the button used to initiate a BROM memory dump.""" return self._brom_btn @property def sram_button(self) -> QPushButton: + """ + Provide access to the SRAM dump button. + + Returns: + QPushButton: The SRAM dump button. + """ return self._sram_btn @property def dram_button(self) -> QPushButton: + """Return the button used to initiate a DRAM dump.""" return self._dram_btn @property def memdump_button(self) -> QPushButton: + """Return the button used to start a full memory dump.""" return self._memdump_btn @property @@ -146,6 +163,15 @@ def hex_viewer(self) -> HexViewer: return self._hex_viewer def get_peek_params(self) -> dict: + """ + Parse and return the configured memory peek parameters. + + Returns: + dict: A mapping containing the hexadecimal address under `"addr"`, the hexadecimal length under `"length"`, and the register-mode state under `"registers"`. + + Raises: + ValueError: If the address or length contains invalid hexadecimal text. + """ return { "addr": int(self._peek_addr.text() or "0", 16), "length": int(self._peek_len.text() or "0", 16), @@ -153,6 +179,12 @@ def get_peek_params(self) -> dict: } def get_poke_params(self) -> dict: + """ + Retrieve the address and data values for a memory write operation. + + Returns: + dict: A mapping containing the parsed hexadecimal address under `"addr"` and the trimmed data string under `"data"`. + """ return { "addr": int(self._poke_addr.text() or "0", 16), "data": self._poke_data.text().strip(), @@ -160,9 +192,20 @@ def get_poke_params(self) -> dict: @property def dump_directory(self) -> str: + """ + Get the configured memory dump output directory. + + Returns: + str: The trimmed output directory, or "." when the field is empty. + """ return self._dump_dir.text().strip() or "." def set_enabled_all(self, enabled: bool): + """Enable or disable all memory operation controls. + + Parameters: + enabled (bool): Whether the controls should be enabled. + """ for btn in (self._peek_btn, self._peek_save_btn, self._poke_btn, self._brom_btn, self._sram_btn, self._dram_btn, self._memdump_btn): btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/read_tab.py b/mtk_gui/ui/tabs/read_tab.py index 7cd4c4f..91467bb 100644 --- a/mtk_gui/ui/tabs/read_tab.py +++ b/mtk_gui/ui/tabs/read_tab.py @@ -12,10 +12,12 @@ class ReadTab(QWidget): def __init__(self, parent=None): + """Initialize the read-operation controls and configure their user interface.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): + """Build the read-operation controls and connect mode selection to the corresponding content page.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -149,11 +151,21 @@ def _setup_ui(self): layout.addLayout(btn_row) def _browse_dir(self, target: QLineEdit): + """Select an output directory and populate the target line edit when a directory is chosen. + + Parameters: + target (QLineEdit): Line edit to receive the selected directory path. + """ d = QFileDialog.getExistingDirectory(self, "Select Output Directory") if d: target.setText(d) def _browse_save(self, target: QLineEdit): + """Select an output file and update the target field when a path is chosen. + + Parameters: + target (QLineEdit): Field that receives the selected output-file path. + """ path, _ = QFileDialog.getSaveFileName(self, "Select Output File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) @@ -162,19 +174,41 @@ def _browse_save(self, target: QLineEdit): @property def read_button(self) -> QPushButton: + """Return the button used to initiate the read operation.""" return self._read_btn @property def partition_list(self) -> PartitionList: + """Expose the partition list widget used to select partitions. + + Returns: + PartitionList: The partition list widget. + """ return self._partition_list def get_mode(self) -> int: + """Return the numeric identifier of the selected read mode. + + Returns: + int: The selected read mode identifier. + """ return self._mode_group.checkedId() def get_parttype(self) -> str: + """Return the currently selected partition type. + + Returns: + str: The selected partition type. + """ return self._parttype_combo.currentText() def get_read_params(self) -> dict: + """ + Collect the settings for the selected read mode. + + Returns: + dict: A parameter dictionary containing the selected mode and partition type, along with mode-specific values. Offset and length are parsed as hexadecimal integers, while start sector and sector count are parsed as decimal integers; empty numeric fields produce zero. + """ mode = self.get_mode() params = {"mode": mode, "parttype": self.get_parttype()} if mode == 0: diff --git a/mtk_gui/ui/tabs/rpmb_tab.py b/mtk_gui/ui/tabs/rpmb_tab.py index 14f8172..4d8fa8f 100644 --- a/mtk_gui/ui/tabs/rpmb_tab.py +++ b/mtk_gui/ui/tabs/rpmb_tab.py @@ -7,10 +7,14 @@ class RpmbTab(QWidget): def __init__(self, parent=None): + """Initialize the RPMB interface widget and its controls.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): + """ + Builds the user interface for RPMB read, write, erase, and authentication controls. + """ layout = QVBoxLayout(self) layout.setSpacing(8) @@ -97,27 +101,54 @@ def _setup_ui(self): layout.addStretch() def _browse_save(self, target): + """Opens a file-save dialog and assigns the selected output path to the target field. + + Parameters: + target: The text field that receives the selected file path. + """ path, _ = QFileDialog.getSaveFileName(self, "Output File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) def _browse_open(self, target): + """Select an input binary file and assign its path to the target field. + + Parameters: + target: The text field that receives the selected file path. + """ path, _ = QFileDialog.getOpenFileName(self, "Input File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) @property def read_button(self) -> QPushButton: + """Return the button used to start RPMB read operations. + + Returns: + QPushButton: The RPMB read button. + """ return self._read_btn @property def write_button(self) -> QPushButton: + """Return the button used to initiate RPMB write operations.""" return self._write_btn @property def erase_button(self) -> QPushButton: + """Return the button used to erase the RPMB partition. + + Returns: + QPushButton: The RPMB erase button. + """ return self._erase_btn def set_enabled_all(self, enabled: bool): + """ + Enable or disable all RPMB operation buttons. + + Parameters: + enabled (bool): Whether the read, write, and erase buttons should be enabled. + """ for btn in (self._read_btn, self._write_btn, self._erase_btn): btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/server_tab.py b/mtk_gui/ui/tabs/server_tab.py index f3c5f0a..36e4e5e 100644 --- a/mtk_gui/ui/tabs/server_tab.py +++ b/mtk_gui/ui/tabs/server_tab.py @@ -12,6 +12,9 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """ + Build the key-server controls, status display, and read-only server log. + """ layout = QVBoxLayout(self) layout.setSpacing(8) @@ -56,9 +59,17 @@ def _setup_ui(self): @property def start_button(self) -> QPushButton: + """Expose the key server start button.""" return self._run_btn def set_status(self, running: bool, message: str = ""): + """ + Update the displayed server status and its visual styling. + + Parameters: + running (bool): Whether the server is currently running. + message (str): Optional status text. Defaults to the corresponding running or idle label. + """ if running: self._status_label.setText(message or "Running...") self._status_label.setStyleSheet("font-weight: bold; font-size: 14px; color: #44aa44;") @@ -67,4 +78,5 @@ def set_status(self, running: bool, message: str = ""): self._status_label.setStyleSheet("font-weight: bold; font-size: 14px;") def append_server_log(self, message: str): + """Append a message to the server log.""" self._server_log.appendPlainText(message) diff --git a/mtk_gui/ui/tabs/write_tab.py b/mtk_gui/ui/tabs/write_tab.py index 012bb1c..b58566b 100644 --- a/mtk_gui/ui/tabs/write_tab.py +++ b/mtk_gui/ui/tabs/write_tab.py @@ -12,10 +12,12 @@ class WriteTab(QWidget): def __init__(self, parent=None): + """Initialize the write-configuration widget and its user interface.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): + """Build the widget layout and controls for selecting flash-write modes and parameters.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -147,16 +149,36 @@ def _setup_ui(self): layout.addLayout(btn_row) def _browse_open(self, target: QLineEdit): + """ + Select a binary file and place its path in the target input field. + + Parameters: + target (QLineEdit): Input field to receive the selected file path. + """ path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) def _browse_dir(self, target: QLineEdit): + """Selects a directory and writes its path to the target input field. + + Parameters: + target (QLineEdit): Input field to receive the selected directory path. + """ d = QFileDialog.getExistingDirectory(self, "Select Directory") if d: target.setText(d) def _on_part_file_select(self, item: QTreeWidgetItem, column: int): + """Selects a binary file for the partition represented by the activated tree item. + + Parameters: + item (QTreeWidgetItem): Partition item whose file path is updated. + column (int): Activated tree column. + + Returns: + None + """ if column == 1: path, _ = QFileDialog.getOpenFileName(self, f"Select file for {item.text(0)}", "", "Binary (*.bin);;All (*)") if path: @@ -166,9 +188,16 @@ def _on_part_file_select(self, item: QTreeWidgetItem, column: int): @property def write_button(self) -> QPushButton: + """Return the destructive write button.""" return self._write_btn def set_partitions(self, partitions: list): + """ + Replace the partition list displayed in the widget. + + Parameters: + partitions (list): Partition objects whose names populate the partition tree. + """ self._part_tree.clear() for p in partitions: item = QTreeWidgetItem([p.name, ""]) @@ -176,12 +205,29 @@ def set_partitions(self, partitions: list): self._part_tree.addTopLevelItem(item) def get_mode(self) -> int: + """ + Get the selected flash-write mode. + + Returns: + int: The identifier of the selected mode. + """ return self._mode_group.checkedId() def get_parttype(self) -> str: + """Return the selected partition type. + + Returns: + str: The currently selected partition type. + """ return self._parttype_combo.currentText() def get_write_params(self) -> dict: + """ + Collect the configured parameters for the selected flash-write mode. + + Returns: + dict: A dictionary containing the selected mode, partition type, and mode-specific write parameters. + """ mode = self.get_mode() params = {"mode": mode, "parttype": self.get_parttype()} if mode == 0: diff --git a/mtk_gui/ui/widgets/confirmation_dialog.py b/mtk_gui/ui/widgets/confirmation_dialog.py index 11798c8..91b6433 100644 --- a/mtk_gui/ui/widgets/confirmation_dialog.py +++ b/mtk_gui/ui/widgets/confirmation_dialog.py @@ -9,6 +9,14 @@ class ConfirmationDialog(QDialog): """Shows a warning and requires typing 'YES' to confirm destructive ops.""" def __init__(self, title: str, message: str, parent=None): + """ + Initialize a modal confirmation dialog. + + Parameters: + title (str): The dialog window title. + message (str): The warning or confirmation message to display. + parent: The optional parent widget. + """ super().__init__(parent) self.setWindowTitle(title) self.setMinimumWidth(400) @@ -17,6 +25,12 @@ def __init__(self, title: str, message: str, parent=None): self._setup_ui(message) def _setup_ui(self, message: str): + """ + Build the confirmation dialog's warning message, input field, and action buttons. + + Parameters: + message (str): Warning text displayed to the user. + """ layout = QVBoxLayout(self) layout.setSpacing(12) @@ -52,20 +66,38 @@ def _setup_ui(self, message: str): layout.addLayout(btn_row) def _on_text_changed(self, text: str): + """Update the confirmation control based on whether the entered text matches `YES`.""" self._confirm_btn.setEnabled(text.strip().upper() == "YES") def _try_accept(self): + """ + Accepts the dialog when the user enters the required confirmation text. + """ if self._input.text().strip().upper() == "YES": self._confirmed = True self.accept() @property def confirmed(self) -> bool: + """Indicate whether the dialog was confirmed. + + Returns: + bool: `True` if the user entered the required confirmation text and accepted the dialog, `False` otherwise. + """ return self._confirmed @staticmethod def confirm(title: str, message: str, parent=None) -> bool: - """Convenience method. Returns True if confirmed.""" + """ + Display a modal confirmation dialog for a destructive operation. + + Parameters: + title (str): Dialog window title. + message (str): Warning message shown to the user. + + Returns: + bool: `true` if the user enters `YES` and confirms, `false` otherwise. + """ dialog = ConfirmationDialog(title, message, parent) dialog.exec() return dialog.confirmed diff --git a/mtk_gui/ui/widgets/hex_viewer.py b/mtk_gui/ui/widgets/hex_viewer.py index e56284e..7674057 100644 --- a/mtk_gui/ui/widgets/hex_viewer.py +++ b/mtk_gui/ui/widgets/hex_viewer.py @@ -18,7 +18,13 @@ def __init__(self, parent=None): self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) def set_data(self, data: bytes, base_addr: int = 0): - """Display bytes as hex dump with address and ASCII columns.""" + """ + Display byte data as a fixed-width hexadecimal dump with addresses and ASCII representation. + + Parameters: + data (bytes): Byte sequence to display. + base_addr (int): Address assigned to the first byte. + """ lines = [] for offset in range(0, len(data), self.BYTES_PER_LINE): chunk = data[offset:offset + self.BYTES_PER_LINE] @@ -44,4 +50,5 @@ def set_data(self, data: bytes, base_addr: int = 0): self.setPlainText("\n".join(lines)) def clear_data(self): + """Clear the displayed hexadecimal data.""" self.clear() diff --git a/mtk_gui/ui/widgets/log_panel.py b/mtk_gui/ui/widgets/log_panel.py index 31ab3a2..8f484e0 100644 --- a/mtk_gui/ui/widgets/log_panel.py +++ b/mtk_gui/ui/widgets/log_panel.py @@ -20,12 +20,16 @@ class LogPanel(QWidget): } def __init__(self, parent=None): + """Initialize the log panel with an empty message store and the lowest log-level threshold.""" super().__init__(parent) self._all_messages = [] self._min_level = logging.DEBUG self._setup_ui() def _setup_ui(self): + """ + Constructs the search, level filter, log display, and log management controls. + """ layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) @@ -65,6 +69,11 @@ def _setup_ui(self): @staticmethod def _monospace_font(): + """Create the monospace font used by the log display. + + Returns: + QFont: A 9-point Consolas font with a monospace style hint. + """ from PySide6.QtGui import QFont font = QFont("Consolas", 9) font.setStyleHint(QFont.StyleHint.Monospace) @@ -72,12 +81,24 @@ def _monospace_font(): @Slot(str, int) def append_log(self, message: str, level: int = logging.INFO): - """Add a log message.""" + """ + Add a log message and display it when it meets the active level and search filters. + + Parameters: + message (str): The log message to store. + level (int): The logging level associated with the message. + """ self._all_messages.append((message, level)) if level >= self._min_level and self._matches_search(message): self._append_colored(message, level) def _append_colored(self, message: str, level: int): + """Append a log message to the display using the color associated with its level. + + Parameters: + message (str): The log message to display. + level (int): The logging level used to select the message color. + """ color = self.LEVEL_COLORS.get(level, "#d4d4d4") fmt = QTextCharFormat() fmt.setForeground(QColor(color)) @@ -88,27 +109,46 @@ def _append_colored(self, message: str, level: int): self._log_view.ensureCursorVisible() def _on_level_changed(self, index: int): + """Update the minimum visible log level and refresh the displayed messages. + + Parameters: + index (int): Index of the selected log-level threshold. + """ levels = [logging.DEBUG, logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR] self._min_level = levels[index] self._apply_filter() def _matches_search(self, message: str) -> bool: + """ + Determine whether a log message matches the current search query. + + Parameters: + message (str): The log message to search. + + Returns: + bool: `true` if the search query is empty or appears in the message, `false` otherwise. + """ query = self._search.text().strip().lower() if not query: return True return query in message.lower() def _apply_filter(self): + """Rebuild the visible log view using the selected level threshold and search query.""" self._log_view.clear() for msg, level in self._all_messages: if level >= self._min_level and self._matches_search(msg): self._append_colored(msg, level) def clear(self): + """Clear all stored log messages and remove their displayed entries.""" self._all_messages.clear() self._log_view.clear() def _export(self): + """ + Export all stored log messages to a user-selected UTF-8 text file. + """ path, _ = QFileDialog.getSaveFileName(self, "Export Log", "mtk_log.txt", "Text (*.txt)") if path: with open(path, "w", encoding="utf-8") as f: diff --git a/mtk_gui/ui/widgets/partition_list.py b/mtk_gui/ui/widgets/partition_list.py index a79fbb6..b531de1 100644 --- a/mtk_gui/ui/widgets/partition_list.py +++ b/mtk_gui/ui/widgets/partition_list.py @@ -18,6 +18,7 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """Builds the widget layout with partition controls and a checkable partition tree.""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) @@ -54,7 +55,12 @@ def _setup_ui(self): layout.addWidget(self._tree) def set_partitions(self, partitions: list): - """Set partition list. Each item needs name, size, sectors attributes.""" + """ + Replace the displayed partition entries with the provided partitions. + + Parameters: + partitions (list): Partition objects with `name`, `size`, and `sectors` attributes. + """ self._tree.blockSignals(True) self._tree.clear() self._partitions = partitions @@ -81,6 +87,7 @@ def get_selected(self) -> list: return selected def _select_all(self): + """Select all partitions and emit the updated selection.""" self._tree.blockSignals(True) for i in range(self._tree.topLevelItemCount()): self._tree.topLevelItem(i).setCheckState(0, Qt.CheckState.Checked) @@ -88,6 +95,9 @@ def _select_all(self): self.selection_changed.emit(self.get_selected()) def _deselect_all(self): + """ + Deselect all partitions and emit the updated selection. + """ self._tree.blockSignals(True) for i in range(self._tree.topLevelItemCount()): self._tree.topLevelItem(i).setCheckState(0, Qt.CheckState.Unchecked) @@ -100,6 +110,15 @@ def _on_item_changed(self, item, column): @staticmethod def _format_size(size_bytes: int) -> str: + """ + Format a byte count using a binary size unit. + + Parameters: + size_bytes (int): The size in bytes. + + Returns: + str: The rounded size with a unit from bytes through terabytes, or "0 B" for nonpositive values. + """ if size_bytes <= 0: return "0 B" units = ("B", "KB", "MB", "GB", "TB") diff --git a/mtk_gui/ui/widgets/progress_panel.py b/mtk_gui/ui/widgets/progress_panel.py index f28f7e0..f97cd34 100644 --- a/mtk_gui/ui/widgets/progress_panel.py +++ b/mtk_gui/ui/widgets/progress_panel.py @@ -8,6 +8,7 @@ class ProgressPanel(QWidget): """Shows operation and overall progress with ETA/speed.""" def __init__(self, parent=None): + """Initialize the progress panel and its progress-tracking state.""" super().__init__(parent) self._start_time = 0 self._last_update = 0 @@ -15,6 +16,9 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """ + Build the progress panel's progress bar and informational labels. + """ layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) @@ -55,7 +59,13 @@ def start(self, status: str = "Working..."): @Slot(int, int) def update_progress(self, current: int, total: int): - """Update progress bar and compute ETA/speed.""" + """ + Update the progress display and estimate transfer speed and completion time. + + Parameters: + current (int): The current amount of completed progress. + total (int): The total amount of progress, or a value less than or equal to zero when the total is unavailable. + """ if total <= 0: self._op_bar.setRange(0, 0) # indeterminate return @@ -83,10 +93,20 @@ def update_progress(self, current: int, total: int): @Slot(str) def set_status(self, text: str): + """Update the displayed operation status text. + + Parameters: + text (str): The status text to display. + """ self._status_label.setText(text) def finish(self, status: str = "Done"): - """Mark operation complete.""" + """ + Mark the operation as complete and display its elapsed time. + + Parameters: + status (str): Text to display as the completion status. + """ self._op_bar.setValue(self._op_bar.maximum() or 100) self._status_label.setText(status) self._speed_label.setText("") @@ -103,6 +123,14 @@ def reset(self): @staticmethod def _format_speed(bps: float) -> str: + """Format a transfer rate using an appropriate byte-per-second unit. + + Parameters: + bps (float): Transfer rate in bytes per second. + + Returns: + str: The formatted rate in B/s, KB/s, or MB/s. + """ if bps >= 1024 * 1024: return f"{bps / (1024*1024):.1f} MB/s" if bps >= 1024: @@ -111,6 +139,14 @@ def _format_speed(bps: float) -> str: @staticmethod def _format_time(seconds: float) -> str: + """Format a duration as hours, minutes, and seconds. + + Parameters: + seconds (float): Duration to format. + + Returns: + str: The duration expressed using the largest applicable time units. + """ s = int(seconds) if s >= 3600: return f"{s // 3600}h {(s % 3600) // 60}m {s % 60}s" diff --git a/mtk_gui/ui/widgets/serial_port_dialog.py b/mtk_gui/ui/widgets/serial_port_dialog.py index 015cb82..28b2d29 100644 --- a/mtk_gui/ui/widgets/serial_port_dialog.py +++ b/mtk_gui/ui/widgets/serial_port_dialog.py @@ -8,6 +8,9 @@ class SerialPortDialog(QDialog): """Dialog for selecting a serial port.""" def __init__(self, parent=None): + """ + Initialize the serial port selection dialog and its interface. + """ super().__init__(parent) self.setWindowTitle("Select Serial Port") self.setMinimumWidth(300) @@ -15,6 +18,7 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): + """Build the dialog interface for serial port selection.""" layout = QVBoxLayout(self) layout.addWidget(QLabel("Available serial ports:")) @@ -41,6 +45,7 @@ def _setup_ui(self): layout.addLayout(btn_row) def _refresh_ports(self): + """Refresh the available serial port options in the selection list.""" self._combo.clear() self._combo.addItem("(auto-detect)", None) try: @@ -51,9 +56,15 @@ def _refresh_ports(self): pass def _accept(self): + """Store the selected serial port and close the dialog.""" self._selected_port = self._combo.currentData() self.accept() @property def selected_port(self): + """Return the serial port selected in the dialog. + + Returns: + str: The selected port identifier, or None for automatic detection. + """ return self._selected_port diff --git a/plugins/example_plugin.py b/plugins/example_plugin.py index 557c4b1..b637532 100644 --- a/plugins/example_plugin.py +++ b/plugins/example_plugin.py @@ -11,10 +11,12 @@ class ExamplePlugin(MtkPlugin): description = "Demonstrates the plugin API with a simple tab." def __init__(self): + """Initialize the plugin with no registered context or widget.""" self._ctx = None self._widget = None def register(self, ctx: PluginContext) -> None: + """Register the plugin with the application context and add its example tab.""" self._ctx = ctx self._widget = QWidget() @@ -35,6 +37,7 @@ def register(self, ctx: PluginContext) -> None: ctx.add_tab(self._widget, "Example") def _show_info(self): + """Log the connected device chipset or indicate that no device is connected.""" info = self._ctx.get_device_info() if info.get("chipset"): self._ctx.log(f"Connected device: {info['chipset']}") @@ -42,6 +45,12 @@ def _show_info(self): self._ctx.log("No device connected.") def on_device_connected(self, device_info: dict) -> None: + """ + Log a message when a device connects. + + Parameters: + device_info (dict): Device information, including an optional ``chipset`` value. + """ self._ctx.log(f"[Example Plugin] Device connected: {device_info.get('chipset', '?')}") def on_device_disconnected(self) -> None: From de39ee1cdd406093fa4ae4be507038e2c788d469 Mon Sep 17 00:00:00 2001 From: sudotsu Date: Thu, 6 Aug 2026 12:21:07 -0500 Subject: [PATCH 3/3] fix(backend): harden threading, validation, and UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend hardening: - Replace sys.exit monkey-patching with SystemExit catch in all wrapper methods - Single worker for multi-partition reads (no concurrent DA operations) - Worker emits exactly one terminal signal (finished/error/cancelled) - USB poll thread joined on shutdown, shutdown flag prevents post-close signals - Fix b"" * 32 → b"\x00" * 32 in nvitem_crypt/read_imei/write_imei defaults - Plugin loader rejects relative dirs when APPDATA is missing Input validation: - IMEI: 15-digit check, hex seed/aeskey even-length validation - Memory peek/poke: reject empty/invalid hex addresses - Read tab: validate offset/length/filename before backend call - RPMB write: require input file, validate sector params - Exploit stage_addr: always parse as hex UX polish: - Dismissable warning banners (erase/write tabs) with QSettings persistence - 40 hover tooltips across all 12 tabs - Expanded Help menu with mtkclient docs, wiki, XDA links - Expanded About dialog with version and feature summary - README.md with project rationale, install, build, and plugin docs Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 94 ++++ mtk_gui/app.py | 7 +- mtk_gui/backend/device_manager.py | 71 +-- mtk_gui/backend/mtk_wrapper.py | 672 ++++------------------- mtk_gui/backend/worker.py | 18 +- mtk_gui/main_window.py | 250 +++------ mtk_gui/ui/tabs/bootloader_tab.py | 5 + mtk_gui/ui/tabs/device_tab.py | 7 + mtk_gui/ui/tabs/efuse_tab.py | 1 + mtk_gui/ui/tabs/erase_tab.py | 12 +- mtk_gui/ui/tabs/exploit_tab.py | 9 +- mtk_gui/ui/tabs/imei_tab.py | 5 + mtk_gui/ui/tabs/keys_tab.py | 2 + mtk_gui/ui/tabs/memory_tab.py | 72 +-- mtk_gui/ui/tabs/read_tab.py | 80 ++- mtk_gui/ui/tabs/rpmb_tab.py | 22 + mtk_gui/ui/tabs/server_tab.py | 1 + mtk_gui/ui/tabs/write_tab.py | 12 +- mtk_gui/ui/widgets/dismissable_banner.py | 86 +++ 19 files changed, 549 insertions(+), 877 deletions(-) create mode 100644 README.md create mode 100644 mtk_gui/ui/widgets/dismissable_banner.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a06b7d1 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# MTK-GUI + +A standalone GUI for [mtkclient](https://github.com/bkerler/mtkclient) — the open-source MediaTek reverse engineering tool. + +## Why this exists + +mtkclient ships with `mtk_gui.py`, a 717-line single-file GUI that's effectively unmaintained. It works for basic operations but is fragile, hard to extend, and freezes during USB operations because everything runs on the main thread. + +MTK-GUI is a ground-up replacement built with PySide6. It's modular (12 tabs, each in its own file), runs all device I/O on background threads, and has a plugin system for extensibility. + +## Features + +- **12 operation tabs**: Device, Read, Write, Erase, Keys, Bootloader, Memory, RPMB, IMEI, Exploit, eFuse, Server +- **Non-blocking UI**: USB polling, partition loading, and all device operations run on background threads +- **Input validation**: Hex fields, IMEI digits, file paths — validated before reaching the backend +- **Destructive operation safety**: Confirmation dialogs with type-to-confirm for writes, erases, bootloader changes +- **Button-disable during operations**: Prevents concurrent conflicting operations on the same device session +- **Dark/light theme**: Toggle from the View menu, persisted across sessions +- **Plugin system**: Drop `.py` files in the `plugins/` directory to add tabs, menu items, or hooks +- **Dismissable warning banners**: Erase and Write tabs warn about destructive operations; dismiss persists +- **Tooltips**: Hover help explaining why settings exist and when to use them + +## Requirements + +- Python 3.9+ +- [mtkclient](https://github.com/bkerler/mtkclient) installed and on `sys.path` +- PySide6 >= 6.5 +- pyusb (for automatic device detection) + +## Installation + +```bash +# Clone this repo +git clone https://github.com/sudotsu/mtk_gui.git +cd mtk_gui + +# Install dependencies +pip install PySide6 mtkclient + +# Run +python run.py +``` + +## Building the executable + +```bash +pip install pyinstaller +pyinstaller mtk-gui.spec --noconfirm +``` + +The standalone executable will be at `dist/mtk-gui/mtk-gui.exe`. The entire `dist/mtk-gui/` folder is distributable. + +## Project structure + +``` +mtk_gui/ +├── app.py # QApplication entry point +├── main_window.py # Signal wiring, operation handlers +├── constants.py # App metadata, USB IDs, part types +├── backend/ +│ ├── device_manager.py # Connection state machine, threaded USB poll +│ ├── log_interceptor.py # GuiSignalProxy for mtkclient logging bridge +│ ├── mtk_wrapper.py # Clean facade over mtkclient DA operations +│ └── worker.py # QThread worker with cancel support +├── ui/ +│ ├── tabs/ # 12 tab widgets (one file each) +│ └── widgets/ # Reusable widgets (log panel, hex viewer, etc.) +├── plugins/ # Plugin base class and loader +└── theme/ # QSS dark/light stylesheets +``` + +## Plugins + +Create a Python file in `plugins/` that subclasses `MtkPlugin`: + +```python +from mtk_gui.plugins.base_plugin import MtkPlugin + +class MyPlugin(MtkPlugin): + name = "My Plugin" + version = "1.0" + + def register(self, ctx): + ctx.add_menu_item("My Plugin/Do Thing", self.do_thing) + + def do_thing(self): + print("Plugin action") +``` + +Plugins are loaded at startup from the app-local `plugins/` directory and from the user config directory (`%APPDATA%/mtk-gui/plugins` on Windows, `~/.config/mtk-gui/plugins` on Linux). + +## License + +GPLv3 — same as mtkclient. diff --git a/mtk_gui/app.py b/mtk_gui/app.py index 3d342e8..1052a6b 100644 --- a/mtk_gui/app.py +++ b/mtk_gui/app.py @@ -23,10 +23,13 @@ def get_plugin_dirs() -> list: dirs.append(os.path.join(app_dir, "plugins")) # User config plugins dir if sys.platform == "win32": - config_dir = os.path.join(os.environ.get("APPDATA", ""), "mtk-gui", "plugins") + appdata = os.environ.get("APPDATA") + if appdata and os.path.isabs(appdata): + dirs.append(os.path.join(appdata, "mtk-gui", "plugins")) else: config_dir = os.path.join(os.path.expanduser("~"), ".config", "mtk-gui", "plugins") - dirs.append(config_dir) + if os.path.isabs(config_dir): + dirs.append(config_dir) return dirs diff --git a/mtk_gui/backend/device_manager.py b/mtk_gui/backend/device_manager.py index dc8bd69..be16c04 100644 --- a/mtk_gui/backend/device_manager.py +++ b/mtk_gui/backend/device_manager.py @@ -1,11 +1,10 @@ """Connection state machine and USB polling.""" import logging import threading -import time from enum import Enum, auto from typing import Optional -from PySide6.QtCore import QObject, QTimer, Signal +from PySide6.QtCore import QObject, Signal from mtk_gui.backend.log_interceptor import LogSignalEmitter, GuiSignalProxy from mtk_gui.backend.mtk_wrapper import MtkWrapper, DeviceInfo @@ -38,6 +37,7 @@ def __init__(self, parent=None): self._wrapper: Optional[MtkWrapper] = None self._device_info = DeviceInfo() self._workers: list[Worker] = [] + self._shutting_down = False # Log signal bridge self._log_emitter = LogSignalEmitter() @@ -60,35 +60,17 @@ def __init__(self, parent=None): @property def state(self) -> DeviceState: - """ - Get the device manager's current connection state. - - Returns: - DeviceState: The current device connection state. - """ return self._state @property def device_info(self) -> DeviceInfo: - """Return information about the currently connected device.""" return self._device_info @property def wrapper(self) -> Optional[MtkWrapper]: - """ - Provide the current MediaTek device wrapper. - - Returns: - MtkWrapper or None: The active wrapper, or None if no wrapper is configured. - """ return self._wrapper def _set_state(self, new_state: DeviceState): - """Update the manager's state and emit a change notification when it changes. - - Parameters: - new_state (DeviceState): The state to assign to the manager. - """ if new_state != self._state: self._state = new_state self.state_changed.emit(new_state) @@ -103,13 +85,14 @@ def start_polling(self): self._poll_thread.start() def stop_polling(self): - """Stop USB device polling.""" + """Stop USB device polling and wait for thread to exit.""" self._poll_stop.set() + if self._poll_thread and self._poll_thread.is_alive(): + self._poll_thread.join(timeout=2.0) + self._poll_thread = None def _poll_usb_loop(self): - """ - Poll for a configured MediaTek USB preloader device until one is detected or polling is stopped. - """ + """Background thread: poll USB until device found or stopped.""" try: import usb.core from mtk_gui.constants import MTK_USB_VID, MTK_PRELOADER_PIDS @@ -135,11 +118,9 @@ def _poll_usb_loop(self): self._poll_stop.wait(interval) def _on_usb_detected(self, pid: int): - """Update the device state and log the detected MediaTek USB device. - - Parameters: - pid (int): Product ID of the detected USB device. - """ + """Handle USB detection on the main thread (via signal).""" + if self._shutting_down: + return from mtk_gui.constants import MTK_USB_VID self._set_state(DeviceState.PRELOADER) self.log_message.emit( @@ -170,12 +151,7 @@ def connect_device(self): worker.start() def _do_connect(self, worker=None): - """ - Initialize the device wrapper and attempt to connect to the device. - - Returns: - bool: `True` if the connection succeeds, `False` otherwise. - """ + """Run connection in worker thread.""" self._wrapper.init( preloader=self.preloader_path, loader=self.da_loader_path, @@ -188,11 +164,6 @@ def _do_connect(self, worker=None): return success def _on_connect_finished(self, success): - """Update the device state and emit connection results after a connection attempt completes. - - Parameters: - success (bool): Whether the device connection succeeded. - """ if success: self._device_info = self._wrapper.get_device_info() self._set_state(DeviceState.CONNECTED) @@ -203,11 +174,6 @@ def _on_connect_finished(self, success): self.log_message.emit("Connection failed.", logging.ERROR) def _on_connect_error(self, msg: str): - """Set the device state to error and emit the connection error message. - - Parameters: - msg (str): Description of the connection error. - """ self._set_state(DeviceState.ERROR) self.log_message.emit(f"Connection error: {msg}", logging.ERROR) @@ -221,8 +187,12 @@ def disconnect_device(self): self._set_state(DeviceState.DISCONNECTED) self.device_info_updated.emit(self._device_info) self.log_message.emit("Device disconnected.", logging.INFO) - # Resume polling for next device - self.start_polling() + if not self._shutting_down: + self.start_polling() + + def begin_shutdown(self): + """Called during app close — prevents post-shutdown side effects.""" + self._shutting_down = True def create_worker(self, func, *args, **kwargs) -> Worker: """Create and track a worker for a backend operation.""" @@ -231,18 +201,19 @@ def create_worker(self, func, *args, **kwargs) -> Worker: return w def _track_worker(self, worker: Worker): - """Track a worker and remove it from the active worker list when it finishes or reports an error.""" self._workers.append(worker) worker.finished.connect(lambda _: self._untrack_worker(worker)) worker.error.connect(lambda _: self._untrack_worker(worker)) + worker.cancelled.connect(lambda: self._untrack_worker(worker)) def _untrack_worker(self, worker: Worker): - """Remove a worker from the active worker list.""" if worker in self._workers: self._workers.remove(worker) def _cancel_all_workers(self): - """Cancel all active workers and clear the worker registry.""" for w in list(self._workers): w.cancel() + # Wait briefly for workers to finish + for w in list(self._workers): + w.wait(2000) self._workers.clear() diff --git a/mtk_gui/backend/mtk_wrapper.py b/mtk_gui/backend/mtk_wrapper.py index 893cd52..8e4a36c 100644 --- a/mtk_gui/backend/mtk_wrapper.py +++ b/mtk_gui/backend/mtk_wrapper.py @@ -1,7 +1,7 @@ """Clean facade over mtkclient API. No Qt dependency.""" import logging +import math import os -import sys from dataclasses import dataclass, field from typing import Optional @@ -11,10 +11,6 @@ from mtkclient.Library.DA.mtk_daloader import DAloader -class OperationAborted(Exception): - """Raised instead of sys.exit() inside library code.""" - - @dataclass class DeviceInfo: chipset: str = "" @@ -42,54 +38,24 @@ class PartitionInfo: class MtkWrapper: - """Wraps mtkclient init/connect and all DA operations.""" + """Wraps mtkclient init/connect and all DA operations. + + All methods catch SystemExit (which mtkclient raises via sys.exit) + and convert it to a controlled failure — no global monkey-patching. + """ def __init__(self, gui_signal=None, gui_progress=None, status_signal=None): - """Initialize the wrapper with optional GUI and status callbacks.""" self.mtk: Optional[Mtk] = None self.da_handler: Optional[DaHandler] = None self.config: Optional[MtkConfig] = None self._gui_signal = gui_signal self._gui_progress = gui_progress self._status_signal = status_signal - self._original_exit = sys.exit - - def _patch_sys_exit(self): - """Monkey-patch sys.exit() to raise OperationAborted instead.""" - def patched_exit(code=0): - """ - Intercept a system-exit request by raising an operation-aborted exception. - - Parameters: - code: The requested exit status. - - Raises: - OperationAborted: Always raised with the requested exit status. - """ - raise OperationAborted(f"sys.exit({code}) intercepted") - sys.exit = patched_exit - - def _restore_sys_exit(self): - """Restore the original ``sys.exit`` implementation.""" - sys.exit = self._original_exit def init(self, preloader: str = None, loader: str = None, serial_port: str = None, iot: bool = False, stock: bool = False, work_dir: str = ".") -> Mtk: - """ - Initialize the MediaTek client with the specified connection and loader settings. - - Parameters: - preloader (str): Optional path to a preloader file. - loader (str): Optional download agent path. - serial_port (str): Optional serial port name. - iot (bool): Whether to enable IoT mode. - stock (bool): Whether to use stock device settings. - work_dir (str): Directory used for hardware parameter data. - - Returns: - Mtk: The initialized MediaTek client. - """ + """Initialize MtkConfig and Mtk instance.""" loglevel = logging.INFO self.config = MtkConfig( loglevel=loglevel, @@ -111,18 +77,9 @@ def init(self, preloader: str = None, loader: str = None, return self.mtk def connect(self, work_dir: str = ".") -> bool: - """ - Connect to the device through the Download Agent. - - Parameters: - work_dir (str): Directory used for connection-related files. - - Returns: - bool: `True` if the connection and DA configuration succeed, `False` otherwise. - """ + """Connect to device via DA. Returns True on success.""" if self.mtk is None: return False - self._patch_sys_exit() try: self.da_handler = DaHandler(self.mtk, logging.INFO) mtk = self.da_handler.connect(self.mtk, work_dir) @@ -131,10 +88,8 @@ def connect(self, work_dir: str = ".") -> bool: self.mtk = mtk self.mtk = self.da_handler.configure_da(self.mtk) return True - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def disconnect(self): """Clean up connection state.""" @@ -143,12 +98,7 @@ def disconnect(self): self.config = None def get_device_info(self) -> DeviceInfo: - """ - Gather information about the connected device. - - Returns: - DeviceInfo: Device metadata, or an empty instance when no device is initialized. - """ + """Gather device info from current connection.""" info = DeviceInfo() if self.mtk is None or self.config is None: return info @@ -181,15 +131,10 @@ def get_device_info(self) -> DeviceInfo: return info def get_partitions(self, worker=None) -> list: - """Retrieve partition metadata from the device's GPT. - - Returns: - list: Partition information, or an empty list when GPT data is unavailable. - """ + """Get list of partitions from GPT.""" partitions = [] if self.da_handler is None: return partitions - try: gpt = self.mtk.daloader.get_gpt() if gpt is None: @@ -203,7 +148,7 @@ def get_partitions(self, worker=None) -> list: size=partition.sectors * self.config.SECTOR_SIZE_IN_BYTES, ) partitions.append(p) - except Exception: + except (SystemExit, Exception): pass return partitions @@ -211,546 +156,270 @@ def get_partitions(self, worker=None) -> list: def read_partition(self, partition_name: str, filename: str, parttype: str = "user", worker=None) -> bool: - """ - Read a partition from the connected device into a file. - - Parameters: - partition_name (str): Name of the partition to read. - filename (str): Destination file path. - parttype (str): Partition type used for the read operation. - worker: Optional worker associated with the operation. - - Returns: - bool: `True` if the partition is read successfully, `False` otherwise. - """ + """Read a single partition to file.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_read( partition_name, parttype, filename, display=True ) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() + + def read_selected_partitions(self, partitions: list, directory: str, + parttype: str = "user", + dump_gpt: bool = False, worker=None) -> dict: + """Read multiple partitions sequentially in a single worker. + Returns dict of {name: success_bool}. + """ + if self.da_handler is None: + return {} + results = {} + try: + if dump_gpt: + self.da_handler.da_gpt(directory, display=True) + except SystemExit: + pass + for name in partitions: + filename = os.path.join(directory, f"{name}.bin") + try: + results[name] = bool(self.da_handler.da_read( + name, parttype, filename, display=True)) + except SystemExit: + results[name] = False + return results def read_partitions(self, directory: str, parttype: str = "user", skip: list = None, worker=None) -> bool: - """ - Read selected device partitions into a directory. - - Parameters: - directory (str): Destination directory for the partition images. - parttype (str): Partition type to read. - skip (list): Partition names to exclude. - worker: Optional worker used by the underlying read operation. - - Returns: - bool: `True` if the operation succeeds, `False` if the device is unavailable or the operation is aborted. - """ + """Read all partitions to directory.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_rl(directory, parttype, skip or [], display=True) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def read_flash(self, filename: str, parttype: str = "user", offset: int = None, length: int = None, worker=None) -> bool: - """Read flash data or a specified range to a file. - - Parameters: - parttype (str): Flash region to read. - offset (int): Starting byte offset, when reading a range. - length (int): Number of bytes to read, when reading a range. - - Returns: - bool: `True` if the read succeeds, `False` otherwise. - """ + """Read full flash or offset range to file.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_rf(filename, parttype, offset, length, display=True) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def read_sectors(self, start: int, sectors: int, filename: str, parttype: str = "user", worker=None) -> bool: - """ - Read a sector range from the connected device into a file. - - Parameters: - start (int): Starting sector. - sectors (int): Number of sectors to read. - filename (str): Destination file path. - parttype (str): Partition type containing the sectors. - - Returns: - bool: `true` if the read succeeds, `false` otherwise. - """ + """Read by sector range.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_rs(start, sectors, filename, parttype, display=True) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def read_offset(self, start: int, length: int, filename: str, parttype: str = "user", worker=None) -> bool: - """ - Read a byte range from the device into a file. - - Parameters: - start (int): The starting byte offset. - length (int): The number of bytes to read. - filename (str): The destination file path. - parttype (str): The partition type containing the byte range. - - Returns: - bool: `True` if the read succeeds, `False` if no device connection is available or the operation is aborted. - """ + """Read by byte offset range.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_ro(start, length, filename, parttype, display=True) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() # ── Flash write operations ──────────────────────────── def write_partitions(self, parttype: str, filenames: list, partitions: list, worker=None) -> bool: - """ - Write files to the specified device partitions. - - Parameters: - parttype (str): Partition type used for the write operation. - filenames (list): Files containing the data to write. - partitions (list): Target partition names. - worker: Optional worker associated with the operation. - - Returns: - bool: `True` if the write succeeds, `False` if no device connection is available or the operation is aborted. - """ + """Write files to named partitions.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_write(parttype, filenames, partitions) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def write_flash(self, filenames: list, parttype: str = "user", worker=None) -> bool: - """Write one or more full flash images to the device. - - Parameters: - filenames (list): Flash image files to write. - parttype (str): Partition type targeted by the images. - - Returns: - bool: `True` if the write operation succeeds, `False` otherwise. - """ + """Write full flash image(s).""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_wf(filenames, parttype) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def write_from_directory(self, parttype: str, directory: str, worker=None) -> bool: - """ - Write matching partition image files from a directory to the device. - - Parameters: - parttype (str): Partition type used to select the files. - directory (str): Directory containing the partition files. - - Returns: - bool: `True` if the write operation succeeds, `False` otherwise. - """ + """Write all matching partition files from directory.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_wl(parttype, directory) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def write_offset(self, start: int, length: int, filename: str, parttype: str = "user", worker=None) -> bool: - """ - Write data from a file to a device at a byte offset. - - Parameters: - start (int): Starting byte offset. - length (int): Number of bytes to write. - filename (str): Path to the input file. - parttype (str): Partition type containing the offset. - worker: Optional progress worker. - - Returns: - bool: `True` if the write succeeds, `False` if no device connection is available or the operation is aborted. - """ + """Write to a byte offset.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_wo(start, length, filename, parttype) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() # ── Erase operations ────────────────────────────────── def erase_partitions(self, partitions: list, parttype: str = "user", worker=None) -> bool: - """ - Erase the specified partitions on the connected device. - - Parameters: - partitions (list): Names of the partitions to erase. - parttype (str): Partition type containing the named partitions. - - Returns: - bool: `True` if the erase operation succeeds, `False` otherwise. - """ + """Erase named partitions.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_erase(partitions, parttype) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def erase_sectors(self, sector: int, sectors: int, parttype: str = "user", worker=None) -> bool: - """Erase a range of sectors on the specified partition type. - - Parameters: - sector (int): The starting sector. - sectors (int): The number of sectors to erase. - parttype (str): The partition type containing the sectors. - worker: An optional operation worker. - - Returns: - bool: `True` if the erase operation succeeds, `False` otherwise. - """ + """Erase by sector range.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_ess(sector, sectors, parttype) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() # ── Security operations ─────────────────────────────── def generate_keys(self, directory: str = ".", worker=None) -> bool: - """Generate hardware keys in the specified directory. - - Parameters: - directory (str): Directory where the generated hardware keys are stored. - worker: Optional worker used by the key-generation operation. - - Returns: - bool: `True` if key generation succeeds, `False` otherwise. - """ + """Generate hardware keys.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: self.config.hwparam_path = directory return self.mtk.daloader.keys() - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def seccfg_unlock(self, lock: bool = False, critical: bool = False, worker=None) -> bool: - """Set the device's secure configuration to lock or unlock mode. - - Parameters: - lock (bool): Whether to lock the bootloader; otherwise, unlock it. - critical (bool): Whether to apply the operation to critical partitions. - - Returns: - bool: `True` if the operation succeeds, `False` if the device is unavailable or the operation is aborted. - """ + """Lock or unlock bootloader via seccfg.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: lockflag = "lock" if lock else "unlock" return self.mtk.daloader.seccfg(lockflag, critical) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def vbmeta_patch(self, vbmode: int = 3, worker=None) -> bool: - """ - Patch verified boot metadata using the specified mode. - - Parameters: - vbmode (int): Verified boot patch mode. - - Returns: - bool: `True` if the patch succeeds, `False` otherwise. - """ + """Patch vbmeta for verified boot bypass.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_vbmeta(vbmode, display=True) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() # ── Memory operations ───────────────────────────────── def peek(self, addr: int, length: int, filename: str = "", registers: bool = False, worker=None): - """ - Read a region of device memory. - - Parameters: - addr (int): Starting memory address. - length (int): Number of bytes to read. - filename (str): Optional file path for saving the data. - registers (bool): Whether to include register data. - - Returns: - bytes or None: The memory contents, or `None` if the device is unavailable or the operation is aborted. - """ + """Read memory at address.""" if self.da_handler is None: return None - self._patch_sys_exit() try: return self.da_handler.da_peek(addr, length, filename, registers) - except OperationAborted: + except SystemExit: return None - finally: - self._restore_sys_exit() def poke(self, addr: int, data: str, filename: str = "", worker=None) -> bool: - """ - Write data to a device memory address. - - Parameters: - addr (int): Memory address to write. - data (str): Data to write. - filename (str): Optional file containing the data. - - Returns: - bool: `True` if the write succeeds, `False` otherwise. - """ + """Write memory at address.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_poke(addr, data, filename) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def dump_brom(self, filename: str, worker=None) -> bool: - """Dump the device's Boot ROM contents to a file. - - Parameters: - filename (str): Path of the output file. - worker: Optional worker used by the underlying dump operation. - - Returns: - bool: `True` if the dump succeeds, `False` if the device is unavailable or the operation is aborted. - """ + """Dump BROM.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_brom(filename) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() # ── RPMB operations ─────────────────────────────────── def read_rpmb(self, filename: str = None, sector: str = None, sectors: str = None, worker=None): - """ - Read data from RPMB storage. - - Parameters: - filename (str, optional): Destination file for the RPMB data. - sector (str, optional): Starting RPMB sector. - sectors (str, optional): Number of sectors to read. - - Returns: - The result of the RPMB read operation, or `None` if the device is unavailable or the operation is aborted. - """ + """Read RPMB.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return None - self._patch_sys_exit() try: return self.mtk.daloader.read_rpmb(filename, sector, sectors) - except OperationAborted: + except SystemExit: return None - finally: - self._restore_sys_exit() def write_rpmb(self, filename: str = None, sector: int = 0, sectors: int = None, worker=None) -> bool: - """ - Write data to RPMB storage. - - Parameters: - filename (str): Path to the file containing data to write. - sector (int): Starting RPMB sector. - sectors (int): Number of sectors to write. - - Returns: - bool: `True` if the write succeeds, `False` otherwise. - """ + """Write RPMB.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: return self.mtk.daloader.write_rpmb(filename, sector, sectors) - except OperationAborted: + except SystemExit: + return False + + def erase_rpmb(self, sector: int = 0, sectors: int = None, worker=None) -> bool: + """Erase RPMB.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + try: + return self.mtk.daloader.erase_rpmb(sector, sectors) + except SystemExit: + return False + + def auth_rpmb(self, rpmbkey: bytes = None, worker=None) -> bool: + """Authenticate RPMB with key.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + try: + return self.mtk.daloader.auth_rpmb(rpmbkey) + except SystemExit: return False - finally: - self._restore_sys_exit() # ── eFuse ───────────────────────────────────────────── def read_efuses(self, worker=None): - """ - Read the device's eFuse values. - - Parameters: - worker: Optional worker context used by the eFuse operation. - - Returns: - The eFuse values, or `None` if the device is unavailable or the operation is aborted. - """ + """Read eFuse values.""" if self.da_handler is None: return None - self._patch_sys_exit() try: return self.da_handler.da_efuse() - except OperationAborted: + except SystemExit: return None - finally: - self._restore_sys_exit() # ── GPT ─────────────────────────────────────────────── def dump_gpt(self, directory: str, worker=None) -> bool: - """ - Dump the device's GPT data to a directory. - - Parameters: - directory (str): Destination directory for the GPT data. - worker: Optional worker associated with the operation. - - Returns: - bool: `True` if the GPT data is dumped successfully, `False` otherwise. - """ + """Dump GPT to directory.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_gpt(directory, display=True) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() - - def erase_rpmb(self, sector: int = 0, sectors: int = None, worker=None) -> bool: - """ - Erase a range of sectors from RPMB storage. - - Parameters: - sector (int): The first sector to erase. - sectors (int): The number of sectors to erase. - - Returns: - bool: `True` if the operation succeeds, `False` otherwise. - """ - if self.mtk is None or not hasattr(self.mtk, 'daloader'): - return False - self._patch_sys_exit() - try: - return self.mtk.daloader.erase_rpmb(sector, sectors) - except OperationAborted: - return False - finally: - self._restore_sys_exit() - - def auth_rpmb(self, rpmbkey: bytes = None, worker=None) -> bool: - """ - Authenticate the device's RPMB using the supplied key. - - Parameters: - rpmbkey (bytes): Key used to authenticate RPMB access. - - Returns: - bool: `true` if authentication succeeds, `false` otherwise. - """ - if self.mtk is None or not hasattr(self.mtk, 'daloader'): - return False - self._patch_sys_exit() - try: - return self.mtk.daloader.auth_rpmb(rpmbkey) - except OperationAborted: - return False - finally: - self._restore_sys_exit() # ── IMEI ────────────────────────────────────────────── - def read_imei(self, seed: bytes = b"", aeskey: bytes = b"" * 32, + def read_imei(self, seed: bytes = b"", aeskey: bytes = b"\x00" * 32, worker=None) -> list: - """ - Read IMEI values from the device's `nvdata` partition. - - Parameters: - seed (bytes): Seed used to process the NVItem data. - aeskey (bytes): AES key used to process the NVItem data. - worker: Optional worker context for the operation. - - Returns: - list: Tuples containing each decoded IMEI and whether its check digit is valid. - """ + """Read IMEI(s) from nvdata partition. Returns list of (imei, valid) tuples.""" if self.da_handler is None: return [] - self._patch_sys_exit() try: from mtkclient.Library.utils import find_binary from mtkclient.Library.mtk_crypto import decode_imei, calc_checksum, is_luhn_valid @@ -783,37 +452,21 @@ def read_imei(self, seed: bytes = b"", aeskey: bytes = b"" * 32, valid = is_luhn_valid(imei) imeis.append((imei, valid)) return imeis - except OperationAborted: + except SystemExit: return [] - finally: - self._restore_sys_exit() def write_imei(self, imeis: list, seed: bytes = b"", - aeskey: bytes = b"" * 32, product: str = "", + aeskey: bytes = b"\x00" * 32, product: str = "", worker=None) -> bool: - """ - Write IMEI values to the device's `nvdata` partition. - - Parameters: - imeis (list): IMEI strings to write. - seed (bytes): Optional encryption seed. - aeskey (bytes): Optional AES key. - product (str): Optional product identifier. - worker: Optional worker context for the operation. - - Returns: - bool: `True` if the `nvdata` partition is updated successfully, `False` otherwise. - """ + """Write IMEI(s) to nvdata partition. imeis: list of IMEI strings.""" if self.da_handler is None or not imeis: return False - self._patch_sys_exit() try: from mtkclient.Library.utils import find_binary from mtkclient.Library.mtk_crypto import ( encode_imei, calc_checksum, make_luhn_checksum, ) - # Fix checksums fixed = [] for imei_str in imeis: pre = imei_str[:14] + "0" @@ -853,193 +506,105 @@ def write_imei(self, imeis: list, seed: bytes = b"", return self.da_handler.da_write_partition( partitionname="nvdata", data=bytes(nvdata) ) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def nvitem_crypt(self, filename: str, encrypt: bool = False, worker=None): - """ - Encrypt or decrypt the contents of an NVItem file. - - Parameters: - filename (str): Path to the NVItem file. - encrypt (bool): Whether to encrypt the file contents; otherwise, decrypt them. - worker: Optional worker context for the operation. - - Returns: - bytes: The processed NVItem data, or `None` if the operation is unavailable or aborted. - """ + """Encrypt or decrypt an NVItem file. Returns processed bytes or None.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return None - self._patch_sys_exit() try: with open(filename, "rb") as rf: data = rf.read() return self.mtk.daloader.nvitem( data=data, encrypt=encrypt, otp=self.mtk.config.get_otp(), - seed=b"", aeskey=b"" * 32, display=False, + seed=b"", aeskey=b"\x00" * 32, display=False, ) - except OperationAborted: + except SystemExit: return None - finally: - self._restore_sys_exit() # ── Exploit ─────────────────────────────────────────── def crash_preloader(self, mode: int = None, worker=None) -> bool: - """ - Crash the preloader to enter BROM mode. - - Parameters: - mode (int, optional): Crash mode to use. If omitted, the operation selects the available modes. - worker: Optional worker context for the operation. - - Returns: - `True` if the preloader crash succeeds, `False` otherwise. - """ + """Crash preloader/DA to get into BROM mode. mode=None iterates all modes.""" if self.mtk is None: return False - self._patch_sys_exit() try: self.mtk.crasher(mode=mode) return True - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def run_payload(self, filename: str, worker=None) -> bool: - """ - Load and execute a payload binary on the connected device. - - Parameters: - filename (str): Path to the payload binary. - - Returns: - bool: `True` if the payload is loaded successfully, `False` otherwise. - """ + """Load and run a payload binary via boot_to.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: with open(filename, "rb") as f: data = f.read() return self.mtk.daloader.boot_to(addr=0x200000, data=data) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def run_stage2(self, filename: str, addr: int = 0x200000, worker=None) -> bool: - """ - Load a stage 2 payload into device memory. - - Parameters: - filename (str): Path to the stage 2 payload. - addr (int): Device memory address at which to load the payload. - - Returns: - bool: `true` if the payload loads successfully, `false` otherwise. - """ + """Load stage2 payload at address.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: with open(filename, "rb") as f: data = f.read() return self.mtk.daloader.boot_to(addr=addr, data=data) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def set_meta_mode(self, porttype: str = "off", worker=None) -> bool: - """ - Set the device's meta mode. - - Parameters: - porttype (str): Meta mode to configure, such as ``"off"``, ``"usb"``, or ``"uart"``. - - Returns: - bool: ``True`` if the mode was configured successfully, ``False`` otherwise. - """ + """Set meta mode (off/usb/uart).""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: return self.mtk.daloader.setmetamode(porttype) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def shutdown(self, worker=None) -> bool: - """Powers down the connected device. - - Parameters: - worker: Optional operation worker passed to the device shutdown handler. - - Returns: - `True` if the shutdown operation succeeds, `False` otherwise. - """ + """Shutdown device.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: return self.mtk.daloader.shutdown() - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def reset_device(self, worker=None) -> bool: """Reset/reboot device.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: return self.mtk.daloader.shutdown( bootmode=self.mtk.daloader.ShutDownModes.HOME_SCREEN ) - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def keyserver(self, worker=None) -> bool: """Run key server.""" if self.mtk is None or not hasattr(self.mtk, 'daloader'): return False - self._patch_sys_exit() try: return self.mtk.daloader.keyserver() - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() def patch_modem(self, worker=None) -> bool: - """ - Patch the modem partition on the connected device. - - Parameters: - worker: Optional worker context used by the modem patching operation. - - Returns: - `True` if patching succeeds, `False` if no device handler is available or the operation is aborted. - """ + """Patch modem partition.""" if self.da_handler is None: return False - self._patch_sys_exit() try: return self.da_handler.da_patch_modem() - except OperationAborted: + except SystemExit: return False - finally: - self._restore_sys_exit() # ── Helpers ─────────────────────────────────────────── @@ -1049,7 +614,6 @@ def _format_size(size_bytes: int) -> str: if size_bytes <= 0: return "0 B" units = ("B", "KB", "MB", "GB", "TB") - import math i = int(math.floor(math.log(size_bytes, 1024))) i = min(i, len(units) - 1) p = math.pow(1024, i) diff --git a/mtk_gui/backend/worker.py b/mtk_gui/backend/worker.py index cce3111..d42a069 100644 --- a/mtk_gui/backend/worker.py +++ b/mtk_gui/backend/worker.py @@ -3,18 +3,18 @@ from PySide6.QtCore import QThread, Signal -class OperationAborted(Exception): - """Raised when a library call hits sys.exit() (monkey-patched).""" - - class Worker(QThread): - """Runs a callable in a background thread, emitting progress and result signals.""" + """Runs a callable in a background thread, emitting progress and result signals. + + Guarantees exactly one terminal signal is emitted: finished, error, or cancelled. + """ progress = Signal(int, int) # current, total log_message = Signal(str, int) # message, level status_text = Signal(str) finished = Signal(object) # result (any) error = Signal(str) # error message + cancelled = Signal() # emitted if cancelled before result def __init__(self, func, *args, **kwargs): """ @@ -48,10 +48,12 @@ def run(self): """Execute the worker function and emit its result or an error message.""" try: result = self._func(*self._args, worker=self, **self._kwargs) - if not self._cancelled: + if self._cancelled: + self.cancelled.emit() + else: self.finished.emit(result) - except OperationAborted: - self.error.emit("Operation aborted by library.") + except SystemExit as e: + self.error.emit(f"Operation aborted (sys.exit({e.code})).") except Exception as e: tb = traceback.format_exc() self.error.emit(f"{e}\n{tb}") diff --git a/mtk_gui/main_window.py b/mtk_gui/main_window.py index 869e7b6..e635492 100644 --- a/mtk_gui/main_window.py +++ b/mtk_gui/main_window.py @@ -1,7 +1,8 @@ """Main window — connects backend to UI.""" import logging import os -from PySide6.QtCore import Qt, QSettings +from PySide6.QtCore import Qt, QSettings, QUrl +from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QMainWindow, QTabWidget, QSplitter, QWidget, QVBoxLayout, QMessageBox, QMenu, @@ -31,7 +32,6 @@ class MainWindow(QMainWindow): def __init__(self): - """Initialize the main application window, backend device manager, user interface, menus, signals, and persisted window settings.""" super().__init__() self.setWindowTitle(APP_TITLE) self.setMinimumSize(900, 650) @@ -39,6 +39,7 @@ def __init__(self): self._settings = QSettings(ORG_NAME, APP_NAME) self._current_theme = self._settings.value("theme", DEFAULT_THEME) self._plugins = [] + self._last_peek_addr = 0 # Backend self.device_manager = DeviceManager(self) @@ -53,7 +54,6 @@ def __init__(self): def _setup_ui(self): # Central splitter: tabs on top, log panel on bottom - """Set up the main window's operation tabs, progress and log panels, splitter layout, and ready status message.""" splitter = QSplitter(Qt.Orientation.Vertical) # Tab widget @@ -111,7 +111,6 @@ def _setup_ui(self): self.statusBar().showMessage("Ready") def _setup_menus(self): - """Create the application's File, View, Plugins, and Help menus.""" menubar = self.menuBar() # File menu @@ -129,10 +128,16 @@ def _setup_menus(self): # Help menu help_menu = menubar.addMenu("Help") + help_menu.addAction("mtkclient Documentation", lambda: QDesktopServices.openUrl( + QUrl("https://github.com/bkerler/mtkclient/blob/main/README.md"))) + help_menu.addAction("MediaTek Exploit Guide", lambda: QDesktopServices.openUrl( + QUrl("https://github.com/bkerler/mtkclient/wiki"))) + help_menu.addAction("XDA Forums - MTK", lambda: QDesktopServices.openUrl( + QUrl("https://xdaforums.com/f/mediatek.4292/"))) + help_menu.addSeparator() help_menu.addAction("About", self._show_about) def _connect_signals(self): - """Connect device-manager signals and operation-tab controls to their handlers.""" dm = self.device_manager # Connection state @@ -203,7 +208,6 @@ def _connect_signals(self): # ── Connection handlers ─────────────────────────────── def _on_connect(self): - """Applies the selected device settings and initiates a device connection.""" settings = self._device_tab.get_settings() dm = self.device_manager dm.preloader_path = settings["preloader"] @@ -216,16 +220,10 @@ def _on_connect(self): dm.connect_device() def _on_disconnect(self): - """Disconnect the device and reset the operation progress display.""" self.device_manager.disconnect_device() self._progress_panel.reset() def _on_state_changed(self, state: DeviceState): - """Update the interface and plugins in response to a device connection state change. - - Parameters: - state (DeviceState): The device's new connection state. - """ self._device_tab.update_connection_state(state) connected = state == DeviceState.CONNECTED @@ -261,15 +259,10 @@ def _on_state_changed(self, state: DeviceState): pass def _on_device_info_updated(self, info): - """Updates the device tab with the latest device information. - - Parameters: - info: Device information to display. - """ self._device_tab.update_device_info(info) def _load_partitions(self): - """Load the device's partition list asynchronously after connection.""" + """Load partition list after connection (off main thread).""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -280,24 +273,34 @@ def _load_partitions(self): w.start() def _on_partitions_loaded(self, partitions): - """Update the read, write, and erase tabs with the available device partitions. - - Parameters: - partitions: Partition information to display in the operation tabs. - """ if partitions: self._read_tab.partition_list.set_partitions(partitions) self._write_tab.set_partitions(partitions) self._erase_tab.partition_list.set_partitions(partitions) + def _on_multi_read_done(self, results): + if isinstance(results, dict): + ok = sum(1 for v in results.values() if v) + total = len(results) + for name, success in results.items(): + self._log_panel.append_log( + f"Read {name}: {'OK' if success else 'FAILED'}", + logging.INFO if success else logging.ERROR) + self._progress_panel.finish(f"Read {ok}/{total} partitions") + else: + self._on_op_done("Read partitions", results) + # ── Operation handlers ──────────────────────────────── def _on_read(self): - """Starts the selected read operation and reports validation or worker errors.""" - params = self._read_tab.get_read_params() wrapper = self.device_manager.wrapper if not wrapper: return + try: + params = self._read_tab.get_read_params() + except ValueError as e: + QMessageBox.warning(self, "Invalid Input", str(e)) + return self._progress_panel.start("Reading...") mode = params["mode"] @@ -312,16 +315,12 @@ def _on_read(self): QMessageBox.warning(self, "No Directory", "Select an output directory.") self._progress_panel.reset() return - if params.get("dump_gpt"): - wrapper.dump_gpt(directory) - for part_name in params["partitions"]: - filename = os.path.join(directory, f"{part_name}.bin") - w = self.device_manager.create_worker( - wrapper.read_partition, part_name, filename, params["parttype"]) - w.finished.connect(lambda r, n=part_name: self._log_panel.append_log( - f"Read {n}: {'OK' if r else 'FAILED'}", logging.INFO)) - w.error.connect(lambda e: self._on_op_error("Read", e)) - w.start() + w = self._start_op(self._read_tab.read_button, + wrapper.read_selected_partitions, + params["partitions"], directory, + params["parttype"], params.get("dump_gpt", False)) + w.finished.connect(self._on_multi_read_done) + w.error.connect(lambda e: self._on_op_error("Read", e)) elif mode == 1: # Full Flash w = self.device_manager.create_worker( wrapper.read_flash, params["filename"], params["parttype"]) @@ -344,7 +343,6 @@ def _on_read(self): w.start() def _on_write(self): - """Starts the selected flash-writing operation after user confirmation.""" params = self._write_tab.get_write_params() wrapper = self.device_manager.wrapper if not wrapper: @@ -384,7 +382,6 @@ def _on_write(self): w.start() def _on_erase(self): - """Erases the selected partitions or sector range from the connected device after user confirmation.""" params = self._erase_tab.get_erase_params() wrapper = self.device_manager.wrapper if not wrapper: @@ -413,7 +410,6 @@ def _on_erase(self): w.start() def _on_generate_keys(self): - """Generate device keys in the selected output directory.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -425,7 +421,6 @@ def _on_generate_keys(self): w.start() def _on_keys_done(self, result): - """Finalize key generation and display the generated keys when available.""" self._progress_panel.finish("Keys generated") self._keys_tab.set_status("Keys saved to hwparam.json") # Try to read and display the keys @@ -441,12 +436,6 @@ def _on_keys_done(self, result): pass def _on_seccfg(self, lock: bool): - """ - Change the bootloader lock state through the seccfg operation after user confirmation. - - Parameters: - lock (bool): Whether to lock the bootloader; `False` unlocks it. - """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -463,9 +452,6 @@ def _on_seccfg(self, lock: bool): w.start() def _on_vbmeta(self): - """ - Patch vbmeta using the selected bootloader mode after user confirmation. - """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -481,12 +467,16 @@ def _on_vbmeta(self): w.start() def _on_peek(self): - """Reads a memory range from the connected device and displays the result.""" wrapper = self.device_manager.wrapper if not wrapper: return - params = self._memory_tab.get_peek_params() + try: + params = self._memory_tab.get_peek_params() + except ValueError as e: + QMessageBox.warning(self, "Invalid Input", str(e)) + return self._progress_panel.start("Reading memory...") + self._last_peek_addr = params["addr"] w = self.device_manager.create_worker( wrapper.peek, params["addr"], params["length"], "", params["registers"]) w.finished.connect(self._on_peek_done) @@ -494,18 +484,19 @@ def _on_peek(self): w.start() def _on_peek_done(self, data): - """Complete a memory read operation and display the returned data in the memory viewer.""" self._progress_panel.finish("Memory read complete") if data and isinstance(data, (bytes, bytearray)): - params = self._memory_tab.get_peek_params() - self._memory_tab.hex_viewer.set_data(data, params["addr"]) + self._memory_tab.hex_viewer.set_data(data, self._last_peek_addr) def _on_poke(self): - """Writes the specified data to the selected memory address after user confirmation.""" wrapper = self.device_manager.wrapper if not wrapper: return - params = self._memory_tab.get_poke_params() + try: + params = self._memory_tab.get_poke_params() + except ValueError as e: + QMessageBox.warning(self, "Invalid Input", str(e)) + return if not ConfirmationDialog.confirm( "Confirm Memory Write", f"Write to address 0x{params['addr']:X}?", self): @@ -518,7 +509,6 @@ def _on_poke(self): w.start() def _on_dump_brom(self): - """Dump the device's Boot ROM to a file named ``brom.bin`` in the selected directory.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -531,30 +521,37 @@ def _on_dump_brom(self): w.start() def _on_rpmb_read(self): - """Read RPMB data from the connected device and report the operation result.""" wrapper = self.device_manager.wrapper if not wrapper: return + params = self._rpmb_tab.get_read_params() self._progress_panel.start("Reading RPMB...") - w = self._start_op(self._rpmb_tab.read_button, wrapper.read_rpmb) + w = self._start_op(self._rpmb_tab.read_button, + wrapper.read_rpmb, params["filename"], + params["sector"], params["sectors"]) w.finished.connect(lambda r: self._on_op_done("RPMB read", r)) w.error.connect(lambda e: self._on_op_error("RPMB read", e)) def _on_rpmb_write(self): - """Confirm and start writing data to the device's RPMB region.""" wrapper = self.device_manager.wrapper if not wrapper: return + try: + params = self._rpmb_tab.get_write_params() + except ValueError as e: + QMessageBox.warning(self, "Invalid Input", str(e)) + return if not ConfirmationDialog.confirm( "Confirm RPMB Write", "Write to RPMB?", self): return self._progress_panel.start("Writing RPMB...") - w = self._start_op(self._rpmb_tab.write_button, wrapper.write_rpmb) + w = self._start_op(self._rpmb_tab.write_button, + wrapper.write_rpmb, params["filename"], + params["sector"], params["sectors"]) w.finished.connect(lambda r: self._on_op_done("RPMB write", r)) w.error.connect(lambda e: self._on_op_error("RPMB write", e)) def _on_read_efuses(self): - """Reads the device's eFuse values asynchronously and updates the progress panel with the result.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -565,25 +562,32 @@ def _on_read_efuses(self): w.start() def _on_efuses_done(self, data): - """Complete eFuse reading and display the retrieved values. - - Parameters: - data (list): The eFuse values returned by the read operation. - """ self._progress_panel.finish("eFuses read") if data and isinstance(data, list): self._efuse_tab.set_efuses(data) # ── IMEI handlers ──────────────────────────────────── + def _parse_imei_hex_params(self, params: dict): + """Validate and parse seed/aeskey hex strings. Returns (seed, aeskey) or (None, None).""" + for field in ("seed", "aeskey"): + val = params[field] + if val and (len(val) % 2 != 0 or not all(c in "0123456789abcdefABCDEF" for c in val)): + QMessageBox.warning(self, "Invalid Hex", + f"{field.title()} must be an even-length hex string.") + return None, None + seed = bytes.fromhex(params["seed"]) if params["seed"] else b"" + aeskey = bytes.fromhex(params["aeskey"]) if params["aeskey"] else b"\x00" * 32 + return seed, aeskey + def _on_imei_read(self): - """Read the device IMEIs using the configured seed and AES key.""" wrapper = self.device_manager.wrapper if not wrapper: return params = self._imei_tab.get_imei_params() - seed = bytes.fromhex(params["seed"]) if params["seed"] else b"" - aeskey = bytes.fromhex(params["aeskey"]) if params["aeskey"] else b"\x00" * 32 + seed, aeskey = self._parse_imei_hex_params(params) + if seed is None: + return self._progress_panel.start("Reading IMEIs...") w = self._start_op(self._imei_tab.read_button, wrapper.read_imei, seed, aeskey) @@ -591,12 +595,6 @@ def _on_imei_read(self): w.error.connect(lambda e: self._on_op_error("IMEI read", e)) def _on_imei_read_done(self, result): - """ - Update the IMEI display and log the validity of each value returned by the device. - - Parameters: - result (list): IMEI values paired with checksum-validity flags. - """ self._progress_panel.finish("IMEI read complete") if result and isinstance(result, list): imei1 = result[0][0] if len(result) > 0 else "" @@ -609,7 +607,6 @@ def _on_imei_read_done(self, result): self._log_panel.append_log("No IMEIs found on device.", logging.WARNING) def _on_imei_write(self): - """Write the selected IMEI values to the connected device after confirmation.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -618,12 +615,18 @@ def _on_imei_write(self): if not imeis: QMessageBox.warning(self, "No IMEI", "Enter at least one IMEI.") return + for imei in imeis: + if not imei.isdigit() or len(imei) != 15: + QMessageBox.warning(self, "Invalid IMEI", + f"IMEI must be exactly 15 digits: \"{imei}\"") + return if not ConfirmationDialog.confirm( "Confirm IMEI Write", "This will overwrite IMEI data on the device.", self): return - seed = bytes.fromhex(params["seed"]) if params["seed"] else b"" - aeskey = bytes.fromhex(params["aeskey"]) if params["aeskey"] else b"\x00" * 32 + seed, aeskey = self._parse_imei_hex_params(params) + if seed is None: + return self._progress_panel.start("Writing IMEIs...") w = self._start_op(self._imei_tab.write_button, wrapper.write_imei, imeis, seed, aeskey, params["product"]) @@ -644,10 +647,6 @@ def _on_patch_modem(self): w.error.connect(lambda e: self._on_op_error("Modem patch", e)) def _on_nvitem_decrypt(self): - """Decrypt the selected NV file using the connected device. - - The operation updates the progress panel and reports completion or errors through the window UI. - """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -662,7 +661,6 @@ def _on_nvitem_decrypt(self): w.error.connect(lambda e: self._on_op_error("NVItem decrypt", e)) def _on_nvitem_encrypt(self): - """Encrypts the selected NV file using the connected device wrapper.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -677,13 +675,6 @@ def _on_nvitem_encrypt(self): w.error.connect(lambda e: self._on_op_error("NVItem encrypt", e)) def _on_nvitem_done(self, op: str, result, filename: str): - """Finalize an NVItem operation and save successful output to a derived file. - - Parameters: - op (str): Operation name used in the completion message and output suffix. - result: Operation output bytes, or a falsey value when the operation fails. - filename (str): Base path for the output file. - """ self._progress_panel.finish(f"NVItem {op.lower()} complete") if result: out_path = filename + f".{op.lower()}ed" @@ -696,11 +687,6 @@ def _on_nvitem_done(self, op: str, result, filename: str): # ── Exploit handlers ───────────────────────────────── def _on_crash(self): - """ - Crash the preloader to enter BROM mode after user confirmation. - - The selected crash mode determines how the preloader crash operation is performed. - """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -744,7 +730,6 @@ def _on_payload(self): w.error.connect(lambda e: self._on_op_error("Payload", e)) def _on_stage2(self): - """Runs the selected stage2 file on the connected device.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -760,9 +745,6 @@ def _on_stage2(self): w.error.connect(lambda e: self._on_op_error("Stage2", e)) def _on_meta_mode(self): - """ - Set the connected device's meta mode. - """ wrapper = self.device_manager.wrapper if not wrapper: return @@ -774,7 +756,6 @@ def _on_meta_mode(self): w.error.connect(lambda e: self._on_op_error("Meta mode", e)) def _on_reset(self): - """Reset the connected device and report the operation status.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -784,7 +765,6 @@ def _on_reset(self): w.error.connect(lambda e: self._on_op_error("Reset", e)) def _on_shutdown(self): - """Shut down the connected device asynchronously.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -796,7 +776,6 @@ def _on_shutdown(self): # ── RPMB erase handler ─────────────────────────────── def _on_rpmb_erase(self): - """Erase the device's RPMB data after user confirmation.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -811,7 +790,6 @@ def _on_rpmb_erase(self): # ── Server handler ─────────────────────────────────── def _on_server_start(self): - """Start the key server operation when a device wrapper is available.""" wrapper = self.device_manager.wrapper if not wrapper: return @@ -822,7 +800,6 @@ def _on_server_start(self): w.error.connect(self._on_server_error) def _on_server_done(self, result): - """Update the key server status, progress message, and log entry based on the operation result.""" success = bool(result) self._server_tab.set_status(False, "Complete" if success else "Failed") @@ -833,20 +810,13 @@ def _on_server_done(self, result): logging.INFO if success else logging.ERROR) def _on_server_error(self, error: str): - """Handle a key server operation error by updating its status and reporting the failure.""" self._server_tab.set_status(False, "Error") self._on_op_error("Key server", error) # ── Button-disable helper ──────────────────────────── def _start_op(self, button, func, *args, **kwargs): - """ - Start a background operation and disable its triggering button until completion or error. - - Parameters: - button: The button to disable while the operation is running. - func: The operation callable to execute. - """ + """Create a worker, disable the triggering button, re-enable on completion.""" button.setEnabled(False) w = self.device_manager.create_worker(func, *args, **kwargs) w.finished.connect(lambda _: button.setEnabled(True)) @@ -857,25 +827,12 @@ def _start_op(self, button, func, *args, **kwargs): # ── Operation result handlers ───────────────────────── def _on_op_done(self, name: str, result): - """ - Update the progress and log panels with the outcome of an operation. - - Parameters: - name (str): Operation name used in the status message. - result: Operation result interpreted as successful when truthy. - """ success = bool(result) status = f"{name}: {'OK' if success else 'FAILED'}" self._progress_panel.finish(status) self._log_panel.append_log(status, logging.INFO if success else logging.ERROR) def _on_op_error(self, name: str, error: str): - """Report an operation error through the progress panel, log panel, and warning dialog. - - Parameters: - name (str): Name of the failed operation. - error (str): Error message to report. - """ self._progress_panel.finish(f"{name}: ERROR") self._log_panel.append_log(f"{name} error: {error}", logging.ERROR) QMessageBox.warning(self, f"{name} Error", str(error)) @@ -883,7 +840,6 @@ def _on_op_error(self, name: str, error: str): # ── Theme ───────────────────────────────────────────── def _toggle_theme(self): - """Toggle between the dark and light application themes and persist the selected theme.""" if self._current_theme == THEME_DARK: self._current_theme = THEME_LIGHT else: @@ -892,34 +848,18 @@ def _toggle_theme(self): self._settings.setValue("theme", self._current_theme) def apply_initial_theme(self): - """Apply the persisted application theme to the main window.""" self._apply_theme() def _apply_theme(self): - """Apply the stylesheet for the current application theme.""" qss = load_stylesheet(self._current_theme) self.setStyleSheet(qss) # ── Plugin support ──────────────────────────────────── def add_plugin_tab(self, widget, title: str): - """ - Add a plugin-provided widget as a tab in the main window. - - Parameters: - widget: The widget to display in the new tab. - title (str): The tab title. - """ self._tabs.addTab(widget, title) def add_plugin_menu_item(self, menu_path: str, action): - """ - Add an action to a nested menu within the plugins menu. - - Parameters: - menu_path (str): Slash-separated path identifying the target menu and action location. - action: Action to add to the target menu. - """ parts = menu_path.split("/") menu = self._plugins_menu for part in parts[:-1]: @@ -935,12 +875,6 @@ def add_plugin_menu_item(self, menu_path: str, action): menu.addAction(parts[-1], action) def set_plugins(self, plugins: list): - """ - Store the plugins available to the main window. - - Parameters: - plugins (list): Plugin instances to register. - """ self._plugins = plugins def _reload_plugins(self): @@ -949,7 +883,6 @@ def _reload_plugins(self): # ── Window state ────────────────────────────────────── def _restore_geometry(self): - """Restore the window geometry and state saved in application settings.""" geo = self._settings.value("geometry") if geo: self.restoreGeometry(geo) @@ -958,13 +891,9 @@ def _restore_geometry(self): self.restoreState(state) def closeEvent(self, event): - """Save the window state, stop device activity, clean up plugins, and close the window. - - Parameters: - event: The close event to process. - """ self._settings.setValue("geometry", self.saveGeometry()) self._settings.setValue("windowState", self.saveState()) + self.device_manager.begin_shutdown() self.device_manager.stop_polling() self.device_manager.disconnect_device() for plugin in self._plugins: @@ -975,9 +904,12 @@ def closeEvent(self, event): super().closeEvent(event) def _show_about(self): - """Display an About dialog containing the application name, description, license, and toolkit information.""" QMessageBox.about(self, "About MTK-GUI", - f"{APP_TITLE}\n\n" - "Standalone GUI for mtkclient.\n" - "License: GPLv3\n\n" - "Built with PySide6.") + "MTK-GUI v0.1.0\n\n" + "Standalone GUI for mtkclient by bkerler.\n" + "Built with PySide6.\n\n" + "This tool provides a graphical interface for MediaTek\n" + "device operations: flash read/write, bootloader unlock,\n" + "key extraction, IMEI management, and more.\n\n" + "Requires mtkclient to be installed.\n" + "License: GPLv3") diff --git a/mtk_gui/ui/tabs/bootloader_tab.py b/mtk_gui/ui/tabs/bootloader_tab.py index 16f90a4..d21736b 100644 --- a/mtk_gui/ui/tabs/bootloader_tab.py +++ b/mtk_gui/ui/tabs/bootloader_tab.py @@ -22,15 +22,18 @@ def _setup_ui(self): bl_layout = QVBoxLayout(bl_group) self._critical_check = QCheckBox("Critical mode (use with caution)") + self._critical_check.setToolTip("Also unlock critical partitions (e.g. preloader). Risk of hard-brick if misused.") bl_layout.addWidget(self._critical_check) btn_row = QHBoxLayout() self._unlock_btn = QPushButton("Unlock Bootloader") + self._unlock_btn.setToolTip("Remove bootloader lock via seccfg. Required for flashing custom ROMs.") self._unlock_btn.setProperty("destructive", True) self._unlock_btn.setEnabled(False) btn_row.addWidget(self._unlock_btn) self._lock_btn = QPushButton("Lock Bootloader") + self._lock_btn.setToolTip("Re-lock the bootloader. Some OTA updates require a locked bootloader.") self._lock_btn.setProperty("destructive", True) self._lock_btn.setEnabled(False) btn_row.addWidget(self._lock_btn) @@ -46,6 +49,7 @@ def _setup_ui(self): mode_row = QHBoxLayout() mode_row.addWidget(QLabel("Patch Mode:")) self._vbmeta_mode = QComboBox() + self._vbmeta_mode.setToolTip("Mode 3 (both) is the most common choice for custom ROM installs.") self._vbmeta_mode.addItem("Disable verity + verify", 3) self._vbmeta_mode.addItem("Disable verity only", 1) self._vbmeta_mode.addItem("Disable verify only", 2) @@ -54,6 +58,7 @@ def _setup_ui(self): vb_btn_row = QHBoxLayout() self._vbmeta_btn = QPushButton("Patch vbmeta") + self._vbmeta_btn.setToolTip("Disables Android Verified Boot checks. Needed for rooted or modified system images.") self._vbmeta_btn.setProperty("destructive", True) self._vbmeta_btn.setEnabled(False) vb_btn_row.addWidget(self._vbmeta_btn) diff --git a/mtk_gui/ui/tabs/device_tab.py b/mtk_gui/ui/tabs/device_tab.py index 5cda503..db646c3 100644 --- a/mtk_gui/ui/tabs/device_tab.py +++ b/mtk_gui/ui/tabs/device_tab.py @@ -52,10 +52,12 @@ def _setup_ui(self): status_layout.addStretch() self._connect_btn = QPushButton("Connect") + self._connect_btn.setToolTip("Initialize connection through preloader and load the Download Agent.") self._connect_btn.setFixedWidth(100) status_layout.addWidget(self._connect_btn) self._disconnect_btn = QPushButton("Disconnect") + self._disconnect_btn.setToolTip("Cleanly disconnect from the device.") self._disconnect_btn.setFixedWidth(100) self._disconnect_btn.setEnabled(False) status_layout.addWidget(self._disconnect_btn) @@ -105,6 +107,7 @@ def _setup_ui(self): settings_grid.addWidget(QLabel("DA Loader:"), 0, 0) self._da_loader_input = QLineEdit() self._da_loader_input.setPlaceholderText("(auto-detect)") + self._da_loader_input.setToolTip("Custom DA binary. Leave empty to use the built-in one.") settings_grid.addWidget(self._da_loader_input, 0, 1) da_browse = QPushButton("Browse") da_browse.setFixedWidth(70) @@ -115,6 +118,7 @@ def _setup_ui(self): settings_grid.addWidget(QLabel("Preloader:"), 1, 0) self._preloader_input = QLineEdit() self._preloader_input.setPlaceholderText("(optional)") + self._preloader_input.setToolTip("Provide a preloader binary if auto-detection fails.") settings_grid.addWidget(self._preloader_input, 1, 1) pl_browse = QPushButton("Browse") pl_browse.setFixedWidth(70) @@ -131,6 +135,7 @@ def _setup_ui(self): # Working directory settings_grid.addWidget(QLabel("Working Dir:"), 3, 0) self._workdir_input = QLineEdit(".") + self._workdir_input.setToolTip("Directory for output files, keys, and logs.") settings_grid.addWidget(self._workdir_input, 3, 1) wd_browse = QPushButton("Browse") wd_browse.setFixedWidth(70) @@ -140,8 +145,10 @@ def _setup_ui(self): # Checkboxes cb_row = QHBoxLayout() self._iot_check = QCheckBox("IoT Mode") + self._iot_check.setToolTip("Use IoT DA. Required for some low-end MediaTek chipsets.") cb_row.addWidget(self._iot_check) self._stock_check = QCheckBox("Stock DA") + self._stock_check.setToolTip("Use the stock (unpatched) Download Agent. Disables exploit features.") cb_row.addWidget(self._stock_check) cb_row.addStretch() settings_grid.addLayout(cb_row, 4, 0, 1, 3) diff --git a/mtk_gui/ui/tabs/efuse_tab.py b/mtk_gui/ui/tabs/efuse_tab.py index ac1ff82..90a5800 100644 --- a/mtk_gui/ui/tabs/efuse_tab.py +++ b/mtk_gui/ui/tabs/efuse_tab.py @@ -17,6 +17,7 @@ def _setup_ui(self): btn_row = QHBoxLayout() self._read_btn = QPushButton("Read eFuses") + self._read_btn.setToolTip("Read one-time-programmable fuse values. These cannot be modified.") self._read_btn.setFixedWidth(140) self._read_btn.setEnabled(False) btn_row.addWidget(self._read_btn) diff --git a/mtk_gui/ui/tabs/erase_tab.py b/mtk_gui/ui/tabs/erase_tab.py index 7604dcd..e3a3b33 100644 --- a/mtk_gui/ui/tabs/erase_tab.py +++ b/mtk_gui/ui/tabs/erase_tab.py @@ -5,6 +5,7 @@ QStackedWidget, QRadioButton, QButtonGroup, ) +from mtk_gui.ui.widgets.dismissable_banner import DismissableBanner from mtk_gui.ui.widgets.partition_list import PartitionList from mtk_gui.constants import PART_TYPES @@ -26,12 +27,11 @@ def _setup_ui(self): layout.setSpacing(8) # Warning - warn = QLabel("WARNING: Erasing partitions is irreversible. Data will be permanently lost.") - warn.setStyleSheet( - "background-color: #440000; color: #ff4444; padding: 6px; " - "border: 1px solid #cc4444; border-radius: 3px; font-weight: bold;" + warn = DismissableBanner( + "WARNING: Erasing partitions is irreversible. Data will be permanently lost.", + banner_type="danger", + settings_key="banner/erase_warning", ) - warn.setWordWrap(True) layout.addWidget(warn) # Mode selector @@ -50,6 +50,7 @@ def _setup_ui(self): mode_layout.addWidget(QLabel("Part Type:")) self._parttype_combo = QComboBox() self._parttype_combo.addItems(PART_TYPES) + self._parttype_combo.setToolTip("Usually 'user'. Only change if you know the target storage region.") mode_layout.addWidget(self._parttype_combo) layout.addWidget(mode_group) @@ -87,6 +88,7 @@ def _setup_ui(self): btn_row = QHBoxLayout() btn_row.addStretch() self._erase_btn = QPushButton("Erase") + self._erase_btn.setToolTip("Permanently destroys data. Cannot be undone.") self._erase_btn.setFixedWidth(120) self._erase_btn.setProperty("destructive", True) self._erase_btn.setEnabled(False) diff --git a/mtk_gui/ui/tabs/exploit_tab.py b/mtk_gui/ui/tabs/exploit_tab.py index c55b67e..b5585ce 100644 --- a/mtk_gui/ui/tabs/exploit_tab.py +++ b/mtk_gui/ui/tabs/exploit_tab.py @@ -25,6 +25,7 @@ def _setup_ui(self): crash_layout = QHBoxLayout(crash_group) crash_layout.addWidget(QLabel("Mode:")) self._crash_mode = QComboBox() + self._crash_mode.setToolTip("Mode 0 is most common. Try other modes if mode 0 fails on your chipset.") self._crash_mode.addItems(["0 (default)", "1", "2"]) crash_layout.addWidget(self._crash_mode) self._crash_btn = QPushButton("Crash") @@ -38,6 +39,7 @@ def _setup_ui(self): brute_group = QGroupBox("Kamakiri Brute Force") brute_layout = QHBoxLayout(brute_group) self._brute_btn = QPushButton("Start Brute Force") + self._brute_btn.setToolTip("Automatically tries all crash modes until BROM access is achieved.") self._brute_btn.setProperty("destructive", True) self._brute_btn.setEnabled(False) brute_layout.addWidget(self._brute_btn) @@ -50,6 +52,7 @@ def _setup_ui(self): p1 = QHBoxLayout() p1.addWidget(QLabel("Payload File:")) self._payload_file = QLineEdit() + self._payload_file.setToolTip("Binary payload to load and execute at default address 0x200000.") p1.addWidget(self._payload_file, 1) pb = QPushButton("Browse") pb.setFixedWidth(70) @@ -78,6 +81,7 @@ def _setup_ui(self): s2.addWidget(QLabel("Address (hex):")) self._stage_addr = QLineEdit() self._stage_addr.setPlaceholderText("0x200000") + self._stage_addr.setToolTip("Memory address to load the stage2 payload. Hex, e.g. 0x200000.") s2.addWidget(self._stage_addr, 1) self._stage_btn = QPushButton("Run") self._stage_btn.setEnabled(False) @@ -90,6 +94,7 @@ def _setup_ui(self): meta_layout = QHBoxLayout(meta_group) meta_layout.addWidget(QLabel("Mode:")) self._meta_mode = QComboBox() + self._meta_mode.setToolTip("off = disable, usb = USB debug, uart = serial debug. xflash-only.") self._meta_mode.addItems(["off", "usb", "uart"]) meta_layout.addWidget(self._meta_mode) self._meta_btn = QPushButton("Set Meta Mode") @@ -102,9 +107,11 @@ def _setup_ui(self): ctrl_group = QGroupBox("Device Control") ctrl_layout = QHBoxLayout(ctrl_group) self._reset_btn = QPushButton("Reset Device") + self._reset_btn.setToolTip("Reboot the device to normal mode.") self._reset_btn.setEnabled(False) ctrl_layout.addWidget(self._reset_btn) self._shutdown_btn = QPushButton("Shutdown Device") + self._shutdown_btn.setToolTip("Power off the device.") self._shutdown_btn.setEnabled(False) ctrl_layout.addWidget(self._shutdown_btn) ctrl_layout.addStretch() @@ -177,7 +184,7 @@ def stage_addr(self) -> int: text = self._stage_addr.text().strip() if not text: return 0x200000 - return int(text, 16) if text.startswith("0x") else int(text) + return int(text.removeprefix("0x").removeprefix("0X"), 16) @property def meta_mode(self) -> str: diff --git a/mtk_gui/ui/tabs/imei_tab.py b/mtk_gui/ui/tabs/imei_tab.py index 693e4d3..05b9a71 100644 --- a/mtk_gui/ui/tabs/imei_tab.py +++ b/mtk_gui/ui/tabs/imei_tab.py @@ -24,10 +24,12 @@ def _setup_ui(self): r1.addWidget(QLabel("Seed:")) self._seed_input = QLineEdit() self._seed_input.setPlaceholderText("(optional)") + self._seed_input.setToolTip("Optional encryption seed for NVItem IMEI data. Usually not needed.") r1.addWidget(self._seed_input, 1) r1.addWidget(QLabel("AES Key:")) self._aeskey_input = QLineEdit() self._aeskey_input.setPlaceholderText("(optional)") + self._aeskey_input.setToolTip("Optional AES key for NVItem decryption. Leave empty for default.") r1.addWidget(self._aeskey_input, 1) read_layout.addLayout(r1) @@ -66,6 +68,7 @@ def _setup_ui(self): w2.addWidget(QLabel("Product:")) self._product_input = QLineEdit() self._product_input.setPlaceholderText("(optional)") + self._product_input.setToolTip("Device product name for CSSD record. Only needed for IMEI write with CSSD.") w2.addWidget(self._product_input, 1) self._write_btn = QPushButton("Write IMEIs") self._write_btn.setProperty("destructive", True) @@ -79,6 +82,7 @@ def _setup_ui(self): modem_group = QGroupBox("Modem") modem_layout = QHBoxLayout(modem_group) self._patch_modem_btn = QPushButton("Patch Modem") + self._patch_modem_btn.setToolTip("Patch the modem partition to allow custom IMEI values.") self._patch_modem_btn.setProperty("destructive", True) self._patch_modem_btn.setEnabled(False) modem_layout.addWidget(self._patch_modem_btn) @@ -91,6 +95,7 @@ def _setup_ui(self): nv1 = QHBoxLayout() nv1.addWidget(QLabel("NV File:")) self._nv_file = QLineEdit() + self._nv_file.setToolTip("Select an NVItem binary file to encrypt or decrypt offline.") nv1.addWidget(self._nv_file, 1) nv_browse = QPushButton("Browse") nv_browse.setFixedWidth(70) diff --git a/mtk_gui/ui/tabs/keys_tab.py b/mtk_gui/ui/tabs/keys_tab.py index e29f04a..363e4a8 100644 --- a/mtk_gui/ui/tabs/keys_tab.py +++ b/mtk_gui/ui/tabs/keys_tab.py @@ -23,6 +23,7 @@ def _setup_ui(self): dir_layout = QHBoxLayout(dir_group) dir_layout.addWidget(QLabel("Directory:")) self._dir_input = QLineEdit(".") + self._dir_input.setToolTip("Where to save hwparam.json. Defaults to current directory.") dir_layout.addWidget(self._dir_input, 1) browse = QPushButton("Browse") browse.setFixedWidth(70) @@ -34,6 +35,7 @@ def _setup_ui(self): btn_row = QHBoxLayout() btn_row.addStretch() self._gen_btn = QPushButton("Generate Keys") + self._gen_btn.setToolTip("Extract hardware-bound crypto keys. Saved to hwparam.json in the output directory.") self._gen_btn.setFixedWidth(140) self._gen_btn.setEnabled(False) btn_row.addWidget(self._gen_btn) diff --git a/mtk_gui/ui/tabs/memory_tab.py b/mtk_gui/ui/tabs/memory_tab.py index a9a799a..ea33595 100644 --- a/mtk_gui/ui/tabs/memory_tab.py +++ b/mtk_gui/ui/tabs/memory_tab.py @@ -13,7 +13,6 @@ def __init__(self, parent=None): self._setup_ui() def _setup_ui(self): - """Build the user interface for memory inspection, writing, and dump operations.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -34,6 +33,7 @@ def _setup_ui(self): opt_row = QHBoxLayout() self._peek_regs = QCheckBox("Register mode") + self._peek_regs.setToolTip("Read hardware registers instead of memory. Addresses must be 4-byte aligned.") opt_row.addWidget(self._peek_regs) opt_row.addStretch() @@ -64,6 +64,7 @@ def _setup_ui(self): poke_row.addWidget(QLabel("Data (hex):")) self._poke_data = QLineEdit() self._poke_data.setPlaceholderText("DEADBEEF") + self._poke_data.setToolTip("Hex bytes to write, e.g. DEADBEEF. No 0x prefix.") poke_row.addWidget(self._poke_data, 1) poke_layout.addLayout(poke_row) @@ -93,6 +94,7 @@ def _setup_ui(self): btn_row = QHBoxLayout() self._brom_btn = QPushButton("Dump BROM") + self._brom_btn.setToolTip("Dump the Boot ROM. Useful for exploit development and research.") self._brom_btn.setEnabled(False) btn_row.addWidget(self._brom_btn) self._sram_btn = QPushButton("Dump SRAM") @@ -108,7 +110,6 @@ def _setup_ui(self): layout.addWidget(dump_group) def _browse_dump_dir(self): - """Select an output directory and update the directory field when one is chosen.""" d = QFileDialog.getExistingDirectory(self, "Select Output Directory") if d: self._dump_dir.setText(d) @@ -121,41 +122,26 @@ def peek_button(self) -> QPushButton: @property def peek_save_button(self) -> QPushButton: - """Return the button used to read memory contents and save them to a file.""" return self._peek_save_btn @property def poke_button(self) -> QPushButton: - """Return the button used to write data to memory. - - Returns: - QPushButton: The memory write button. - """ return self._poke_btn @property def brom_button(self) -> QPushButton: - """Return the button used to initiate a BROM memory dump.""" return self._brom_btn @property def sram_button(self) -> QPushButton: - """ - Provide access to the SRAM dump button. - - Returns: - QPushButton: The SRAM dump button. - """ return self._sram_btn @property def dram_button(self) -> QPushButton: - """Return the button used to initiate a DRAM dump.""" return self._dram_btn @property def memdump_button(self) -> QPushButton: - """Return the button used to start a full memory dump.""" return self._memdump_btn @property @@ -163,49 +149,31 @@ def hex_viewer(self) -> HexViewer: return self._hex_viewer def get_peek_params(self) -> dict: - """ - Parse and return the configured memory peek parameters. - - Returns: - dict: A mapping containing the hexadecimal address under `"addr"`, the hexadecimal length under `"length"`, and the register-mode state under `"registers"`. - - Raises: - ValueError: If the address or length contains invalid hexadecimal text. - """ - return { - "addr": int(self._peek_addr.text() or "0", 16), - "length": int(self._peek_len.text() or "0", 16), - "registers": self._peek_regs.isChecked(), - } + """Returns params dict. Raises ValueError if addr/length are invalid.""" + addr_text = self._peek_addr.text().strip() + len_text = self._peek_len.text().strip() + if not addr_text: + raise ValueError("Address is required.") + if not len_text: + raise ValueError("Length is required.") + addr = int(addr_text, 16) + length = int(len_text, 16) + if length <= 0: + raise ValueError("Length must be greater than zero.") + return {"addr": addr, "length": length, "registers": self._peek_regs.isChecked()} def get_poke_params(self) -> dict: - """ - Retrieve the address and data values for a memory write operation. - - Returns: - dict: A mapping containing the parsed hexadecimal address under `"addr"` and the trimmed data string under `"data"`. - """ - return { - "addr": int(self._poke_addr.text() or "0", 16), - "data": self._poke_data.text().strip(), - } + """Returns params dict. Raises ValueError if addr is invalid.""" + addr_text = self._poke_addr.text().strip() + if not addr_text: + raise ValueError("Address is required.") + return {"addr": int(addr_text, 16), "data": self._poke_data.text().strip()} @property def dump_directory(self) -> str: - """ - Get the configured memory dump output directory. - - Returns: - str: The trimmed output directory, or "." when the field is empty. - """ return self._dump_dir.text().strip() or "." def set_enabled_all(self, enabled: bool): - """Enable or disable all memory operation controls. - - Parameters: - enabled (bool): Whether the controls should be enabled. - """ for btn in (self._peek_btn, self._peek_save_btn, self._poke_btn, self._brom_btn, self._sram_btn, self._dram_btn, self._memdump_btn): btn.setEnabled(enabled) diff --git a/mtk_gui/ui/tabs/read_tab.py b/mtk_gui/ui/tabs/read_tab.py index 91467bb..4c487ed 100644 --- a/mtk_gui/ui/tabs/read_tab.py +++ b/mtk_gui/ui/tabs/read_tab.py @@ -12,12 +12,10 @@ class ReadTab(QWidget): def __init__(self, parent=None): - """Initialize the read-operation controls and configure their user interface.""" super().__init__(parent) self._setup_ui() def _setup_ui(self): - """Build the read-operation controls and connect mode selection to the corresponding content page.""" layout = QVBoxLayout(self) layout.setSpacing(8) @@ -31,6 +29,8 @@ def _setup_ui(self): rb = QRadioButton(name) if i == 0: rb.setChecked(True) + if i == 1: + rb.setToolTip("Dump the entire flash storage to a single file. Can be very large.") self._mode_group.addButton(rb, i) mode_layout.addWidget(rb) mode_layout.addStretch() @@ -39,6 +39,7 @@ def _setup_ui(self): mode_layout.addWidget(QLabel("Part Type:")) self._parttype_combo = QComboBox() self._parttype_combo.addItems(PART_TYPES) + self._parttype_combo.setToolTip("Usually 'user'. Only change if you know the target storage region.") mode_layout.addWidget(self._parttype_combo) layout.addWidget(mode_group) @@ -53,6 +54,7 @@ def _setup_ui(self): part_layout.addWidget(self._partition_list) self._dump_gpt_check = QCheckBox("Also dump GPT") + self._dump_gpt_check.setToolTip("Save the partition table alongside the partition data.") part_layout.addWidget(self._dump_gpt_check) dir_row = QHBoxLayout() @@ -151,21 +153,11 @@ def _setup_ui(self): layout.addLayout(btn_row) def _browse_dir(self, target: QLineEdit): - """Select an output directory and populate the target line edit when a directory is chosen. - - Parameters: - target (QLineEdit): Line edit to receive the selected directory path. - """ d = QFileDialog.getExistingDirectory(self, "Select Output Directory") if d: target.setText(d) def _browse_save(self, target: QLineEdit): - """Select an output file and update the target field when a path is chosen. - - Parameters: - target (QLineEdit): Field that receives the selected output-file path. - """ path, _ = QFileDialog.getSaveFileName(self, "Select Output File", "", "Binary (*.bin);;All (*)") if path: target.setText(path) @@ -174,41 +166,20 @@ def _browse_save(self, target: QLineEdit): @property def read_button(self) -> QPushButton: - """Return the button used to initiate the read operation.""" return self._read_btn @property def partition_list(self) -> PartitionList: - """Expose the partition list widget used to select partitions. - - Returns: - PartitionList: The partition list widget. - """ return self._partition_list def get_mode(self) -> int: - """Return the numeric identifier of the selected read mode. - - Returns: - int: The selected read mode identifier. - """ return self._mode_group.checkedId() def get_parttype(self) -> str: - """Return the currently selected partition type. - - Returns: - str: The selected partition type. - """ return self._parttype_combo.currentText() def get_read_params(self) -> dict: - """ - Collect the settings for the selected read mode. - - Returns: - dict: A parameter dictionary containing the selected mode and partition type, along with mode-specific values. Offset and length are parsed as hexadecimal integers, while start sector and sector count are parsed as decimal integers; empty numeric fields produce zero. - """ + """Returns params dict. Raises ValueError on invalid input.""" mode = self.get_mode() params = {"mode": mode, "parttype": self.get_parttype()} if mode == 0: @@ -216,13 +187,40 @@ def get_read_params(self) -> dict: params["directory"] = self._part_dir_input.text() params["dump_gpt"] = self._dump_gpt_check.isChecked() elif mode == 1: - params["filename"] = self._flash_file_input.text() + filename = self._flash_file_input.text().strip() + if not filename: + raise ValueError("Output filename is required for full flash read.") + params["filename"] = filename elif mode == 2: - params["offset"] = int(self._offset_input.text() or "0", 16) - params["length"] = int(self._offset_len_input.text() or "0", 16) - params["filename"] = self._offset_file_input.text() + offset_text = self._offset_input.text().strip() + len_text = self._offset_len_input.text().strip() + filename = self._offset_file_input.text().strip() + if not offset_text: + raise ValueError("Offset is required.") + if not len_text: + raise ValueError("Length is required.") + if not filename: + raise ValueError("Output filename is required.") + length = int(len_text, 16) + if length <= 0: + raise ValueError("Length must be greater than zero.") + params["offset"] = int(offset_text, 16) + params["length"] = length + params["filename"] = filename elif mode == 3: - params["start"] = int(self._sector_start_input.text() or "0") - params["sectors"] = int(self._sector_count_input.text() or "0") - params["filename"] = self._sector_file_input.text() + start_text = self._sector_start_input.text().strip() + count_text = self._sector_count_input.text().strip() + filename = self._sector_file_input.text().strip() + if not start_text: + raise ValueError("Start sector is required.") + if not count_text: + raise ValueError("Sector count is required.") + if not filename: + raise ValueError("Output filename is required.") + sectors = int(count_text) + if sectors <= 0: + raise ValueError("Sector count must be greater than zero.") + params["start"] = int(start_text) + params["sectors"] = sectors + params["filename"] = filename return params diff --git a/mtk_gui/ui/tabs/rpmb_tab.py b/mtk_gui/ui/tabs/rpmb_tab.py index 4d8fa8f..1beccee 100644 --- a/mtk_gui/ui/tabs/rpmb_tab.py +++ b/mtk_gui/ui/tabs/rpmb_tab.py @@ -84,6 +84,7 @@ def _setup_ui(self): erase_layout = QHBoxLayout(erase_group) erase_layout.addStretch() self._erase_btn = QPushButton("Erase RPMB") + self._erase_btn.setToolTip("Wipe all RPMB data. Only works on xflash/xml-mode devices.") self._erase_btn.setProperty("destructive", True) self._erase_btn.setEnabled(False) erase_layout.addWidget(self._erase_btn) @@ -95,6 +96,7 @@ def _setup_ui(self): auth_layout.addWidget(QLabel("Auth Key (32-byte hex):")) self._auth_key = QLineEdit() self._auth_key.setPlaceholderText("00" * 32) + self._auth_key.setToolTip("32-byte RPMB authentication key in hex. Required for authenticated RPMB access.") auth_layout.addWidget(self._auth_key, 1) layout.addWidget(auth_group) @@ -143,6 +145,26 @@ def erase_button(self) -> QPushButton: """ return self._erase_btn + def get_read_params(self) -> dict: + return { + "filename": self._read_file.text().strip() or None, + "sector": self._read_sector.text().strip() or None, + "sectors": self._read_sectors.text().strip() or None, + } + + def get_write_params(self) -> dict: + """Returns write params. Raises ValueError on invalid input.""" + filename = self._write_file.text().strip() + if not filename: + raise ValueError("Input file is required for RPMB write.") + sector_text = self._write_sector.text().strip() + sectors_text = self._write_sectors.text().strip() + return { + "filename": filename, + "sector": int(sector_text) if sector_text else 0, + "sectors": int(sectors_text) if sectors_text else None, + } + def set_enabled_all(self, enabled: bool): """ Enable or disable all RPMB operation buttons. diff --git a/mtk_gui/ui/tabs/server_tab.py b/mtk_gui/ui/tabs/server_tab.py index 36e4e5e..2bef85b 100644 --- a/mtk_gui/ui/tabs/server_tab.py +++ b/mtk_gui/ui/tabs/server_tab.py @@ -29,6 +29,7 @@ def _setup_ui(self): btn_row = QHBoxLayout() self._run_btn = QPushButton("Run Key Server") + self._run_btn.setToolTip("Run the mtkclient key exchange. Completes automatically when done.") self._run_btn.setProperty("success", True) self._run_btn.setEnabled(False) btn_row.addWidget(self._run_btn) diff --git a/mtk_gui/ui/tabs/write_tab.py b/mtk_gui/ui/tabs/write_tab.py index b58566b..73c008d 100644 --- a/mtk_gui/ui/tabs/write_tab.py +++ b/mtk_gui/ui/tabs/write_tab.py @@ -8,6 +8,7 @@ ) from mtk_gui.constants import PART_TYPES +from mtk_gui.ui.widgets.dismissable_banner import DismissableBanner class WriteTab(QWidget): @@ -22,12 +23,11 @@ def _setup_ui(self): layout.setSpacing(8) # Warning banner - warn = QLabel("WARNING: Writing to flash is destructive. Ensure you have backups.") - warn.setStyleSheet( - "background-color: #442200; color: #ffaa44; padding: 6px; " - "border: 1px solid #cc8844; border-radius: 3px; font-weight: bold;" + warn = DismissableBanner( + "WARNING: Writing to flash is destructive. Ensure you have backups.", + banner_type="warning", + settings_key="banner/write_warning", ) - warn.setWordWrap(True) layout.addWidget(warn) # ── Mode selector ───────────────────────────────── @@ -47,6 +47,7 @@ def _setup_ui(self): mode_layout.addWidget(QLabel("Part Type:")) self._parttype_combo = QComboBox() self._parttype_combo.addItems(PART_TYPES) + self._parttype_combo.setToolTip("Usually 'user'. Only change if you know the target storage region.") mode_layout.addWidget(self._parttype_combo) layout.addWidget(mode_group) @@ -60,6 +61,7 @@ def _setup_ui(self): self._part_tree = QTreeWidget() self._part_tree.setHeaderLabels(["Partition", "File"]) + self._part_tree.setToolTip("Double-click the File column to select a file for each partition.") self._part_tree.setRootIsDecorated(False) header = self._part_tree.header() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) diff --git a/mtk_gui/ui/widgets/dismissable_banner.py b/mtk_gui/ui/widgets/dismissable_banner.py new file mode 100644 index 0000000..5b28cc0 --- /dev/null +++ b/mtk_gui/ui/widgets/dismissable_banner.py @@ -0,0 +1,86 @@ +"""Dismissable banner widget — colored warning/info message with persistent close.""" +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton + + +_STYLES = { + "danger": { + "background": "#440000", + "color": "#ff4444", + "border": "#cc4444", + }, + "warning": { + "background": "#442200", + "color": "#ffaa44", + "border": "#cc8844", + }, +} + + +class DismissableBanner(QWidget): + """A colored banner with a close button that remembers dismissal via QSettings. + + Parameters + ---------- + message : str + Text to display. + banner_type : str + ``"warning"`` or ``"danger"`` — selects the color scheme. + settings_key : str + QSettings key used to persist the dismissed state. + parent : QWidget | None + Parent widget. + """ + + def __init__( + self, + message: str, + banner_type: str = "warning", + settings_key: str = "", + parent: QWidget | None = None, + ): + super().__init__(parent) + self._settings_key = settings_key + + # Hide if previously dismissed + if settings_key: + settings = QSettings() + if settings.value(settings_key, False, type=bool): + self.setVisible(False) + return + + style = _STYLES.get(banner_type, _STYLES["warning"]) + + self.setStyleSheet( + f"background-color: {style['background']}; " + f"border: 1px solid {style['border']}; " + f"border-radius: 3px;" + ) + + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + label = QLabel(message) + label.setWordWrap(True) + label.setStyleSheet( + f"color: {style['color']}; font-weight: bold; " + "border: none; background: transparent;" + ) + layout.addWidget(label, 1) + + close_btn = QPushButton("\u00d7") + close_btn.setFixedSize(20, 20) + close_btn.setCursor(self.cursor()) + close_btn.setStyleSheet( + f"color: {style['color']}; background: transparent; " + "border: none; font-weight: bold; font-size: 14px;" + ) + close_btn.clicked.connect(self._dismiss) + layout.addWidget(close_btn) + + def _dismiss(self): + self.setVisible(False) + if self._settings_key: + settings = QSettings() + settings.setValue(self._settings_key, True)