diff --git a/README.md b/README.md index 0ec608b..b4ce3cb 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ sudo pacman -S --needed base-devel libusb git python \ - Python 3.8+ - MSYS2 with MinGW-w64 toolchain - Zadig (for USB driver installation) +- e2fsprogs (provides `debugfs`, used to edit `mode.json` inside the ext filesystem image without mounting) - ~20GB free disk space ## What Does It Do? @@ -75,6 +76,28 @@ sudo pacman -S --needed base-devel libusb git python \ ./jibo_automod.sh ``` +## Optional GUI + +The GUI is separate from the CLI tools. You can still run `jibo_automod.sh` / `jibo_updater.sh` directly. + +Install GUI deps: + +```bash +python3 -m pip install -r requirements-gui.txt +``` + +Launch main panel (Linux): + +```bash +./jibo_gui.sh +``` + +Launch main panel (Windows): + +```bat +jibo_gui.bat +``` + ### Just Dump (no modification) ```bash ./jibo_automod.sh --dump-only -o my_jibo_backup.bin @@ -90,6 +113,20 @@ sudo pacman -S --needed base-devel libusb git python \ ./jibo_automod.sh --write-partition var_modified.bin --start-sector 0x7E9022 ``` +### Fast Mode (GPT + /var only) +This avoids the 15GB full dump by reading just the partition table + the ~500MB `/var` partition, +editing `/var/jibo/mode.json`, and writing back only the changed sectors. + +```bash +./jibo_automod.sh --mode-json-only +``` + +If patch-writing is not desired (or your filesystem changes a lot of blocks), force a full `/var` write: + +```bash +./jibo_automod.sh --mode-json-only --full-var-write +``` + ## Command Line Options | Option | Description | @@ -102,6 +139,8 @@ sudo pacman -S --needed base-devel libusb git python \ | `--rebuild-shofel` | Force rebuild of exploit tool | | `--skip-detection` | Skip USB device detection | | `--no-verify` | Skip write verification | +| `--mode-json-only` | Fast mode: dump GPT + /var only, patch `mode.json`, write back minimal changes | +| `--full-var-write` | With `--mode-json-only`: write entire /var partition instead of patch-writing | ## Entering RCM Mode @@ -134,6 +173,42 @@ Once the tool completes successfully: # Password: jibo ``` +## Updating Jibo (JiboOs releases) + +This repo also includes an updater that can pull the latest release from the JiboOs Gitea repo, +then upload the release `build/` overlay into Jibo’s `/` over SFTP. + +### Install updater dependency + +```bash +python3 -m pip install -r requirements.txt +``` + +### Run updater + +```bash +./jibo_updater.sh --ip +``` + +Windows: + +```bat +jibo_updater.bat --ip +``` + +Stable-only (ignore prereleases): + +```bash +python3 jibo_updater.py --ip --stable +``` + +If the release archive layout changes and the tool can’t find the `build/` folder automatically, +pass it explicitly (path is relative to the extracted archive root): + +```bash +python3 jibo_updater.py --ip --build-path V3.1/build +``` + ## Troubleshooting ### "Jibo not found in RCM mode" @@ -145,6 +220,15 @@ Once the tool completes successfully: - Run with sudo: `sudo ./jibo_automod.sh` - Or add udev rules for the Nvidia APX device +### Windows: mode.json edit fails / raw patch warning +If you see messages about raw edits needing padding, install `debugfs` via MSYS2 so the tool can edit the ext filesystem image safely: + +1. Install MSYS2: https://www.msys2.org/ +2. In an MSYS2 terminal: + - `pacman -S --needed e2fsprogs` + +Then re-run with `--mode-json-only`. + ### Build fails - Make sure ARM toolchain is installed - On Arch: `pacman -S arm-none-eabi-gcc arm-none-eabi-newlib` diff --git a/__pycache__/jibo_automod.cpython-313.pyc b/__pycache__/jibo_automod.cpython-313.pyc index 09ba9d5..f306e0b 100644 Binary files a/__pycache__/jibo_automod.cpython-313.pyc and b/__pycache__/jibo_automod.cpython-313.pyc differ diff --git a/__pycache__/jibo_updater.cpython-313.pyc b/__pycache__/jibo_updater.cpython-313.pyc new file mode 100644 index 0000000..91a3434 Binary files /dev/null and b/__pycache__/jibo_updater.cpython-313.pyc differ diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/__pycache__/__init__.cpython-313.pyc b/gui/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..55f94b3 Binary files /dev/null and b/gui/__pycache__/__init__.cpython-313.pyc differ diff --git a/gui/__pycache__/installer_gui.cpython-313.pyc b/gui/__pycache__/installer_gui.cpython-313.pyc new file mode 100644 index 0000000..a808dae Binary files /dev/null and b/gui/__pycache__/installer_gui.cpython-313.pyc differ diff --git a/gui/__pycache__/main_panel.cpython-313.pyc b/gui/__pycache__/main_panel.cpython-313.pyc new file mode 100644 index 0000000..8c53627 Binary files /dev/null and b/gui/__pycache__/main_panel.cpython-313.pyc differ diff --git a/gui/__pycache__/process_runner.cpython-313.pyc b/gui/__pycache__/process_runner.cpython-313.pyc new file mode 100644 index 0000000..cd7c1d7 Binary files /dev/null and b/gui/__pycache__/process_runner.cpython-313.pyc differ diff --git a/gui/__pycache__/terminal_helper.cpython-313.pyc b/gui/__pycache__/terminal_helper.cpython-313.pyc new file mode 100644 index 0000000..ccac640 Binary files /dev/null and b/gui/__pycache__/terminal_helper.cpython-313.pyc differ diff --git a/gui/__pycache__/updater_gui.cpython-313.pyc b/gui/__pycache__/updater_gui.cpython-313.pyc new file mode 100644 index 0000000..0639224 Binary files /dev/null and b/gui/__pycache__/updater_gui.cpython-313.pyc differ diff --git a/gui/assets/jibo.svg b/gui/assets/jibo.svg new file mode 100644 index 0000000..38bedf3 --- /dev/null +++ b/gui/assets/jibo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + Jibo + diff --git a/gui/installer_gui.py b/gui/installer_gui.py new file mode 100644 index 0000000..87f0d9d --- /dev/null +++ b/gui/installer_gui.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from .process_runner import ProcessRunner, resolve_python +from .terminal_helper import TerminalHelper + + +def main() -> int: + app = QGuiApplication(sys.argv) + + engine = QQmlApplicationEngine() + + runner = ProcessRunner() + terminal = TerminalHelper() + engine.rootContext().setContextProperty("runner", runner) + engine.rootContext().setContextProperty("terminal", terminal) + engine.rootContext().setContextProperty("pyExec", resolve_python()) + engine.rootContext().setContextProperty("toolScript", "jibo_automod.py") + engine.rootContext().setContextProperty("toolTitle", "Installer") + + qml_path = Path(__file__).resolve().parent / "qml" / "ToolRunner.qml" + engine.load(QUrl.fromLocalFile(str(qml_path))) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui/main_panel.py b/gui/main_panel.py new file mode 100644 index 0000000..af4aaec --- /dev/null +++ b/gui/main_panel.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl, QObject, Slot +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from .process_runner import ConnectionMonitor, resolve_python, resolve_python_invocation + + +class Launcher(QObject): + def __init__(self, python_program: str, python_prefix: list[str]) -> None: + super().__init__() + self._python_program = python_program + self._python_prefix = list(python_prefix) + + @Slot() + def launchInstaller(self) -> None: + # Start installer GUI in a separate process. + import subprocess + subprocess.Popen( + [self._python_program, *self._python_prefix, "-m", "gui.installer_gui"], + cwd=str(Path(__file__).resolve().parents[1]), + ) + + @Slot() + def launchUpdater(self) -> None: + import subprocess + subprocess.Popen( + [self._python_program, *self._python_prefix, "-m", "gui.updater_gui"], + cwd=str(Path(__file__).resolve().parents[1]), + ) + + +def main() -> int: + app = QGuiApplication(sys.argv) + + engine = QQmlApplicationEngine() + + conn = ConnectionMonitor() + py_program, py_prefix = resolve_python_invocation() + py_exec = resolve_python() + engine.rootContext().setContextProperty("conn", conn) + engine.rootContext().setContextProperty("pyExec", py_exec) + engine.rootContext().setContextProperty("launcher", Launcher(py_program, py_prefix)) + + qml_path = Path(__file__).resolve().parent / "qml" / "MainPanel.qml" + engine.load(QUrl.fromLocalFile(str(qml_path))) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui/process_runner.py b/gui/process_runner.py new file mode 100644 index 0000000..4db3ac8 --- /dev/null +++ b/gui/process_runner.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import socket +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QProcess, QTimer, Signal, Slot, Property + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def resolve_python_invocation() -> tuple[str, list[str]]: + """Return (program, prefix_args) to invoke Python reliably. + + On Windows, prefer the repo-local venv; otherwise prefer the `py -3` launcher + when present so we don’t depend on `python.exe` being on PATH. + """ + + venv_py = REPO_ROOT / ".venv" / ("Scripts" if os.name == "nt" else "bin") / ( + "python.exe" if os.name == "nt" else "python" + ) + if venv_py.exists(): + return (str(venv_py), []) + + if os.name == "nt" and shutil.which("py"): + return ("py", ["-3"]) + + if shutil.which("python3"): + return ("python3", []) + + return (sys.executable or "python", []) + + +def resolve_python() -> str: + program, prefix = resolve_python_invocation() + if prefix: + # Best-effort string representation (mostly for display) + return " ".join([program] + prefix) + return program + + +def can_connect(host: str, port: int, timeout_s: float = 0.8) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout_s): + return True + except OSError: + return False + + +def _pick_terminal_command() -> Optional[list[str]]: + if os.name == "nt": + return None + + candidates: list[list[str]] = [] + # Debian/Ubuntu alternative system + candidates.append(["x-terminal-emulator", "-e"]) + candidates.append(["gnome-terminal", "--"]) + candidates.append(["konsole", "-e"]) + candidates.append(["xfce4-terminal", "-e"]) + candidates.append(["xterm", "-e"]) + + for cmd in candidates: + if shutil.which(cmd[0]): + return cmd + return None + + +def spawn_in_terminal(argv: list[str]) -> bool: + """Best-effort external terminal launcher. + + Returns True if a terminal was spawned, False otherwise. + """ + + if os.name == "nt": + # Use cmd.exe window, keep it open (/k) + # Build a single string command for cmd. + cmdline = " ".join(shlex.quote(a) for a in argv) + subprocess.Popen(["cmd", "/c", "start", "cmd", "/k", cmdline], shell=False) + return True + + term = _pick_terminal_command() + if not term: + return False + + subprocess.Popen(term + argv, cwd=str(REPO_ROOT)) + return True + + +class ProcessRunner(QObject): + runningChanged = Signal() + exitCodeChanged = Signal() + outputAppended = Signal(str) + + def __init__(self) -> None: + super().__init__() + self._proc = QProcess(self) + self._proc.setProcessChannelMode(QProcess.MergedChannels) + self._proc.readyReadStandardOutput.connect(self._on_ready) + self._proc.finished.connect(self._on_finished) + self._exit_code: int = -1 + + @Property(bool, notify=runningChanged) + def running(self) -> bool: + return self._proc.state() != QProcess.NotRunning + + @Property(int, notify=exitCodeChanged) + def exitCode(self) -> int: + return self._exit_code + + @Slot(str, list) + def start(self, program: str, arguments: list) -> None: + if self.running: + return + self._exit_code = -1 + self.exitCodeChanged.emit() + self._proc.setProgram(program) + self._proc.setArguments([str(a) for a in arguments]) + self._proc.setWorkingDirectory(str(REPO_ROOT)) + self._proc.start() + self.runningChanged.emit() + + @Slot() + def stop(self) -> None: + if not self.running: + return + self._proc.terminate() + if not self._proc.waitForFinished(1500): + self._proc.kill() + self.runningChanged.emit() + + def _on_ready(self) -> None: + data = bytes(self._proc.readAllStandardOutput()).decode("utf-8", errors="replace") + if data: + self.outputAppended.emit(data) + + def _on_finished(self, exit_code: int, _status) -> None: + self._exit_code = int(exit_code) + self.exitCodeChanged.emit() + self.runningChanged.emit() + + +class ConnectionMonitor(QObject): + hostChanged = Signal() + connectedChanged = Signal() + + def __init__(self) -> None: + super().__init__() + self._host = "" + self._connected = False + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._poll) + self._timer.start() + + @Property(str, notify=hostChanged) + def host(self) -> str: + return self._host + + @host.setter + def host(self, value: str) -> None: + value = (value or "").strip() + if value == self._host: + return + self._host = value + self.hostChanged.emit() + self._poll() + + @Property(bool, notify=connectedChanged) + def connected(self) -> bool: + return self._connected + + def _poll(self) -> None: + host = self._host + connected = False + if host: + connected = can_connect(host, 22) + if connected != self._connected: + self._connected = connected + self.connectedChanged.emit() diff --git a/gui/qml/MainPanel.qml b/gui/qml/MainPanel.qml new file mode 100644 index 0000000..0f2dd7b --- /dev/null +++ b/gui/qml/MainPanel.qml @@ -0,0 +1,168 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window + +ApplicationWindow { + id: win + width: 880 + height: 520 + visible: true + title: "Jibo Tools" + + property string host: hostField.text.trim() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 14 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Text { + text: "Connection" + font.pixelSize: 18 + font.bold: true + } + + Rectangle { + width: 10 + height: 10 + radius: 5 + color: conn.connected ? "#2ecc71" : (host.length > 0 ? "#e67e22" : "#bdc3c7") + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: conn.connected ? "SSH reachable" : (host.length > 0 ? "Not reachable" : "No IP") + color: "#555" + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } + + TextField { + id: hostField + placeholderText: "Jibo IP (e.g. 192.168.1.50)" + Layout.preferredWidth: 280 + onTextChanged: conn.host = text + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 16 + + Rectangle { + Layout.preferredWidth: 320 + Layout.fillHeight: true + radius: 14 + color: "#f6f6f6" + border.color: "#e4e4e4" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + + Text { + text: "Your Jibo" + font.pixelSize: 18 + font.bold: true + } + + Item { Layout.fillHeight: true } + + Rectangle { + id: jiboCard + Layout.alignment: Qt.AlignHCenter + width: 240 + height: 240 + radius: 18 + color: "#ffffff" + border.color: "#e4e4e4" + + Image { + anchors.centerIn: parent + width: 200 + height: 200 + source: "../assets/jibo.svg" + fillMode: Image.PreserveAspectFit + } + + MouseArea { + anchors.fill: parent + onClicked: { + // Best-effort: open Chrome remote devices page. + Qt.openUrlExternally("chrome://inspect/#devices") + } + } + } + + Item { Layout.fillHeight: true } + + Text { + text: "Click Jibo to open chrome://inspect" + color: "#666" + Layout.alignment: Qt.AlignHCenter + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 14 + color: "#f6f6f6" + border.color: "#e4e4e4" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + Text { + text: "Actions" + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "Installer and updater remain available via CLI.\nUse the buttons below to launch their GUIs." + color: "#555" + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Button { + Layout.fillWidth: true + text: "Install" + enabled: true + onClicked: { + launcher.launchInstaller() + } + } + + Button { + Layout.fillWidth: true + text: "Check for updates" + enabled: true + onClicked: { + launcher.launchUpdater() + } + } + } + } + } + } + } +} diff --git a/gui/qml/ToolRunner.qml b/gui/qml/ToolRunner.qml new file mode 100644 index 0000000..72d3e84 --- /dev/null +++ b/gui/qml/ToolRunner.qml @@ -0,0 +1,142 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: win + width: 900 + height: 560 + visible: true + title: (typeof toolTitle === "string" ? toolTitle : "Tool") + + property string script: (typeof toolScript === "string" ? toolScript : "") + property bool isUpdater: script.indexOf("jibo_updater.py") >= 0 + + function buildArgs() { + var args = [] + args.push(script) + + if (isUpdater) { + var h = hostField.text.trim() + if (h.length > 0) { + args.push("--ip") + args.push(h) + } + } + + var extra = extraArgs.text.trim() + if (extra.length > 0) { + // naive split (keeps GUI minimal) + var parts = extra.split(/\s+/) + for (var i=0; i 0) args.push(parts[i]) + } + } + + return args + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: title + font.pixelSize: 18 + font.bold: true + } + + Item { Layout.fillWidth: true } + + Button { + text: runner.running ? "Stop" : "Start" + onClicked: { + if (runner.running) { + runner.stop() + } else { + runner.start(pyExec, buildArgs()) + } + } + } + + Button { + text: "Open in terminal" + enabled: !runner.running + onClicked: { + terminal.openTerminal(pyExec, buildArgs()) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + TextField { + id: hostField + visible: isUpdater + placeholderText: "Jibo IP (required for updater)" + Layout.preferredWidth: 260 + } + + TextField { + id: extraArgs + placeholderText: "Extra arguments (optional)" + Layout.fillWidth: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 12 + color: "#0f0f0f" + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + clip: true + + TextArea { + id: log + readOnly: true + wrapMode: TextArea.Wrap + color: "#e8e8e8" + font.family: "monospace" + background: null + text: "" + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: runner.running ? "Running..." : (runner.exitCode >= 0 ? ("Exit: " + runner.exitCode) : "Idle") + color: "#666" + } + + Item { Layout.fillWidth: true } + + Button { + text: "Clear log" + onClicked: log.text = "" + } + } + } + + Connections { + target: runner + function onOutputAppended(chunk) { + log.text += chunk + log.cursorPosition = log.length + } + } +} diff --git a/gui/terminal_helper.py b/gui/terminal_helper.py new file mode 100644 index 0000000..f37382c --- /dev/null +++ b/gui/terminal_helper.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from PySide6.QtCore import QObject, Slot + +from .process_runner import spawn_in_terminal + + +class TerminalHelper(QObject): + @Slot(str, list, result=bool) + def openTerminal(self, program: str, arguments: list) -> bool: + argv = [program] + [str(a) for a in arguments] + return spawn_in_terminal(argv) diff --git a/gui/updater_gui.py b/gui/updater_gui.py new file mode 100644 index 0000000..9e52e5d --- /dev/null +++ b/gui/updater_gui.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from .process_runner import ProcessRunner, resolve_python +from .terminal_helper import TerminalHelper + + +def main() -> int: + app = QGuiApplication(sys.argv) + + engine = QQmlApplicationEngine() + + runner = ProcessRunner() + terminal = TerminalHelper() + engine.rootContext().setContextProperty("runner", runner) + engine.rootContext().setContextProperty("terminal", terminal) + engine.rootContext().setContextProperty("pyExec", resolve_python()) + engine.rootContext().setContextProperty("toolScript", "jibo_updater.py") + engine.rootContext().setContextProperty("toolTitle", "Updater") + + qml_path = Path(__file__).resolve().parent / "qml" / "ToolRunner.qml" + engine.load(QUrl.fromLocalFile(str(qml_path))) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/jibo_automod.py b/jibo_automod.py index 99f84cb..f40fbed 100644 --- a/jibo_automod.py +++ b/jibo_automod.py @@ -219,6 +219,10 @@ def check_windows_dependencies() -> Tuple[bool, List[str], List[str]]: # Check for make if not shutil.which("make") and not shutil.which("mingw32-make"): missing.append("GNU Make") + + # Optional: debugfs for editing ext filesystem images without mounting + if not shutil.which("debugfs") and not shutil.which("debugfs.exe"): + warnings.append("debugfs (e2fsprogs) - optional but recommended for reliable mode.json edits on Windows") return len(missing) == 0, missing, warnings @@ -613,93 +617,54 @@ def modify_mode_json_direct(partition_path: Path) -> bool: Modify mode.json directly in the partition image by searching for the pattern. This works on both Linux and Windows without mounting. """ - print_info("Searching for mode.json in partition...") - + print_info("Searching for mode.json in partition (raw, no mount)...") + + def _is_safe_pad_byte(b: int) -> bool: + return b in (0x00, 0x09, 0x0A, 0x0D, 0x20) + try: with open(partition_path, "r+b") as f: - data = f.read() - - # Search for the mode.json content pattern - # The file contains: {"mode": "normal"} or similar - patterns_to_find = [ - b'"mode": "normal"', - b'"mode":"normal"', - b'"mode" : "normal"', + data = bytearray(f.read()) + + # Best-effort raw replacement. + # IMPORTANT: never change image length and never shift bytes; only overwrite in-place. + json_patterns = [ + (b'{"mode":"normal"}', b'{"mode":"int-developer"}'), + (b'{"mode": "normal"}', b'{"mode": "int-developer"}'), + (b'{ "mode": "normal" }', b'{"mode":"int-developer"}'), ] - - replacement = b'"mode": "int-developer"' - - modified = False - for pattern in patterns_to_find: - if pattern in data: - # Calculate padding needed - pad_len = len(pattern) - len(replacement) - if pad_len > 0: - # Original is longer, we need to pad replacement - # Actually, we need to be careful here - let's make them same size - replacement_padded = replacement + b' ' * pad_len - elif pad_len < 0: - # Replacement is longer - this is a problem - # "normal" (6 chars) vs "int-developer" (13 chars) - # Original: "mode": "normal" (16 chars) - # New: "mode": "int-developer" (23 chars) - # We need to find the full JSON object and replace it - continue - else: - replacement_padded = replacement - - # Find offset - offset = data.find(pattern) - print_info(f"Found pattern at offset {offset} (0x{offset:x})") - - # This simple replacement won't work due to size difference - # We need a smarter approach - modified = True - break - - if not modified: - # Try finding the full JSON object - json_patterns = [ - (b'{"mode":"normal"}', b'{"mode":"int-developer"}'), - (b'{"mode": "normal"}', b'{"mode": "int-developer"}'), - (b'{ "mode": "normal" }', b'{"mode":"int-developer"}'), - ] - - for old_json, new_json in json_patterns: - if old_json in data: - offset = data.find(old_json) - print_info(f"Found mode.json at offset {offset} (0x{offset:x})") - - # Check if there's enough space (look at surrounding nulls/padding) - end_offset = offset + len(old_json) - - # The new JSON is longer, so we need to check if there's padding - size_diff = len(new_json) - len(old_json) - - if size_diff > 0: - # Check if the bytes after the old JSON are nulls or whitespace - following_bytes = data[end_offset:end_offset + size_diff] - if all(b == 0 or b == 0x20 or b == 0x0a for b in following_bytes): - # Safe to overwrite - new_data = data[:offset] + new_json + data[end_offset + size_diff:] - else: - # Not safe, need to use filesystem modification - print_warning("Cannot safely modify in-place, using filesystem mount") - return False - else: - # Replacement is shorter or same size, pad with nulls - padding = b'\x00' * (-size_diff) - new_data = data[:offset] + new_json + padding + data[end_offset:] - - # Write modified data - f.seek(0) - f.write(new_data) - print_success("mode.json modified successfully!") - return True - - print_warning("mode.json pattern not found, trying filesystem mount...") + + for old_json, new_json in json_patterns: + offset = bytes(data).find(old_json) + if offset == -1: + continue + + print_info(f"Found mode.json JSON at offset {offset} (0x{offset:x})") + end_offset = offset + len(old_json) + + if len(new_json) <= len(old_json): + region_len = len(old_json) + replacement = new_json + b" " * (region_len - len(new_json)) + data[offset:offset + region_len] = replacement + else: + extra = len(new_json) - len(old_json) + following = data[end_offset:end_offset + extra] + if len(following) != extra or not all(_is_safe_pad_byte(b) for b in following): + print_warning("Raw edit would require growing the file and no safe padding was found") + return False + + region_len = len(new_json) + # Overwrite the JSON plus the padding region; do NOT shift bytes. + data[offset:offset + region_len] = new_json + + f.seek(0) + f.write(data) + print_success("mode.json modified successfully (raw in-place overwrite)") + return True + + print_warning("mode.json pattern not found (raw). Will try filesystem mount if available...") return False - + except Exception as e: print_error(f"Direct modification failed: {e}") return False @@ -737,10 +702,49 @@ def modify_partition_mounted(partition_path: Path) -> bool: if mode_json_path.exists(): print_info(f"Found mode.json at {mode_json_path}") - - # Read current content - with open(mode_json_path, "r") as f: - content = json.load(f) + + # Capture original permissions/ownership so we can restore after copy-write + perm = None + uid = None + gid = None + try: + stat_res = run_command( + ["stat", "-c", "%a %u %g", str(mode_json_path)], + sudo=True, + capture_output=True, + check=True, + ) + parts = stat_res.stdout.strip().split() + if len(parts) == 3: + perm, uid, gid = parts[0], parts[1], parts[2] + except Exception: + pass + + # Save a raw backup copy of mode.json for debugging/recovery + try: + backup_text = run_command( + ["cat", str(mode_json_path)], + sudo=True, + capture_output=True, + check=True, + ).stdout + (WORK_DIR / "mode.json.original").write_text(backup_text) + except Exception: + pass + + # Read current content (prefer sudo cat so permissions don't bite us) + try: + mode_text = run_command( + ["cat", str(mode_json_path)], + sudo=True, + capture_output=True, + check=True, + ).stdout + content = json.loads(mode_text) + except Exception: + # Fallback: direct open (works if script is run with sudo) + with open(mode_json_path, "r") as f: + content = json.load(f) print_info(f"Current mode: {content.get('mode', 'unknown')}") @@ -751,11 +755,19 @@ def modify_partition_mounted(partition_path: Path) -> bool: temp_json = WORK_DIR / "mode_temp.json" with open(temp_json, "w") as f: json.dump(content, f) - - run_command( - ["cp", str(temp_json), str(mode_json_path)], - sudo=True - ) + + run_command(["cp", str(temp_json), str(mode_json_path)], sudo=True) + + # Restore permissions/ownership if we captured them + if perm is not None: + run_command(["chmod", perm, str(mode_json_path)], sudo=True, check=False) + if uid is not None and gid is not None: + run_command(["chown", f"{uid}:{gid}", str(mode_json_path)], sudo=True, check=False) + + try: + (WORK_DIR / "mode.json.modified").write_text(json.dumps(content)) + except Exception: + pass print_success("mode.json modified to 'int-developer'") @@ -779,22 +791,240 @@ def modify_partition_mounted(partition_path: Path) -> bool: pass +def _find_debugfs_executable() -> Optional[str]: + """Find a usable debugfs executable (e2fsprogs).""" + for candidate in ("debugfs", "debugfs.exe"): + path = shutil.which(candidate) + if path: + return path + return None + + +def modify_partition_debugfs(partition_path: Path) -> bool: + """Modify mode.json using debugfs (e2fsprogs) without mounting. + + This can work on Windows if the user has MSYS2 e2fsprogs installed (debugfs.exe on PATH). + """ + debugfs = _find_debugfs_executable() + if not debugfs: + return False + + print_info("Attempting mode.json edit via debugfs (no mount)...") + + # Potential locations inside /var + candidate_paths = [ + "/jibo/mode.json", + "/mode.json", + "/etc/jibo/mode.json", + ] + + # Find which path exists by trying to cat it + existing_path: Optional[str] = None + original_text: Optional[str] = None + for p in candidate_paths: + try: + res = run_command( + [debugfs, "-R", f"cat {p}", str(partition_path)], + capture_output=True, + check=True, + ) + # debugfs prints to stdout for cat + if res.stdout and "File not found" not in res.stdout: + existing_path = p + original_text = res.stdout + break + except Exception: + continue + + if not existing_path or original_text is None: + print_warning("debugfs could not locate mode.json inside the image") + return False + + # Save backup + try: + (WORK_DIR / "mode.json.original").write_text(original_text) + except Exception: + pass + + try: + content = json.loads(original_text) + except Exception: + print_warning("mode.json content is not valid JSON; refusing to edit") + return False + + content["mode"] = "int-developer" + new_text = json.dumps(content) + + temp_json = WORK_DIR / "mode_temp.json" + temp_json.write_text(new_text) + + # Overwrite: remove then write to ensure replacement works even if size differs. + # This may change filesystem allocation, which is fine for full /var write, and + # our patch-write logic can still handle it. + try: + run_command([debugfs, "-w", "-R", f"rm {existing_path}", str(partition_path)], check=False, capture_output=True) + run_command([debugfs, "-w", "-R", f"write {str(temp_json)} {existing_path}", str(partition_path)], capture_output=True) + except Exception as e: + print_warning(f"debugfs write failed: {e}") + return False + + try: + (WORK_DIR / "mode.json.modified").write_text(new_text) + except Exception: + pass + + print_success("mode.json modified to 'int-developer' (debugfs)") + return True + + def modify_var_partition(partition_path: Path) -> bool: """Modify the var partition to enable developer mode""" print_step(4, 6, "Modifying var partition") - - # Try direct modification first (works on all platforms) + + # On Linux, prefer mounting: it's the only truly safe way to update a file in an ext filesystem. + if platform.system() == "Linux": + if modify_partition_mounted(partition_path): + return True + print_warning("Mount-based edit failed; falling back to raw in-place patch") + + # If mounting is unavailable (Windows/macOS) or failed, try debugfs (ext filesystem edit without mount) + if modify_partition_debugfs(partition_path): + return True + + # Raw patch is a best-effort last resort if modify_mode_json_direct(partition_path): return True - # Fall back to mounting (Linux only) - if platform.system() == "Linux": - return modify_partition_mounted(partition_path) - print_error("Could not modify partition") return False +def emmc_read_to_file(output_path: Path, start_sector: int, num_sectors: int) -> bool: + """Read a range of sectors from eMMC into a file.""" + shofel = get_shofel_path() + if not shofel.exists(): + print_error("shofel2_t124 not found. Please build it first.") + return False + + try: + cmd = [ + str(shofel), + "EMMC_READ", + f"0x{start_sector:x}", + f"0x{num_sectors:x}", + str(output_path), + ] + if platform.system() == "Linux": + cmd = ["sudo"] + cmd + subprocess.run(cmd, cwd=SHOFEL_DIR, check=True) + return output_path.exists() + except subprocess.CalledProcessError as e: + print_error(f"EMMC_READ failed: {e}") + return False + + +def emmc_write_file(input_path: Path, start_sector: int) -> bool: + """Write a file to eMMC starting at a given sector.""" + shofel = get_shofel_path() + if not shofel.exists(): + print_error("shofel2_t124 not found. Please build it first.") + return False + + try: + cmd = [ + str(shofel), + "EMMC_WRITE", + f"0x{start_sector:x}", + str(input_path), + ] + if platform.system() == "Linux": + cmd = ["sudo"] + cmd + subprocess.run(cmd, cwd=SHOFEL_DIR, check=True) + return True + except subprocess.CalledProcessError as e: + print_error(f"EMMC_WRITE failed: {e}") + return False + + +def compute_changed_sector_ranges(original_path: Path, modified_path: Path, sector_size: int = 512, + scan_chunk_bytes: int = 4 * 1024 * 1024) -> Tuple[int, List[Tuple[int, int]]]: + """Return (changed_sector_count, ranges) where ranges are (start_sector_offset, num_sectors).""" + if original_path.stat().st_size != modified_path.stat().st_size: + raise ValueError("Files differ in size; cannot compute sector diffs") + total_bytes = original_path.stat().st_size + if total_bytes % sector_size != 0: + raise ValueError("Partition image size is not a multiple of sector size") + + changed_sectors: List[int] = [] + scan_chunk_bytes = max(sector_size, (scan_chunk_bytes // sector_size) * sector_size) + + with open(original_path, "rb") as f1, open(modified_path, "rb") as f2: + base_sector = 0 + while True: + b1 = f1.read(scan_chunk_bytes) + b2 = f2.read(scan_chunk_bytes) + if not b1 and not b2: + break + if b1 == b2: + base_sector += len(b1) // sector_size + continue + + # Chunk differs; identify sector-level diffs within this chunk + sectors_in_chunk = min(len(b1), len(b2)) // sector_size + for i in range(sectors_in_chunk): + s1 = b1[i * sector_size:(i + 1) * sector_size] + s2 = b2[i * sector_size:(i + 1) * sector_size] + if s1 != s2: + changed_sectors.append(base_sector + i) + base_sector += sectors_in_chunk + + if not changed_sectors: + return 0, [] + + changed_sectors.sort() + ranges: List[Tuple[int, int]] = [] + start = prev = changed_sectors[0] + for s in changed_sectors[1:]: + if s == prev + 1: + prev = s + continue + ranges.append((start, prev - start + 1)) + start = prev = s + ranges.append((start, prev - start + 1)) + return len(changed_sectors), ranges + + +def write_partition_patch_to_emmc(original_path: Path, modified_path: Path, base_start_sector: int, + max_ranges: int = 128, max_changed_sectors: int = 131072) -> bool: + """Write only the changed sectors between two partition images.""" + try: + changed_count, ranges = compute_changed_sector_ranges(original_path, modified_path) + except Exception as e: + print_warning(f"Patch write unavailable ({e}); falling back to full partition write") + return write_partition_to_emmc(modified_path, base_start_sector) + + if changed_count == 0: + print_success("No changes detected in /var partition; nothing to write") + return True + + if len(ranges) > max_ranges or changed_count > max_changed_sectors: + print_warning(f"Too many changes for patch write (ranges={len(ranges)}, sectors={changed_count}); using full /var write") + return write_partition_to_emmc(modified_path, base_start_sector) + + print_info(f"Writing patch: {changed_count} sectors across {len(ranges)} ranges") + + sector_size = EMMC_SECTOR_SIZE + with open(modified_path, "rb") as src: + for idx, (start_off, count) in enumerate(ranges, start=1): + patch_path = WORK_DIR / f"var_patch_{idx:03d}.bin" + src.seek(start_off * sector_size) + payload = src.read(count * sector_size) + patch_path.write_bytes(payload) + if not emmc_write_file(patch_path, base_start_sector + start_off): + return False + return True + + # ============================================================================ # eMMC Operations # ============================================================================ @@ -1107,6 +1337,88 @@ def run_write_only(args) -> bool: return write_partition_to_emmc(partition_path, args.start_sector) +def run_mode_json_only(args) -> bool: + """Fast path: dump only GPT + /var, modify /var/jibo/mode.json, and write back minimal changes.""" + print_banner() + print_info("Running in mode-json-only mode (GPT + /var only)") + + WORK_DIR.mkdir(parents=True, exist_ok=True) + + # Build Shofel + if not build_shofel(force_rebuild=args.rebuild_shofel): + return False + + # Wait for Jibo + if not args.skip_detection: + if not wait_for_jibo_rcm(timeout=120): + return False + + # Dump GPT / partition table (small read) + gpt_path = WORK_DIR / "gpt_dump.bin" + gpt_sectors = 4096 # 2MB; safely covers typical GPT entry area + print_info(f"Dumping GPT header/table ({gpt_sectors} sectors)...") + if not emmc_read_to_file(gpt_path, 0, gpt_sectors): + return False + + partitions = parse_gpt_partitions(gpt_path) + if not partitions: + print_error("No partitions found in GPT dump") + return False + + var_partition = find_var_partition(partitions) + if not var_partition: + print_error("Could not identify /var partition from GPT") + return False + + print_success( + f"Identified /var partition: {var_partition.number} " + f"(start=0x{var_partition.start_sector:x}, sectors={var_partition.size_sectors})" + ) + + # Dump /var partition only + original_var_path = WORK_DIR / "var_partition_original.bin" + var_partition_path = WORK_DIR / "var_partition.bin" + backup_var_path = WORK_DIR / "var_partition_backup.bin" + + print_info("Dumping /var partition only (this is much smaller than a full eMMC dump)...") + if not emmc_read_to_file(original_var_path, var_partition.start_sector, var_partition.size_sectors): + return False + + shutil.copy(original_var_path, var_partition_path) + shutil.copy(original_var_path, backup_var_path) + print_info(f"Backup created: {backup_var_path}") + + # Modify mode.json inside /var + if not modify_var_partition(var_partition_path): + return False + + # Re-check connectivity (optional) + if not args.skip_detection: + print_info("Please ensure Jibo is still in RCM mode") + if not wait_for_jibo_rcm(timeout=60): + print_warning("Continuing anyway...") + + # Write back: patch by default, full write if requested + if args.full_var_write: + print_info("Writing full /var partition back to device...") + if not write_partition_to_emmc(var_partition_path, var_partition.start_sector): + return False + else: + print_info("Writing only changed sectors back to device (patch write)...") + if not write_partition_patch_to_emmc(original_var_path, var_partition_path, var_partition.start_sector): + return False + + # Verify (reads back full /var; optional) + if args.verify: + if not verify_write(var_partition_path, var_partition.start_sector, var_partition.size_sectors): + print_warning("Verification failed, but write may still be successful") + + print(f"\n{Colors.GREEN}{Colors.BOLD}Mode.json update complete!{Colors.RESET}") + print_info(f"Saved originals in: {WORK_DIR}") + print_info("If Jibo boots to a checkmark, SSH should work.") + return True + + # ============================================================================ # CLI # ============================================================================ @@ -1130,6 +1442,8 @@ Examples: help="Only dump the eMMC without modifying") mode_group.add_argument("--write-partition", metavar="FILE", help="Write a partition file to Jibo (requires --start-sector)") + mode_group.add_argument("--mode-json-only", action="store_true", + help="Fast mode: dump GPT + /var only, patch /var/jibo/mode.json, write back minimal changes") # Options parser.add_argument("--dump-path", metavar="FILE", @@ -1148,6 +1462,8 @@ Examples: help="Verify write by reading back (default: True)") parser.add_argument("--no-verify", action="store_false", dest="verify", help="Skip write verification") + parser.add_argument("--full-var-write", action="store_true", default=False, + help="With --mode-json-only: write entire /var partition instead of patch-writing changed sectors") args = parser.parse_args() @@ -1162,6 +1478,8 @@ Examples: elif args.write_partition: args.partition = args.write_partition success = run_write_only(args) + elif args.mode_json_only: + success = run_mode_json_only(args) else: success = run_full_mod(args) diff --git a/jibo_gui.bat b/jibo_gui.bat new file mode 100644 index 0000000..4f01b7f --- /dev/null +++ b/jibo_gui.bat @@ -0,0 +1,21 @@ +@echo off +setlocal + +REM Main GUI launcher (Main Panel) + +set "SCRIPT_DIR=%~dp0" +set "VENV_PY=%SCRIPT_DIR%.venv\Scripts\python.exe" + +if exist "%VENV_PY%" ( + "%VENV_PY%" -m gui.main_panel %* + exit /b %ERRORLEVEL% +) + +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + py -3 -m gui.main_panel %* + exit /b %ERRORLEVEL% +) + +python -m gui.main_panel %* +exit /b %ERRORLEVEL% diff --git a/jibo_gui.sh b/jibo_gui.sh new file mode 100755 index 0000000..4bb6e70 --- /dev/null +++ b/jibo_gui.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Main GUI launcher (Main Panel) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PY="$SCRIPT_DIR/.venv/bin/python" +if [[ -x "$PY" ]]; then + exec "$PY" -m gui.main_panel "$@" +fi + +if command -v python3 >/dev/null 2>&1; then + exec python3 -m gui.main_panel "$@" +fi + +exec python -m gui.main_panel "$@" diff --git a/jibo_updater.bat b/jibo_updater.bat new file mode 100644 index 0000000..6268abf --- /dev/null +++ b/jibo_updater.bat @@ -0,0 +1,23 @@ +@echo off +setlocal + +REM Wrapper for jibo_updater.py +REM Prefers the repo-local venv if it exists. + +set "SCRIPT_DIR=%~dp0" +set "VENV_PY=%SCRIPT_DIR%.venv\Scripts\python.exe" + +if exist "%VENV_PY%" ( + "%VENV_PY%" "%SCRIPT_DIR%jibo_updater.py" %* + exit /b %ERRORLEVEL% +) + +REM Prefer the Python launcher if available +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + py -3 "%SCRIPT_DIR%jibo_updater.py" %* + exit /b %ERRORLEVEL% +) + +python "%SCRIPT_DIR%jibo_updater.py" %* +exit /b %ERRORLEVEL% diff --git a/jibo_updater.py b/jibo_updater.py new file mode 100644 index 0000000..1b67520 --- /dev/null +++ b/jibo_updater.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""Jibo OS Updater + +Downloads the latest JiboOs release from the configured Gitea instance, +extracts it, then uploads the contents of the release "build" folder into +Jibo's root filesystem over SFTP. + +High-level flow: +1) Check latest release +2) Download + extract archive +3) SSH into Jibo (root / password) +4) Remount / as read-write +5) SFTP upload build/ contents into / +6) Optionally switch /var/jibo/mode.json back to "normal" + +This tool assumes your Jibo is already modded and reachable via SSH. +""" + +from __future__ import annotations + +import argparse +import json +import os +import posixpath +import re +import shutil +import sys +import tarfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +import paramiko + + +SCRIPT_DIR = Path(__file__).parent.resolve() +WORK_DIR = SCRIPT_DIR / "jibo_work" +UPDATES_DIR = WORK_DIR / "updates" +STATE_FILE_DEFAULT = WORK_DIR / "update_state.json" + +DEFAULT_RELEASES_API = "https://kevinblog.sytes.net/Code/api/v1/repos/Kevin/JiboOs/releases" + + +class Colors: + RED = "\033[91m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + BLUE = "\033[94m" + CYAN = "\033[96m" + RESET = "\033[0m" + BOLD = "\033[1m" + + +def _no_color_if_not_tty() -> None: + if not sys.stdout.isatty(): + for attr in dir(Colors): + if attr.startswith("_"): + continue + setattr(Colors, attr, "") + + +def print_info(msg: str) -> None: + print(f"{Colors.CYAN}ℹ {msg}{Colors.RESET}") + + +def print_success(msg: str) -> None: + print(f"{Colors.GREEN}✓ {msg}{Colors.RESET}") + + +def print_warning(msg: str) -> None: + print(f"{Colors.YELLOW}⚠ {msg}{Colors.RESET}") + + +def print_error(msg: str) -> None: + print(f"{Colors.RED}✗ {msg}{Colors.RESET}") + + +def prompt_yes_no(question: str, default: bool = False) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + while True: + ans = input(f"{question} {suffix} ").strip().lower() + if not ans: + return default + if ans in {"y", "yes"}: + return True + if ans in {"n", "no"}: + return False + print("Please answer y or n.") + + +@dataclass(frozen=True) +class Release: + tag_name: str + name: str + prerelease: bool + tarball_url: str + zipball_url: str + + +def http_get_json(url: str, timeout: int = 20) -> object: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "JiboUpdater/1.0", + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + return json.loads(data.decode("utf-8", errors="replace")) + + +_VERSION_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?") + + +def _version_tuple(tag: str) -> tuple[int, int, int]: + m = _VERSION_RE.match(tag.strip()) + if not m: + return (0, 0, 0) + major = int(m.group(1) or 0) + minor = int(m.group(2) or 0) + patch = int(m.group(3) or 0) + return (major, minor, patch) + + +def get_latest_release(releases_api: str, allow_prerelease: bool) -> Release: + raw = http_get_json(releases_api) + if not isinstance(raw, list) or not raw: + raise RuntimeError(f"Unexpected releases API response from {releases_api}") + + releases: list[Release] = [] + for item in raw: + if not isinstance(item, dict): + continue + prerelease = bool(item.get("prerelease", False)) + if prerelease and not allow_prerelease: + continue + releases.append( + Release( + tag_name=str(item.get("tag_name", "")), + name=str(item.get("name", "")), + prerelease=prerelease, + tarball_url=str(item.get("tarball_url", "")), + zipball_url=str(item.get("zipball_url", "")), + ) + ) + + if not releases: + raise RuntimeError("No releases found (after prerelease filtering)") + + # Gitea usually returns newest first, but sort by semver-ish tag to be safe. + releases.sort(key=lambda r: _version_tuple(r.tag_name), reverse=True) + return releases[0] + + +def normalize_download_url(download_url: str, base_url: str) -> str: + """Force downloads to use the same scheme/host as the API base. + + Some Gitea instances can be configured with a different ROOT_URL than the + externally reachable hostname, which can leak into tarball_url/zipball_url. + """ + + if not download_url: + return download_url + + base = urllib.parse.urlparse(base_url) + dl = urllib.parse.urlparse(download_url) + + # If already matches, keep as-is. + if dl.scheme == base.scheme and dl.netloc == base.netloc: + return download_url + + # If download URL is missing components or has a different host, rewrite it. + return urllib.parse.urlunparse( + (base.scheme, base.netloc, dl.path, dl.params, dl.query, dl.fragment) + ) + + +def _ensure_dirs() -> None: + WORK_DIR.mkdir(parents=True, exist_ok=True) + UPDATES_DIR.mkdir(parents=True, exist_ok=True) + + +def _download(url: str, dest: Path, *, force: bool = False) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists() and not force: + print_info(f"Using cached download: {dest}") + return + + print_info(f"Downloading: {url}") + tmp = dest.with_suffix(dest.suffix + ".part") + + last_err: Optional[BaseException] = None + for attempt in range(1, 4): + try: + if tmp.exists(): + tmp.unlink(missing_ok=True) + + with urllib.request.urlopen(url, timeout=180) as resp: + total = resp.headers.get("Content-Length") + total_int = int(total) if total and total.isdigit() else None + downloaded = 0 + chunk_size = 1024 * 256 + with open(tmp, "wb") as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total_int: + pct = downloaded * 100.0 / total_int + sys.stdout.write( + f"\r {downloaded/1e6:.1f}MB / {total_int/1e6:.1f}MB ({pct:.1f}%)" + ) + sys.stdout.flush() + + if total_int: + sys.stdout.write("\n") + tmp.replace(dest) + print_success(f"Downloaded to {dest}") + return + + except Exception as e: + last_err = e + wait = 2**attempt + print_warning(f"Download attempt {attempt}/3 failed: {e}. Retrying in {wait}s...") + time.sleep(wait) + + if tmp.exists(): + tmp.unlink(missing_ok=True) + raise RuntimeError(f"Download failed after 3 attempts: {last_err}") + + +def _extract(archive: Path, extract_dir: Path, *, force: bool = False) -> Path: + if extract_dir.exists() and force: + shutil.rmtree(extract_dir) + + if extract_dir.exists(): + print_info(f"Using cached extraction: {extract_dir}") + return extract_dir + + extract_dir.mkdir(parents=True, exist_ok=True) + print_info(f"Extracting {archive.name} ...") + + def _is_within(base: Path, target: Path) -> bool: + try: + target.resolve().relative_to(base.resolve()) + return True + except Exception: + return False + + if archive.suffixes[-2:] == [".tar", ".gz"] or archive.suffix == ".tgz": + with tarfile.open(archive, "r:gz") as tf: + for member in tf.getmembers(): + member_path = extract_dir / member.name + if not _is_within(extract_dir, member_path): + raise RuntimeError(f"Unsafe path in tar archive: {member.name}") + # Python 3.14 changes tar default filtering behavior; be explicit. + try: + tf.extractall(extract_dir, filter="data") + except TypeError: + tf.extractall(extract_dir) + elif archive.suffix == ".zip": + with zipfile.ZipFile(archive) as zf: + for member in zf.infolist(): + member_path = extract_dir / member.filename + if not _is_within(extract_dir, member_path): + raise RuntimeError(f"Unsafe path in zip archive: {member.filename}") + zf.extractall(extract_dir) + else: + raise RuntimeError(f"Unsupported archive type: {archive}") + + print_success(f"Extracted to {extract_dir}") + return extract_dir + + +def _iter_build_candidates(root: Path) -> Iterable[Path]: + for path in root.rglob("build"): + if path.is_dir(): + yield path + + +def _score_build_dir(path: Path) -> int: + score = 0 + for name, weight in (("etc", 5), ("opt", 5), ("var", 2), ("usr", 2), ("lib", 1), ("bin", 1)): + if (path / name).exists(): + score += weight + # Prefer build dirs that are under a version folder like V3.1/build + parts = {p.lower() for p in path.parts} + if any(re.fullmatch(r"v\d+(?:\.\d+)*", p, flags=re.IGNORECASE) for p in parts): + score += 2 + return score + + +def find_build_dir(extract_root: Path, explicit: Optional[str]) -> Path: + if explicit: + p = (extract_root / explicit).resolve() + if not p.exists() or not p.is_dir(): + raise RuntimeError(f"--build-path not found: {p}") + return p + + candidates = list(_iter_build_candidates(extract_root)) + if not candidates: + raise RuntimeError( + "Could not find a 'build' folder in the extracted archive. " + "Use --build-path to point to it (relative to the extracted root)." + ) + + candidates.sort(key=_score_build_dir, reverse=True) + best = candidates[0] + + if _score_build_dir(best) == 0 and len(candidates) > 1: + print_warning("Found build folders, but none look like a rootfs overlay (no etc/opt).") + + print_info(f"Using build folder: {best}") + return best + + +def load_state(path: Path) -> dict: + if not path.exists(): + return {} + try: + return json.loads(path.read_text("utf-8")) + except Exception: + return {} + + +def save_state(path: Path, state: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def ssh_connect(host: str, user: str, password: str, timeout: int) -> paramiko.SSHClient: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + hostname=host, + username=user, + password=password, + look_for_keys=False, + allow_agent=False, + timeout=timeout, + banner_timeout=timeout, + auth_timeout=timeout, + ) + return client + + +def ssh_exec(client: paramiko.SSHClient, command: str, timeout: int = 60) -> tuple[int, str, str]: + stdin, stdout, stderr = client.exec_command(command, timeout=timeout) + _ = stdin + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + return code, out, err + + +def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None: + # Create each path component if missing. + parts = [p for p in remote_dir.split("/") if p] + cur = "/" + for part in parts: + cur = posixpath.join(cur, part) + try: + sftp.stat(cur) + except IOError: + sftp.mkdir(cur) + + +def upload_tree( + sftp: paramiko.SFTPClient, + local_root: Path, + remote_root: str = "/", + *, + dry_run: bool = False, +) -> None: + local_root = local_root.resolve() + + paths = sorted(local_root.rglob("*")) + total = len(paths) + sent = 0 + + for p in paths: + rel = p.relative_to(local_root).as_posix() + remote_path = posixpath.join(remote_root, rel) + + if p.is_dir(): + if dry_run: + continue + ensure_remote_dir(sftp, remote_path) + continue + + if p.is_symlink(): + target = os.readlink(p) + if dry_run: + sent += 1 + continue + # Ensure parent exists + ensure_remote_dir(sftp, posixpath.dirname(remote_path)) + try: + # Remove if exists + try: + sftp.remove(remote_path) + except IOError: + pass + sftp.symlink(target, remote_path) + except Exception: + # Fallback: dereference and upload file content + real_path = p.resolve() + sftp.put(str(real_path), remote_path) + sent += 1 + if sent % 200 == 0: + print_info(f"Uploaded {sent}/{total} entries...") + continue + + if p.is_file(): + if dry_run: + sent += 1 + continue + + ensure_remote_dir(sftp, posixpath.dirname(remote_path)) + sftp.put(str(p), remote_path) + try: + mode = p.stat().st_mode & 0o777 + sftp.chmod(remote_path, mode) + except Exception: + pass + + sent += 1 + if sent % 200 == 0: + print_info(f"Uploaded {sent}/{total} entries...") + + print_success(f"Upload complete ({sent} files/links)") + + +def set_mode_json_to_normal(sftp: paramiko.SFTPClient) -> None: + remote = "/var/jibo/mode.json" + try: + with sftp.open(remote, "r") as f: + content = f.read().decode("utf-8", errors="replace") + except IOError as e: + raise RuntimeError(f"Failed to read {remote}: {e}") + + new_content: str + try: + data = json.loads(content) + if not isinstance(data, dict): + raise ValueError("mode.json is not a JSON object") + data["mode"] = "normal" + new_content = json.dumps(data, separators=(",", ": ")) + "\n" + except Exception: + # Fallback for non-standard formatting + new_content = re.sub(r'("mode"\s*:\s*")([^"]+)(")', r'\1normal\3', content) + if new_content == content: + # As a last resort, overwrite with a minimal JSON. + new_content = '{"mode": "normal"}\n' + + with sftp.open(remote, "w") as f: + f.write(new_content.encode("utf-8")) + + +def main() -> int: + _no_color_if_not_tty() + + parser = argparse.ArgumentParser(description="Update a modded Jibo with the latest JiboOs release") + parser.add_argument("--ip", "--host", dest="host", required=True, help="Jibo IP/hostname") + parser.add_argument("--user", default="root", help="SSH username (default: root)") + parser.add_argument("--password", default="jibo", help="SSH password (default: jibo)") + parser.add_argument("--releases-api", default=DEFAULT_RELEASES_API, help="Gitea releases API URL") + + parser.add_argument("--stable", action="store_true", help="Ignore prereleases") + parser.add_argument("--tag", help="Install a specific tag (e.g. v3.3.0) instead of latest") + + parser.add_argument("--build-path", help="Path to build folder inside extracted tree (relative)") + + parser.add_argument("--state-file", type=Path, default=STATE_FILE_DEFAULT, help="Where to store last applied version") + parser.add_argument("--force", action="store_true", help="Re-download and re-install even if version matches") + parser.add_argument("--yes", action="store_true", help="Don’t prompt for confirmation") + parser.add_argument("--dry-run", action="store_true", help="Download/extract + connect, but don’t write files") + + parser.add_argument( + "--return-normal", + action="store_true", + help="After update, set /var/jibo/mode.json mode back to normal (no prompt)", + ) + parser.add_argument( + "--no-return-normal", + action="store_true", + help="After update, do not ask to return to normal mode", + ) + + parser.add_argument("--ssh-timeout", type=int, default=15, help="SSH connect timeout seconds") + + args = parser.parse_args() + + _ensure_dirs() + + allow_prerelease = not args.stable + + print_info("Checking latest release...") + if args.tag: + # Fetch all releases and pick the one matching tag + raw = http_get_json(args.releases_api) + if not isinstance(raw, list): + raise RuntimeError("Unexpected releases API response") + chosen: Optional[Release] = None + for item in raw: + if not isinstance(item, dict): + continue + if str(item.get("tag_name", "")) == args.tag: + chosen = Release( + tag_name=str(item.get("tag_name", "")), + name=str(item.get("name", "")), + prerelease=bool(item.get("prerelease", False)), + tarball_url=str(item.get("tarball_url", "")), + zipball_url=str(item.get("zipball_url", "")), + ) + break + if not chosen: + raise RuntimeError(f"Tag not found in releases: {args.tag}") + release = chosen + else: + release = get_latest_release(args.releases_api, allow_prerelease=allow_prerelease) + + if not release.tag_name or not release.tarball_url: + raise RuntimeError("Release JSON missing tag_name or tarball_url") + + state = load_state(args.state_file) + last = str(state.get(args.host, "")) if isinstance(state, dict) else "" + + print_info(f"Latest: {release.tag_name} ({'prerelease' if release.prerelease else 'stable'})") + if last: + print_info(f"Last applied (from state): {last}") + + if (not args.force) and last and last == release.tag_name: + print_success("Already at latest version (per local state). Use --force to reinstall.") + return 0 + + if not args.yes: + if not prompt_yes_no( + f"This will upload the release build overlay into / on {args.host} and overwrite files. Continue?", + default=False, + ): + print_info("Aborted.") + return 2 + + # Download + extract + archive_name = f"{release.tag_name}.tar.gz" + archive_path = UPDATES_DIR / "downloads" / archive_name + extract_dir = UPDATES_DIR / "extracted" / release.tag_name + + tarball_url = normalize_download_url(release.tarball_url, args.releases_api) + + try: + _download(tarball_url, archive_path, force=args.force) + except urllib.error.URLError as e: + raise RuntimeError(f"Download failed: {e}") + + _extract(archive_path, extract_dir, force=args.force) + + # Gitea archives usually create a single top-level folder. Prefer that as the search root. + children = [p for p in extract_dir.iterdir() if p.is_dir()] + search_root = children[0] if len(children) == 1 else extract_dir + + build_dir = find_build_dir(search_root, args.build_path) + + # Connect and update + print_info(f"Connecting to {args.user}@{args.host} ...") + client = ssh_connect(args.host, args.user, args.password, timeout=args.ssh_timeout) + try: + code, out, err = ssh_exec(client, "sh -c 'touch /.jibo_rw_test 2>/dev/null && rm /.jibo_rw_test 2>/dev/null && echo WRITABLE || echo READONLY'") + if "WRITABLE" in out: + print_info("Root FS already writable") + else: + print_info("Remounting / as read-write...") + code, out, err = ssh_exec(client, "sh -c 'mount -o remount,rw /'", timeout=60) + if code != 0: + print_warning(f"Remount command returned {code}. stderr: {err.strip()}") + code, out, err = ssh_exec(client, "sh -c 'touch /.jibo_rw_test 2>/dev/null && rm /.jibo_rw_test 2>/dev/null && echo WRITABLE || echo READONLY'") + if "WRITABLE" not in out: + raise RuntimeError("Failed to remount / as writable (still READONLY)") + print_success("/ remounted writable") + + if args.dry_run: + print_success("Dry-run: skipping upload") + else: + print_info("Starting SFTP upload (this can take a while)...") + sftp = client.open_sftp() + try: + upload_tree(sftp, build_dir, remote_root="/", dry_run=False) + finally: + sftp.close() + + do_return = False + if args.return_normal: + do_return = True + elif args.no_return_normal: + do_return = False + elif args.yes: + do_return = False + else: + do_return = prompt_yes_no("Return Jibo to normal mode (mode.json: int-developer -> normal)?", default=False) + + if do_return: + if args.dry_run: + print_info("Dry-run: skipping mode.json change") + else: + sftp = client.open_sftp() + try: + set_mode_json_to_normal(sftp) + print_success("Updated /var/jibo/mode.json to normal") + finally: + sftp.close() + + if not args.dry_run: + # Update local state + if isinstance(state, dict): + state[args.host] = release.tag_name + save_state(args.state_file, state) + + print_success(f"Update finished ({release.tag_name})") + return 0 + + finally: + client.close() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("\nInterrupted.") + raise SystemExit(130) + except Exception as e: + print_error(str(e)) + raise SystemExit(1) diff --git a/jibo_updater.sh b/jibo_updater.sh new file mode 100755 index 0000000..11f49a0 --- /dev/null +++ b/jibo_updater.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wrapper for jibo_updater.py +# Prefers the repo-local venv if it exists. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PY="$SCRIPT_DIR/.venv/bin/python" +if [[ -x "$PY" ]]; then + exec "$PY" "$SCRIPT_DIR/jibo_updater.py" "$@" +fi + +if command -v python3 >/dev/null 2>&1; then + exec python3 "$SCRIPT_DIR/jibo_updater.py" "$@" +fi + +exec python "$SCRIPT_DIR/jibo_updater.py" "$@" diff --git a/jibo_work/_t1.bin b/jibo_work/_t1.bin new file mode 100644 index 0000000..38dd1b3 --- /dev/null +++ b/jibo_work/_t1.bin @@ -0,0 +1 @@ +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ No newline at end of file diff --git a/jibo_work/_t2.bin b/jibo_work/_t2.bin new file mode 100644 index 0000000..e2e5607 --- /dev/null +++ b/jibo_work/_t2.bin @@ -0,0 +1 @@ +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ No newline at end of file diff --git a/jibo_work/update_state.json b/jibo_work/update_state.json new file mode 100644 index 0000000..71d8223 --- /dev/null +++ b/jibo_work/update_state.json @@ -0,0 +1,3 @@ +{ + "192.168.1.15": "v3.3.0" +} diff --git a/requirements-gui.txt b/requirements-gui.txt new file mode 100644 index 0000000..0bb08f4 --- /dev/null +++ b/requirements-gui.txt @@ -0,0 +1 @@ +PySide6>=6.7.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8fad050 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +paramiko>=3.4.0