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.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..1052a6b --- /dev/null +++ b/mtk_gui/app.py @@ -0,0 +1,65 @@ +"""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: + """ + 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__))) + dirs.append(os.path.join(app_dir, "plugins")) + # User config plugins dir + if sys.platform == "win32": + 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") + if os.path.isabs(config_dir): + dirs.append(config_dir) + return dirs + + +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) + + 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..be16c04 --- /dev/null +++ b/mtk_gui/backend/device_manager.py @@ -0,0 +1,219 @@ +"""Connection state machine and USB polling.""" +import logging +import threading +from enum import Enum, auto +from typing import Optional + +from PySide6.QtCore import QObject, 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] = [] + self._shutting_down = False + + # 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 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): + """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).""" + if self._shutting_down: + return + 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) + 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.""" + 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)) + worker.cancelled.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() + # Wait briefly for workers to finish + for w in list(self._workers): + w.wait(2000) + 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..c4930f9 --- /dev/null +++ b/mtk_gui/backend/log_interceptor.py @@ -0,0 +1,88 @@ +"""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): + """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) + 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): + """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): + """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 new file mode 100644 index 0000000..8e4a36c --- /dev/null +++ b/mtk_gui/backend/mtk_wrapper.py @@ -0,0 +1,621 @@ +"""Clean facade over mtkclient API. No Qt dependency.""" +import logging +import math +import os +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 + + +@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. + + 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): + 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 + + 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 + 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 SystemExit: + return False + + 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 (SystemExit, 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 + try: + return self.da_handler.da_read( + partition_name, parttype, filename, display=True + ) + except SystemExit: + return False + + 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 all partitions to directory.""" + if self.da_handler is None: + return False + try: + return self.da_handler.da_rl(directory, parttype, skip or [], display=True) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_rf(filename, parttype, offset, length, display=True) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_rs(start, sectors, filename, parttype, display=True) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_ro(start, length, filename, parttype, display=True) + except SystemExit: + return False + + # ── 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 + try: + return self.da_handler.da_write(parttype, filenames, partitions) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_wf(filenames, parttype) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_wl(parttype, directory) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_wo(start, length, filename, parttype) + except SystemExit: + return False + + # ── 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 + try: + return self.da_handler.da_erase(partitions, parttype) + except SystemExit: + return False + + 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 + try: + return self.da_handler.da_ess(sector, sectors, parttype) + except SystemExit: + return False + + # ── 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 + try: + self.config.hwparam_path = directory + return self.mtk.daloader.keys() + except SystemExit: + return False + + 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 + try: + lockflag = "lock" if lock else "unlock" + return self.mtk.daloader.seccfg(lockflag, critical) + except SystemExit: + return False + + def vbmeta_patch(self, vbmode: int = 3, worker=None) -> bool: + """Patch vbmeta for verified boot bypass.""" + if self.da_handler is None: + return False + try: + return self.da_handler.da_vbmeta(vbmode, display=True) + except SystemExit: + return False + + # ── 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 + try: + return self.da_handler.da_peek(addr, length, filename, registers) + except SystemExit: + return None + + def poke(self, addr: int, data: str, filename: str = "", worker=None) -> bool: + """Write memory at address.""" + if self.da_handler is None: + return False + try: + return self.da_handler.da_poke(addr, data, filename) + except SystemExit: + return False + + def dump_brom(self, filename: str, worker=None) -> bool: + """Dump BROM.""" + if self.da_handler is None: + return False + try: + return self.da_handler.da_brom(filename) + except SystemExit: + return False + + # ── 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 + try: + return self.mtk.daloader.read_rpmb(filename, sector, sectors) + except SystemExit: + return None + + 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 + try: + return self.mtk.daloader.write_rpmb(filename, sector, sectors) + 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 + + # ── eFuse ───────────────────────────────────────────── + + def read_efuses(self, worker=None): + """Read eFuse values.""" + if self.da_handler is None: + return None + try: + return self.da_handler.da_efuse() + except SystemExit: + return None + + # ── GPT ─────────────────────────────────────────────── + + def dump_gpt(self, directory: str, worker=None) -> bool: + """Dump GPT to directory.""" + if self.da_handler is None: + return False + try: + return self.da_handler.da_gpt(directory, display=True) + except SystemExit: + return False + + # ── IMEI ────────────────────────────────────────────── + + def read_imei(self, seed: bytes = b"", aeskey: bytes = b"\x00" * 32, + worker=None) -> list: + """Read IMEI(s) from nvdata partition. Returns list of (imei, valid) tuples.""" + if self.da_handler is None: + return [] + 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 SystemExit: + return [] + + def write_imei(self, imeis: list, seed: bytes = b"", + aeskey: bytes = b"\x00" * 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 + try: + from mtkclient.Library.utils import find_binary + from mtkclient.Library.mtk_crypto import ( + encode_imei, calc_checksum, make_luhn_checksum, + ) + + 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 SystemExit: + return False + + 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 + 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"\x00" * 32, display=False, + ) + except SystemExit: + return None + + # ── 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 + try: + self.mtk.crasher(mode=mode) + return True + except SystemExit: + return False + + 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 + try: + with open(filename, "rb") as f: + data = f.read() + return self.mtk.daloader.boot_to(addr=0x200000, data=data) + except SystemExit: + return False + + 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 + try: + with open(filename, "rb") as f: + data = f.read() + return self.mtk.daloader.boot_to(addr=addr, data=data) + except SystemExit: + return False + + 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 + try: + return self.mtk.daloader.setmetamode(porttype) + except SystemExit: + return False + + def shutdown(self, worker=None) -> bool: + """Shutdown device.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + try: + return self.mtk.daloader.shutdown() + except SystemExit: + return False + + def reset_device(self, worker=None) -> bool: + """Reset/reboot device.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + try: + return self.mtk.daloader.shutdown( + bootmode=self.mtk.daloader.ShutDownModes.HOME_SCREEN + ) + except SystemExit: + return False + + def keyserver(self, worker=None) -> bool: + """Run key server.""" + if self.mtk is None or not hasattr(self.mtk, 'daloader'): + return False + try: + return self.mtk.daloader.keyserver() + except SystemExit: + return False + + def patch_modem(self, worker=None) -> bool: + """Patch modem partition.""" + if self.da_handler is None: + return False + try: + return self.da_handler.da_patch_modem() + except SystemExit: + return False + + # ── 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") + 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..d42a069 --- /dev/null +++ b/mtk_gui/backend/worker.py @@ -0,0 +1,59 @@ +"""QThread-based worker for running backend operations off the GUI thread.""" +import traceback +from PySide6.QtCore import QThread, Signal + + +class Worker(QThread): + """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): + """ + 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 + self._kwargs = 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 self._cancelled: + self.cancelled.emit() + else: + self.finished.emit(result) + 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/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..e635492 --- /dev/null +++ b/mtk_gui/main_window.py @@ -0,0 +1,915 @@ +"""Main window — connects backend to UI.""" +import logging +import os +from PySide6.QtCore import Qt, QSettings, QUrl +from PySide6.QtGui import QDesktopServices +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 = [] + self._last_peek_addr = 0 + + # 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("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): + 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) + + 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): + 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"] + + 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 + 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"]) + 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 + 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) + 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)): + self._memory_tab.hex_viewer.set_data(data, self._last_peek_addr) + + def _on_poke(self): + wrapper = self.device_manager.wrapper + if not wrapper: + return + 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): + 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 + 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, 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): + 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, 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): + 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 _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): + wrapper = self.device_manager.wrapper + if not wrapper: + return + params = self._imei_tab.get_imei_params() + 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) + 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 + 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, 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"]) + 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.begin_shutdown() + 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", + "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/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..6b48fcd --- /dev/null +++ b/mtk_gui/plugins/base_plugin.py @@ -0,0 +1,109 @@ +"""Abstract base class for MTK-GUI plugins.""" +from abc import ABC, abstractmethod + + +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): + """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): + """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): + """Get the DeviceManager instance.""" + return self._app.main_window.device_manager + + def get_device_info(self) -> 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 + 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): + """ + 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 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) + + +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: + """ + Handle notification that a device has connected. + + Parameters: + device_info (dict): Information about the connected device. + """ + pass + + def on_device_disconnected(self) -> None: + """Called when a device disconnects.""" + pass + + def cleanup(self) -> None: + """Releases plugin resources 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..92c04b8 --- /dev/null +++ b/mtk_gui/plugins/loader.py @@ -0,0 +1,78 @@ +"""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: + """ + 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: + 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 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) + + 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..597ffc1 --- /dev/null +++ b/mtk_gui/theme/__init__.py @@ -0,0 +1,20 @@ +"""Theme management.""" +from mtk_gui.theme.colors import Colors + + +def load_stylesheet(theme_name: str) -> str: + """ + 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): + 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..d21736b --- /dev/null +++ b/mtk_gui/ui/tabs/bootloader_tab.py @@ -0,0 +1,106 @@ +"""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): + """ + Build the controls for bootloader state changes and vbmeta patching. + """ + 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)") + 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) + + 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.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) + 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.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) + vb_btn_row.addStretch() + vb_layout.addLayout(vb_btn_row) + + layout.addWidget(vb_group) + layout.addStretch() + + @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 new file mode 100644 index 0000000..db646c3 --- /dev/null +++ b/mtk_gui/ui/tabs/device_tab.py @@ -0,0 +1,258 @@ +"""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): + """Set the indicator's background color.""" + 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): + """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) + + # ── 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.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) + + 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)") + 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) + 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)") + 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) + 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(".") + 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) + wd_browse.clicked.connect(self._browse_workdir) + settings_grid.addWidget(wd_browse, 3, 2) + + # 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) + + layout.addWidget(settings_group) + 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) + + # ── Public API ──────────────────────────────────────── + + @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 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() + 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 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, + "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 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), + 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..90a5800 --- /dev/null +++ b/mtk_gui/ui/tabs/efuse_tab.py @@ -0,0 +1,57 @@ +"""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): + """Build the widget layout with a disabled read button and a read-only eFuse table.""" + layout = QVBoxLayout(self) + layout.setSpacing(8) + + 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) + 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: + """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): + """ + 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))) + 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..e3a3b33 --- /dev/null +++ b/mtk_gui/ui/tabs/erase_tab.py @@ -0,0 +1,130 @@ +"""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.dismissable_banner import DismissableBanner +from mtk_gui.ui.widgets.partition_list import PartitionList +from mtk_gui.constants import PART_TYPES + + +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) + + # Warning + warn = DismissableBanner( + "WARNING: Erasing partitions is irreversible. Data will be permanently lost.", + banner_type="danger", + settings_key="banner/erase_warning", + ) + 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) + 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) + + # 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.setToolTip("Permanently destroys data. Cannot be undone.") + 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 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: + 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..b5585ce --- /dev/null +++ b/mtk_gui/ui/tabs/exploit_tab.py @@ -0,0 +1,202 @@ +"""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): + """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) + + # ── Preloader crash ─────────────────────────────── + crash_group = QGroupBox("Preloader Crash") + 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") + 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.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) + 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() + 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) + 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") + 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) + 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.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") + 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.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() + layout.addWidget(ctrl_group) + + 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) + + @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 the device reset button.""" + return self._reset_btn + + @property + def shutdown_button(self) -> QPushButton: + return self._shutdown_btn + + @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 + return int(text.removeprefix("0x").removeprefix("0X"), 16) + + @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 new file mode 100644 index 0000000..05b9a71 --- /dev/null +++ b/mtk_gui/ui/tabs/imei_tab.py @@ -0,0 +1,202 @@ +"""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): + """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) + + # ── 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)") + 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) + + 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)") + 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) + 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.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) + 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() + 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) + 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): + """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}" + self._imei_display.setText(text) + + @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(), + "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): + """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 new file mode 100644 index 0000000..363e4a8 --- /dev/null +++ b/mtk_gui/ui/tabs/keys_tab.py @@ -0,0 +1,100 @@ +"""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): + """ + Build the widget layout for directory selection, key generation, key display, and status messages. + """ + 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(".") + 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) + 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.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) + 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): + """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 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 new file mode 100644 index 0000000..ea33595 --- /dev/null +++ b/mtk_gui/ui/tabs/memory_tab.py @@ -0,0 +1,179 @@ +"""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") + 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() + + 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") + 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) + + 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.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") + 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: + """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: + """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: + 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..4c487ed --- /dev/null +++ b/mtk_gui/ui/tabs/read_tab.py @@ -0,0 +1,226 @@ +"""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) + 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() + + # Part type + 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) + + # ── 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") + self._dump_gpt_check.setToolTip("Save the partition table alongside the partition data.") + 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: + """Returns params dict. Raises ValueError on invalid input.""" + 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: + 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: + 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: + 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 new file mode 100644 index 0000000..1beccee --- /dev/null +++ b/mtk_gui/ui/tabs/rpmb_tab.py @@ -0,0 +1,176 @@ +"""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): + """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) + + # ── 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.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) + 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) + 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) + + 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 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. + + 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 new file mode 100644 index 0000000..2bef85b --- /dev/null +++ b/mtk_gui/ui/tabs/server_tab.py @@ -0,0 +1,83 @@ +"""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): + """ + Build the key-server controls, status display, and read-only server log. + """ + 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.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) + 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: + """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;") + 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): + """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 new file mode 100644 index 0000000..73c008d --- /dev/null +++ b/mtk_gui/ui/tabs/write_tab.py @@ -0,0 +1,253 @@ +"""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 +from mtk_gui.ui.widgets.dismissable_banner import DismissableBanner + + +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) + + # Warning banner + warn = DismissableBanner( + "WARNING: Writing to flash is destructive. Ensure you have backups.", + banner_type="warning", + settings_key="banner/write_warning", + ) + 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) + 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) + + # ── 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.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) + 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): + """ + 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: + item.setText(1, path) + + # ── Public API ──────────────────────────────────────── + + @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, ""]) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + 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: + 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..91b6433 --- /dev/null +++ b/mtk_gui/ui/widgets/confirmation_dialog.py @@ -0,0 +1,103 @@ +"""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): + """ + 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) + self.setModal(True) + self._confirmed = False + 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) + + # 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): + """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: + """ + 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/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) diff --git a/mtk_gui/ui/widgets/hex_viewer.py b/mtk_gui/ui/widgets/hex_viewer.py new file mode 100644 index 0000000..7674057 --- /dev/null +++ b/mtk_gui/ui/widgets/hex_viewer.py @@ -0,0 +1,54 @@ +"""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 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] + 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): + """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 new file mode 100644 index 0000000..8f484e0 --- /dev/null +++ b/mtk_gui/ui/widgets/log_panel.py @@ -0,0 +1,156 @@ +"""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): + """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) + + # 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(): + """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) + return font + + @Slot(str, int) + def append_log(self, message: str, level: int = logging.INFO): + """ + 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)) + 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): + """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: + 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..b531de1 --- /dev/null +++ b/mtk_gui/ui/widgets/partition_list.py @@ -0,0 +1,128 @@ +"""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): + """Builds the widget layout with partition controls and a checkable partition tree.""" + 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): + """ + 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 + + 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): + """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) + self._tree.blockSignals(False) + 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) + 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: + """ + 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") + 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..f97cd34 --- /dev/null +++ b/mtk_gui/ui/widgets/progress_panel.py @@ -0,0 +1,155 @@ +"""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): + """Initialize the progress panel and its progress-tracking state.""" + super().__init__(parent) + self._start_time = 0 + self._last_update = 0 + self._last_bytes = 0 + 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) + + # 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 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 + + 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): + """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 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("") + 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: + """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: + return f"{bps / 1024:.1f} KB/s" + return f"{bps:.0f} B/s" + + @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" + 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..28b2d29 --- /dev/null +++ b/mtk_gui/ui/widgets/serial_port_dialog.py @@ -0,0 +1,70 @@ +"""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): + """ + Initialize the serial port selection dialog and its interface. + """ + super().__init__(parent) + self.setWindowTitle("Select Serial Port") + self.setMinimumWidth(300) + self._selected_port = 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:")) + + 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): + """Refresh the available serial port options in the selection list.""" + 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): + """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 new file mode 100644 index 0000000..b637532 --- /dev/null +++ b/plugins/example_plugin.py @@ -0,0 +1,57 @@ +"""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): + """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() + 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): + """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']}") + else: + 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: + 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()