From 1ef520e0953e9d2743c2cfeed66324b7687bf91a Mon Sep 17 00:00:00 2001 From: Nolan <112007432+CephandriusMaxtori@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:28:27 -0400 Subject: [PATCH 1/3] Change OS label from 'ubuntu-latest' to 'linux-latest' Change for better clarity --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6d24f0d..48199ba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,7 +39,7 @@ jobs: fail-fast: false matrix: os: - - ubuntu-latest + - linux-latest - windows-latest - macos-latest - macos-15-intel From 5041db2a8da58e8fc00713c77f4b7b27bd617397 Mon Sep 17 00:00:00 2001 From: Nolan Bragan Date: Fri, 3 Apr 2026 16:05:29 -0400 Subject: [PATCH 2/3] added gui 1.0 and tui 1.0 --- .gitignore | 3 ++- README.md | 2 +- pyproject.toml | 20 ++++++++++---------- requirements.txt | 2 ++ 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 068324d..1a66145 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +uv.lock # PyInstaller # Usually these files are written by a python script from a template @@ -133,4 +134,4 @@ updates/ rm-docker/ nuitka-crash-report.xml .nuitka/ -compilation-report.xml \ No newline at end of file +compilation-report.xml diff --git a/README.md b/README.md index c2584ba..5b90ebd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ When downgrading a Paper Pro device across the 3.20/3.22 firmware boundary, code You can find pre-compiled binaries on the [releases](https://github.com/Jayy001/codexctl/releases/) page. This includes a build for the reMarkable itself, as well as well as builds for linux, macOS, and Windows. Alternatively, you can install directly from pypi with `pip install codexctl`. Codexctl currently only has support for a **command line interfaces** but a graphical interface is soon to come. -Finally, if you want to build it yourself, you can run `make executable` which requires python 3.11 or newer, python-venv and pip. Linux also requires libfuse-dev. +Finally, if you want to build it yourself, first sudo pacman -S fuse2 pkgconf, you can run `make executable` which requires python 3.11 or newer, python-venv and pip. Linux also requires libfuse-dev. ## General useage diff --git a/pyproject.toml b/pyproject.toml index 30651b1..a2481ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,20 @@ -[tool.poetry] +[project] name = "codexctl" version = "1.2.1" description = "Automated update managment for the ReMarkable tablet" authors = ["Jayy001 "] license = "GPLv3" readme = "README.md" +dependencies = [ + "loguru==0.7.3", + "pyqt6>=6.11.0", + "remarkable-update-fuse==1.3.2 ; sys_platform == 'linux'", + "remarkable-update-image==1.3.2 ; sys_platform != 'linux'", + "requests==2.32.5", + "textual>=8.2.2", +] -[tool.poetry.dependencies] +[dependencies] python = ">=3.12,<3.14" paramiko = "3.4.1" psutil = "6.0.0" @@ -14,11 +22,3 @@ requests = "2.32.4" loguru = "0.7.3" remarkable-update-image = { version = "1.3", markers = "sys_platform != 'linux'" } remarkable-update-fuse = { version = "1.3", markers = "sys_platform == 'linux'" } - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" - -[tool.poetry.scripts] -codexctl = "codexctl.__main__:main" -cxtl = "codexctl.__main__:main" diff --git a/requirements.txt b/requirements.txt index 863a1c3..499e796 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ requests==2.32.5 loguru==0.7.3 remarkable-update-image==1.3.2; sys_platform != 'linux' remarkable-update-fuse==1.3.2; sys_platform == 'linux' +textual +PyQt6 From 53e90edae816ad51bf5699c168eac29197fb1678 Mon Sep 17 00:00:00 2001 From: Nolan Bragan Date: Fri, 3 Apr 2026 16:06:41 -0400 Subject: [PATCH 3/3] added requirements --- gui.py | 550 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tui.py | 388 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 938 insertions(+) create mode 100644 gui.py create mode 100644 tui.py diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..c712bac --- /dev/null +++ b/gui.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +""" +codexctl GUI - Graphical User Interface for reMarkable firmware management +Requires: pip install PyQt6 codexctl + (no system Tk/libtk needed) +""" + +import sys +import subprocess +import threading + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QTabWidget, QTextEdit, QComboBox, + QFileDialog, QMessageBox, QFrame, QSizePolicy, QSpacerItem, +) +from PyQt6.QtGui import QFont, QColor, QPalette, QTextCharFormat, QTextCursor +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject + + +# ─── Colors ─────────────────────────────────────────────────────────────────── + +BG = "#0f1117" +SURFACE = "#1a1d27" +CARD = "#222536" +ACCENT = "#6c8cff" +SUCCESS = "#34d399" +WARNING = "#fbbf24" +DANGER = "#f87171" +TEXT = "#e2e8f0" +MUTED = "#64748b" +BORDER = "#2e3347" +BTN = "#2d3155" +BTN_H = "#3d4370" + +GLOBAL_CSS = f""" +QMainWindow, QWidget {{ + background-color: {BG}; + color: {TEXT}; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; + font-size: 13px; +}} +QTabWidget::pane {{ + border: 1px solid {BORDER}; + background: {CARD}; + border-radius: 4px; +}} +QTabBar::tab {{ + background: {SURFACE}; + color: {MUTED}; + padding: 8px 16px; + margin-right: 2px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + font-size: 12px; +}} +QTabBar::tab:selected {{ + background: {CARD}; + color: {ACCENT}; + font-weight: bold; +}} +QTabBar::tab:hover {{ + background: {BTN}; + color: {TEXT}; +}} +QLineEdit {{ + background: {SURFACE}; + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 5px 8px; + font-family: "JetBrains Mono", monospace; +}} +QLineEdit:focus {{ + border-color: {ACCENT}; +}} +QComboBox {{ + background: {SURFACE}; + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 5px 8px; +}} +QComboBox QAbstractItemView {{ + background: {SURFACE}; + color: {TEXT}; + selection-background-color: {BTN_H}; +}} +QPushButton {{ + background: {BTN}; + color: {TEXT}; + border: none; + border-radius: 4px; + padding: 7px 18px; + font-weight: bold; + font-family: "JetBrains Mono", monospace; +}} +QPushButton:hover {{ background: {ACCENT}; color: #ffffff; }} +QPushButton:pressed {{ background: {BTN_H}; }} +QPushButton.warning {{ color: {WARNING}; }} +QPushButton.warning:hover {{ background: #92400e; color: #ffffff; }} +QPushButton.danger {{ color: {DANGER}; }} +QPushButton.danger:hover {{ background: #991b1b; color: #ffffff; }} +QTextEdit {{ + background: {SURFACE}; + color: {SUCCESS}; + border: none; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 12px; +}} +QScrollBar:vertical {{ + background: {SURFACE}; width: 8px; border-radius: 4px; +}} +QScrollBar::handle:vertical {{ + background: {BORDER}; border-radius: 4px; min-height: 20px; +}} +QFrame#conn-bar {{ + background: {SURFACE}; + border-bottom: 1px solid {BORDER}; +}} +QLabel.muted {{ color: {MUTED}; font-size: 12px; }} +QLabel.section {{ color: {ACCENT}; font-size: 14px; font-weight: bold; }} +QLabel.info {{ color: {MUTED}; font-size: 11px; }} +""" + + +# ─── Worker thread ──────────────────────────────────────────────────────────── + +class CmdWorker(QObject): + line = pyqtSignal(str, str) # (text, colour) + done = pyqtSignal(int) + + def __init__(self, args, address, password): + super().__init__() + self.args = args + self.address = address + self.password = password + + def run(self): + cmd = ["codexctl"] + if self.address: + cmd += ["--address", self.address] + if self.password: + cmd += ["--password", self.password] + cmd += self.args + + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + for ln in proc.stdout: + self.line.emit(ln.rstrip(), TEXT) + proc.wait() + rc = proc.returncode + except FileNotFoundError: + self.line.emit( + "ERROR: codexctl not found — install with: pip install codexctl", DANGER) + rc = 1 + except Exception as e: + self.line.emit(f"ERROR: {e}", DANGER) + rc = 1 + + self.done.emit(rc) + + +# ─── Main window ────────────────────────────────────────────────────────────── + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("codexctl — reMarkable Firmware Manager") + self.resize(980, 720) + self.setMinimumSize(800, 560) + self.setStyleSheet(GLOBAL_CSS) + self._threads = [] # keep references alive + + root = QWidget() + self.setCentralWidget(root) + root_layout = QVBoxLayout(root) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + + # title bar + title_bar = QFrame() + title_bar.setFixedHeight(52) + title_bar.setStyleSheet(f"background: {BG}; padding: 0 16px;") + tb_lay = QHBoxLayout(title_bar) + tb_lay.setContentsMargins(16, 0, 16, 0) + lbl = QLabel("⬡ codexctl") + lbl.setStyleSheet(f"color: {ACCENT}; font-size: 20px; font-weight: bold;") + tb_lay.addWidget(lbl) + sub = QLabel("reMarkable Firmware Manager") + sub.setStyleSheet(f"color: {MUTED}; font-size: 11px; padding-left: 10px;") + tb_lay.addWidget(sub) + tb_lay.addStretch() + root_layout.addWidget(title_bar) + + # connection bar + conn = QFrame() + conn.setObjectName("conn-bar") + conn.setFixedHeight(46) + conn_lay = QHBoxLayout(conn) + conn_lay.setContentsMargins(16, 0, 16, 0) + conn_lay.setSpacing(8) + + self.addr_edit = self._conn_pair(conn_lay, "Address:", "10.11.99.1") + self.pass_edit = self._conn_pair(conn_lay, "Password / SSH key:", "alpine", echo=QLineEdit.EchoMode.Password) + conn_lay.addStretch() + root_layout.addWidget(conn) + + # tabs + self.tabs = QTabWidget() + self.tabs.setDocumentMode(True) + root_layout.addWidget(self.tabs, 1) + + self._build_tabs() + + # output log + log_frame = QFrame() + log_frame.setStyleSheet(f"background: {SURFACE}; border-top: 1px solid {BORDER};") + log_lay = QVBoxLayout(log_frame) + log_lay.setContentsMargins(0, 0, 0, 0) + log_lay.setSpacing(0) + + log_header = QFrame() + log_header.setStyleSheet(f"background: {BG}; padding: 2px 12px;") + lh_lay = QHBoxLayout(log_header) + lh_lay.setContentsMargins(12, 2, 12, 2) + lh_lay.addWidget(QLabel("Output", styleSheet=f"color: {MUTED}; font-size: 11px;")) + lh_lay.addStretch() + clear_btn = QPushButton("Clear") + clear_btn.setFixedSize(60, 22) + clear_btn.setStyleSheet(f"background: transparent; color: {MUTED}; font-size: 11px; font-weight: normal;") + clear_btn.clicked.connect(self._clear_log) + lh_lay.addWidget(clear_btn) + log_lay.addWidget(log_header) + + self.log = QTextEdit() + self.log.setReadOnly(True) + self.log.setFixedHeight(160) + log_lay.addWidget(self.log) + root_layout.addWidget(log_frame) + + # ── connection pair ─────────────────────────────────────────────────────── + + def _conn_pair(self, layout, label, placeholder="", echo=None): + lbl = QLabel(label) + lbl.setProperty("class", "muted") + layout.addWidget(lbl) + edit = QLineEdit() + edit.setPlaceholderText(placeholder) + edit.setFixedWidth(200) + if echo: + edit.setEchoMode(echo) + layout.addWidget(edit) + return edit + + # ── tabs ────────────────────────────────────────────────────────────────── + + def _build_tabs(self): + defs = [ + ("📊 Status", self._tab_status), + ("📋 List", self._tab_list), + ("⬇ Download", self._tab_download), + ("⬆ Install", self._tab_install), + ("💾 Backup", self._tab_backup), + ("↩ Restore", self._tab_restore), + ("📤 Upload", self._tab_upload), + ("📦 Extract", self._tab_extract), + ("🔍 Cat / LS", self._tab_cat_ls), + ] + for label, builder in defs: + w = QWidget() + w.setStyleSheet(f"background: {CARD};") + lay = QVBoxLayout(w) + lay.setContentsMargins(20, 16, 20, 16) + lay.setSpacing(10) + builder(lay) + lay.addStretch() + self.tabs.addTab(w, label) + + # ── tab builders ────────────────────────────────────────────────────────── + + def _tab_status(self, lay): + self._section(lay, "Device Status") + self._info(lay, "Retrieves the current firmware version and other info over SSH.") + lay.addWidget(self._btn("Get Status", self._run_status)) + + def _tab_list(self, lay): + self._section(lay, "Available Firmware Versions") + self._info(lay, "List firmware versions available for your device model.") + self.hw_list_cb = self._hw_row(lay) + lay.addWidget(self._btn("List Versions", self._run_list)) + + def _tab_download(self, lay): + self._section(lay, "Download Firmware") + self._info(lay, "Download a firmware .swu file to your machine.") + self.dl_ver = self._field_row(lay, "Version:", "e.g. 3.15.4.2") + self.hw_dl_cb = self._hw_row(lay) + self.dl_out = self._field_row(lay, "Output dir:", "./firmware", browse_dir=True) + lay.addWidget(self._btn("Download", self._run_download)) + + def _tab_install(self, lay): + self._section(lay, "Install Firmware") + self._info(lay, "Install a firmware version (downloads if needed) onto the device.") + self.inst_ver = self._field_row(lay, "Version / .swu:", "3.15.4.2 or ./file.swu", + browse_file=True) + b = self._btn("Install on Device", self._run_install) + b.setProperty("class", "warning") + b.setStyleSheet(f"QPushButton {{ color: {WARNING}; background: {BTN}; }}" + f"QPushButton:hover {{ background: #92400e; color: #fff; }}") + lay.addWidget(b) + + def _tab_backup(self, lay): + self._section(lay, "Backup Remote Files") + self._info(lay, "Download documents and notebooks from the device.") + self.bk_out = self._field_row(lay, "Output dir:", "./backup", browse_dir=True) + lay.addWidget(self._btn("Start Backup", self._run_backup)) + + def _tab_restore(self, lay): + self._section(lay, "Restore Previous Version") + self._info(lay, "Revert to the previously installed firmware.\n" + "⚠ This modifies your device firmware.") + b = self._btn("Restore Previous", self._run_restore) + b.setStyleSheet(f"QPushButton {{ color: {DANGER}; background: {BTN}; }}" + f"QPushButton:hover {{ background: #991b1b; color: #fff; }}") + lay.addWidget(b) + + def _tab_upload(self, lay): + self._section(lay, "Upload Files (PDF only)") + self._info(lay, "Upload PDF files or folders to the reMarkable device.") + self.up_path = self._field_row(lay, "Local path:", "/path/to/file.pdf", + browse_file=True, + file_filter="PDF files (*.pdf);;All files (*.*)") + lay.addWidget(self._btn("Upload", self._run_upload)) + + def _tab_extract(self, lay): + self._section(lay, "Extract Firmware File") + self._info(lay, "Extract the filesystem from a firmware .swu file.") + self.ex_ver = self._field_row(lay, "Version / .swu:", "3.15.4.2 or ./file.swu", + browse_file=True) + self.ex_out = self._field_row(lay, "Output dir:", "./extracted", browse_dir=True) + lay.addWidget(self._btn("Extract", self._run_extract)) + + def _tab_cat_ls(self, lay): + self._section(lay, "Cat — Read File inside Firmware") + self.cat_ver = self._field_row(lay, "Firmware file:", "3.15.4.2_reMarkable2-xxx.signed", + browse_file=True) + self.cat_path = self._field_row(lay, "Inner path:", "/etc/version") + lay.addWidget(self._btn("Cat File", self._run_cat)) + + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {BORDER};") + lay.addWidget(sep) + + self._section(lay, "LS — List Files in Firmware") + self.ls_ver = self._field_row(lay, "Firmware file:", "3.15.4.2_reMarkable2-xxx.signed", + browse_file=True) + lay.addWidget(self._btn("List Files", self._run_ls)) + + # ── widget helpers ──────────────────────────────────────────────────────── + + def _section(self, lay, text): + lbl = QLabel(text) + lbl.setProperty("class", "section") + lbl.setStyleSheet(f"color: {ACCENT}; font-size: 14px; font-weight: bold;") + lay.addWidget(lbl) + + def _info(self, lay, text): + lbl = QLabel(text) + lbl.setStyleSheet(f"color: {MUTED}; font-size: 11px;") + lbl.setWordWrap(True) + lay.addWidget(lbl) + + def _btn(self, text, slot, width=None): + b = QPushButton(text) + if width: + b.setFixedWidth(width) + b.clicked.connect(slot) + b.setCursor(Qt.CursorShape.PointingHandCursor) + return b + + def _field_row(self, lay, label, placeholder="", + browse_dir=False, browse_file=False, file_filter="All files (*.*)"): + row = QWidget(); row.setStyleSheet(f"background: {CARD};") + rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(8) + lbl = QLabel(label); lbl.setFixedWidth(140) + lbl.setStyleSheet(f"color: {MUTED}; font-size: 12px;") + rl.addWidget(lbl) + edit = QLineEdit(); edit.setPlaceholderText(placeholder); edit.setFixedWidth(340) + rl.addWidget(edit) + if browse_dir: + def pick(e=edit): + d = QFileDialog.getExistingDirectory(self, "Choose directory") + if d: e.setText(d) + rl.addWidget(self._btn("Browse…", pick, 80)) + elif browse_file: + def pick(e=edit, ff=file_filter): + p, _ = QFileDialog.getOpenFileName(self, "Choose file", filter=ff) + if p: e.setText(p) + rl.addWidget(self._btn("Browse…", pick, 80)) + rl.addStretch() + lay.addWidget(row) + return edit + + def _hw_row(self, lay): + row = QWidget(); row.setStyleSheet(f"background: {CARD};") + rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(8) + lbl = QLabel("Hardware:"); lbl.setFixedWidth(140) + lbl.setStyleSheet(f"color: {MUTED}; font-size: 12px;") + rl.addWidget(lbl) + cb = QComboBox(); cb.setFixedWidth(220) + cb.addItem("(auto-detect)", "") + cb.addItem("reMarkable 1", "rm1") + cb.addItem("reMarkable 2", "rm2") + cb.addItem("Paper Pro", "rmpp") + rl.addStretch() + lay.addWidget(row) + rl.insertWidget(1, cb) + return cb + + # ── logging ─────────────────────────────────────────────────────────────── + + def _write(self, text: str, colour: str = TEXT): + cur = self.log.textCursor() + cur.movePosition(QTextCursor.MoveOperation.End) + fmt = QTextCharFormat() + fmt.setForeground(QColor(colour)) + cur.setCharFormat(fmt) + cur.insertText(text + "\n") + self.log.setTextCursor(cur) + self.log.ensureCursorVisible() + + def _clear_log(self): + self.log.clear() + + # ── connections ─────────────────────────────────────────────────────────── + + def _conn(self): + return self.addr_edit.text().strip(), self.pass_edit.text().strip() + + def _val(self, edit: QLineEdit) -> str: + return edit.text().strip() + + def _hw(self, cb: QComboBox) -> str: + return cb.currentData() or "" + + def _confirm(self, msg: str) -> bool: + dlg = QMessageBox(self) + dlg.setWindowTitle("Confirm") + dlg.setText(msg) + dlg.setStandardButtons( + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) + dlg.setDefaultButton(QMessageBox.StandardButton.Cancel) + dlg.setStyleSheet(f"background: {SURFACE}; color: {TEXT};") + return dlg.exec() == QMessageBox.StandardButton.Ok + + def _warn(self, msg: str): + QMessageBox.warning(self, "Missing input", msg) + + # ── run helper ──────────────────────────────────────────────────────────── + + def _run(self, args: list[str]): + addr, pw = self._conn() + self._write(f"» codexctl {' '.join(args)}", ACCENT) + worker = CmdWorker(args, addr, pw) + thread = QThread() + worker.moveToThread(thread) + worker.line.connect(self._write) + worker.done.connect(lambda rc: self._write( + f"exit {rc}", SUCCESS if rc == 0 else DANGER)) + worker.done.connect(thread.quit) + thread.started.connect(worker.run) + thread.start() + # keep alive + self._threads.append((thread, worker)) + thread.finished.connect(lambda t=thread, w=worker: self._threads.remove((t, w)) + if (t, w) in self._threads else None) + + # ── command runners ─────────────────────────────────────────────────────── + + def _run_status(self): self._run(["status"]) + + def _run_list(self): + args = ["list"] + hw = self._hw(self.hw_list_cb) + if hw: args += ["--hardware", hw] + self._run(args) + + def _run_download(self): + ver = self._val(self.dl_ver) + if not ver: self._warn("Version is required."); return + args = ["download", ver] + hw = self._hw(self.hw_dl_cb) + if hw: args += ["--hardware", hw] + out = self._val(self.dl_out) + if out: args += ["-o", out] + self._run(args) + + def _run_install(self): + ver = self._val(self.inst_ver) + if not ver: self._warn("Version or .swu path is required."); return + if not self._confirm(f"Install '{ver}' on the device?"): return + self._run(["install", ver]) + + def _run_backup(self): + args = ["backup"] + out = self._val(self.bk_out) + if out: args += ["-o", out] + self._run(args) + + def _run_restore(self): + if not self._confirm("Restore previous firmware?\nThis modifies your device."): return + self._run(["restore"]) + + def _run_upload(self): + path = self._val(self.up_path) + if not path: self._warn("File path is required."); return + self._run(["upload", path]) + + def _run_extract(self): + ver = self._val(self.ex_ver) + if not ver: self._warn("Version or .swu path is required."); return + args = ["extract", ver] + out = self._val(self.ex_out) + if out: args += ["-o", out] + self._run(args) + + def _run_cat(self): + ver = self._val(self.cat_ver) + path = self._val(self.cat_path) + if not ver or not path: + self._warn("Firmware file and inner path are both required."); return + self._run(["cat", ver, path]) + + def _run_ls(self): + ver = self._val(self.ls_ver) + if not ver: self._warn("Firmware file is required."); return + self._run(["ls", ver]) + + +# ─── Entry point ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setStyle("Fusion") + win = MainWindow() + win.show() + sys.exit(app.exec()) diff --git a/tui.py b/tui.py new file mode 100644 index 0000000..a174f85 --- /dev/null +++ b/tui.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +codexctl TUI - Terminal User Interface for reMarkable firmware management +Requires: pip install textual codexctl +""" + +from textual.app import App, ComposeResult +from textual.widgets import ( + Header, Footer, Button, Static, Label, Input, Select, RichLog, +) +from textual.containers import Horizontal, Vertical, ScrollableContainer +from textual.screen import ModalScreen +from textual.binding import Binding +from textual import work, on +from textual.reactive import reactive +import subprocess + + +HARDWARE_OPTIONS = [ + ("reMarkable 1", "rm1"), + ("reMarkable 2", "rm2"), + ("reMarkable Paper Pro", "rmpp"), +] + + +def run_codexctl(args: list[str], address: str = "", password: str = "") -> tuple[str, str, int]: + cmd = ["codexctl"] + if address: + cmd += ["--address", address] + if password: + cmd += ["--password", password] + cmd += args + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + return result.stdout, result.stderr, result.returncode + except FileNotFoundError: + return "", "codexctl not found — install with: pip install codexctl", 1 + except subprocess.TimeoutExpired: + return "", "Command timed out (120 s).", 1 + except Exception as e: + return "", str(e), 1 + + +# ─── Confirm modal ──────────────────────────────────────────────────────────── + +class ConfirmScreen(ModalScreen): + CSS = """ + ConfirmScreen { align: center middle; } + #dialog { + padding: 1 2; background: $surface; + border: round $primary; width: 52; height: auto; + } + #dialog-msg { margin-bottom: 1; } + #dialog-btns { align: center middle; height: 3; } + #dialog-btns Button { margin: 0 1; } + """ + + def __init__(self, message: str, **kw): + super().__init__(**kw) + self.message = message + + def compose(self) -> ComposeResult: + with Vertical(id="dialog"): + yield Label(self.message, id="dialog-msg") + with Horizontal(id="dialog-btns"): + yield Button("Confirm", id="ok", variant="error") + yield Button("Cancel", id="cancel", variant="default") + + @on(Button.Pressed, "#ok") + def do_ok(self): self.dismiss(True) + + @on(Button.Pressed, "#cancel") + def do_cancel(self): self.dismiss(False) + + +# ─── Main app ───────────────────────────────────────────────────────────────── + +class CodexctlTUI(App): + CSS = """ + Screen { background: $background; } + Header { background: $primary-darken-3; } + + #sidebar { + width: 22; background: $surface; + border-right: solid $primary-darken-2; + padding: 1 1; + } + #sidebar-title { + text-align: center; text-style: bold; + color: $primary; margin-bottom: 1; + } + .nav-btn { + width: 100%; margin-bottom: 1; + background: $surface; border: none; + } + .nav-btn:hover { background: $primary-darken-2; } + .nav-btn.-active { background: $primary; text-style: bold; } + + #conn-bar { + height: 3; background: $surface-darken-1; + padding: 0 1; border-bottom: solid $primary-darken-3; + } + .conn-lbl { width: auto; padding: 1 1 0 0; color: $text-muted; } + .conn-inp { width: 24; margin-right: 1; } + + #main { padding: 1 2; } + #panel { height: auto; } + + .sec-title { text-style: bold; color: $accent; margin-bottom: 1; } + .card { + background: $surface; border: round $primary-darken-2; + padding: 1 2; margin-bottom: 1; + } + .field-row { height: 3; margin-bottom: 1; } + .field-lbl { width: 18; padding: 1 0; color: $text-muted; } + .field-inp { width: 32; } + .act-btn { margin-top: 1; margin-right: 1; } + + #output-log { + height: 11; border-top: solid $primary-darken-2; + background: $surface-darken-1; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("ctrl+l", "clear_output", "Clear log"), + ] + + _current_panel: reactive[str] = reactive("status") + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + + with Horizontal(id="conn-bar"): + yield Label("Address:", classes="conn-lbl") + yield Input(placeholder="10.11.99.1", id="inp-addr", classes="conn-inp") + yield Label("Password/SSH key:", classes="conn-lbl") + yield Input(placeholder="alpine", id="inp-pass", password=True, classes="conn-inp") + + with Horizontal(): + with Vertical(id="sidebar"): + yield Static("⬡ codexctl", id="sidebar-title") + for label, panel in [ + ("📊 Status", "status"), + ("📋 List", "list"), + ("⬇ Download", "download"), + ("⬆ Install", "install"), + ("💾 Backup", "backup"), + ("↩ Restore", "restore"), + ("📤 Upload", "upload"), + ("📦 Extract", "extract"), + ("🔍 Cat", "cat"), + ("📁 LS", "ls"), + ]: + yield Button(label, id=f"nav-{panel}", classes="nav-btn") + + with ScrollableContainer(id="main"): + yield Vertical(id="panel") + + yield RichLog(id="output-log", markup=True, highlight=True) + yield Footer() + + def on_mount(self) -> None: + self._switch_panel("status") + self.query_one("#nav-status").add_class("-active") + + # ── navigation ─────────────────────────────────────────────────────────── + + @on(Button.Pressed, ".nav-btn") + def _nav(self, event: Button.Pressed) -> None: + name = event.button.id.removeprefix("nav-") + for b in self.query(".nav-btn"): + b.remove_class("-active") + event.button.add_class("-active") + self._switch_panel(name) + + def _switch_panel(self, name: str) -> None: + self._current_panel = name + panel = self.query_one("#panel", Vertical) + panel.remove_children() + { + "status": self._panel_status, + "list": self._panel_list, + "download": self._panel_download, + "install": self._panel_install, + "backup": self._panel_backup, + "restore": self._panel_restore, + "upload": self._panel_upload, + "extract": self._panel_extract, + "cat": self._panel_cat, + "ls": self._panel_ls, + }.get(name, self._panel_status)(panel) + + # ── panel builders ──────────────────────────────────────────────────────── + + def _panel_status(self, p): + p.mount(Static("Device Status", classes="sec-title")) + p.mount(Static("Fetches firmware version and info from the connected device.", classes="card")) + p.mount(Button("Get Status", id="btn-status", variant="primary", classes="act-btn")) + + def _panel_list(self, p): + p.mount(Static("Available Firmware Versions", classes="sec-title")) + row = Horizontal(classes="field-row") + row.mount(Label("Hardware:", classes="field-lbl")) + row.mount(Select([(l, v) for l, v in HARDWARE_OPTIONS], + id="sel-hw-list", allow_blank=True, classes="field-inp")) + p.mount(row) + p.mount(Button("List Versions", id="btn-list", variant="primary", classes="act-btn")) + + def _panel_download(self, p): + p.mount(Static("Download Firmware", classes="sec-title")) + self._field(p, "Version:", "inp-dl-ver", "e.g. 3.15.4.2") + row = Horizontal(classes="field-row") + row.mount(Label("Hardware:", classes="field-lbl")) + row.mount(Select([(l, v) for l, v in HARDWARE_OPTIONS], + id="sel-hw-dl", allow_blank=True, classes="field-inp")) + p.mount(row) + self._field(p, "Output dir:", "inp-dl-out", "./firmware") + p.mount(Button("Download", id="btn-download", variant="primary", classes="act-btn")) + + def _panel_install(self, p): + p.mount(Static("Install Firmware", classes="sec-title")) + self._field(p, "Version / .swu:", "inp-inst-ver", "3.15.4.2 or ./file.swu") + p.mount(Button("Install on Device", id="btn-install", variant="warning", classes="act-btn")) + + def _panel_backup(self, p): + p.mount(Static("Backup Remote Files", classes="sec-title")) + self._field(p, "Output dir:", "inp-bk-out", "./backup") + p.mount(Button("Start Backup", id="btn-backup", variant="primary", classes="act-btn")) + + def _panel_restore(self, p): + p.mount(Static("Restore Previous Version", classes="sec-title")) + p.mount(Static("Reverts device to previously installed firmware.", classes="card")) + p.mount(Button("Restore", id="btn-restore", variant="error", classes="act-btn")) + + def _panel_upload(self, p): + p.mount(Static("Upload Files (PDF only)", classes="sec-title")) + self._field(p, "Local path:", "inp-up-path", "/path/to/file.pdf") + p.mount(Button("Upload", id="btn-upload", variant="primary", classes="act-btn")) + + def _panel_extract(self, p): + p.mount(Static("Extract Firmware", classes="sec-title")) + self._field(p, "Version / .swu:", "inp-ex-ver", "3.15.4.2 or ./file.swu") + self._field(p, "Output dir:", "inp-ex-out", "./extracted") + p.mount(Button("Extract", id="btn-extract", variant="primary", classes="act-btn")) + + def _panel_cat(self, p): + p.mount(Static("Cat File in Firmware Image", classes="sec-title")) + self._field(p, "Firmware file:", "inp-cat-ver", "3.15.4.2_reMarkable2-xxx.signed") + self._field(p, "Inner path:", "inp-cat-path", "/etc/version") + p.mount(Button("Cat", id="btn-cat", variant="primary", classes="act-btn")) + + def _panel_ls(self, p): + p.mount(Static("List Files in Firmware Image", classes="sec-title")) + self._field(p, "Firmware file:", "inp-ls-ver", "3.15.4.2_reMarkable2-xxx.signed") + p.mount(Button("List Files", id="btn-ls", variant="primary", classes="act-btn")) + + def _field(self, parent, label: str, inp_id: str, placeholder: str = ""): + row = Horizontal(classes="field-row") + row.mount(Label(label, classes="field-lbl")) + row.mount(Input(placeholder=placeholder, id=inp_id, classes="field-inp")) + parent.mount(row) + + # ── helpers ─────────────────────────────────────────────────────────────── + + def _conn(self) -> tuple[str, str]: + try: + addr = self.query_one("#inp-addr", Input).value.strip() + pw = self.query_one("#inp-pass", Input).value.strip() + except Exception: + addr, pw = "", "" + return addr, pw + + def _val(self, wid: str) -> str: + try: + return self.query_one(f"#{wid}", Input).value.strip() + except Exception: + return "" + + def _sel(self, wid: str) -> str: + try: + v = self.query_one(f"#{wid}", Select).value + return "" if v is Select.BLANK else str(v) + except Exception: + return "" + + def _write(self, text: str) -> None: + """Write a line to the output log. Safe to call from any thread via call_from_thread.""" + self.query_one("#output-log", RichLog).write(text) + + def action_clear_output(self) -> None: + self.query_one("#output-log", RichLog).clear() + + # ── button handlers ─────────────────────────────────────────────────────── + + @on(Button.Pressed, "#btn-status") + def _do_status(self): self._dispatch(["status"]) + + @on(Button.Pressed, "#btn-list") + def _do_list(self): + args = ["list"] + hw = self._sel("sel-hw-list") + if hw: args += ["--hardware", hw] + self._dispatch(args) + + @on(Button.Pressed, "#btn-download") + def _do_download(self): + ver = self._val("inp-dl-ver") + if not ver: self._write("[red]⚠ Version is required.[/red]"); return + args = ["download", ver] + hw = self._sel("sel-hw-dl") + if hw: args += ["--hardware", hw] + out = self._val("inp-dl-out") + if out: args += ["-o", out] + self._dispatch(args) + + @on(Button.Pressed, "#btn-install") + def _do_install(self): + ver = self._val("inp-inst-ver") + if not ver: self._write("[red]⚠ Version or path required.[/red]"); return + self.push_screen( + ConfirmScreen(f"Install '{ver}' on device?"), + lambda ok: self._dispatch(["install", ver]) if ok else None, + ) + + @on(Button.Pressed, "#btn-backup") + def _do_backup(self): + args = ["backup"] + out = self._val("inp-bk-out") + if out: args += ["-o", out] + self._dispatch(args) + + @on(Button.Pressed, "#btn-restore") + def _do_restore(self): + self.push_screen( + ConfirmScreen("Restore previous firmware? This modifies your device."), + lambda ok: self._dispatch(["restore"]) if ok else None, + ) + + @on(Button.Pressed, "#btn-upload") + def _do_upload(self): + path = self._val("inp-up-path") + if not path: self._write("[red]⚠ File path required.[/red]"); return + self._dispatch(["upload", path]) + + @on(Button.Pressed, "#btn-extract") + def _do_extract(self): + ver = self._val("inp-ex-ver") + if not ver: self._write("[red]⚠ Version or path required.[/red]"); return + args = ["extract", ver] + out = self._val("inp-ex-out") + if out: args += ["-o", out] + self._dispatch(args) + + @on(Button.Pressed, "#btn-cat") + def _do_cat(self): + ver = self._val("inp-cat-ver") + path = self._val("inp-cat-path") + if not ver or not path: + self._write("[red]⚠ Firmware file and inner path are both required.[/red]"); return + self._dispatch(["cat", ver, path]) + + @on(Button.Pressed, "#btn-ls") + def _do_ls(self): + ver = self._val("inp-ls-ver") + if not ver: self._write("[red]⚠ Firmware file required.[/red]"); return + self._dispatch(["ls", ver]) + + # ── worker ──────────────────────────────────────────────────────────────── + + def _dispatch(self, args: list[str]) -> None: + addr, pw = self._conn() + self._write(f"[bold cyan]» codexctl {' '.join(args)}[/bold cyan]") + self._thread_run(args, addr, pw) + + @work(thread=True) + def _thread_run(self, args: list[str], addr: str, pw: str) -> None: + stdout, stderr, rc = run_codexctl(args, addr, pw) + combined = (stdout + stderr).strip() + if combined: + self.call_from_thread(self._write, combined) + colour = "green" if rc == 0 else "red" + self.call_from_thread(self._write, f"[{colour}]exit {rc}[/{colour}]\n") + + +if __name__ == "__main__": + CodexctlTUI().run()