tinyGuiUpdate , Updating script v1 finnished
This commit is contained in:
84
README.md
84
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 <jibo-ip-address>
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```bat
|
||||
jibo_updater.bat --ip <jibo-ip-address>
|
||||
```
|
||||
|
||||
Stable-only (ignore prereleases):
|
||||
|
||||
```bash
|
||||
python3 jibo_updater.py --ip <jibo-ip-address> --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 <jibo-ip-address> --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`
|
||||
|
||||
Binary file not shown.
BIN
__pycache__/jibo_updater.cpython-313.pyc
Normal file
BIN
__pycache__/jibo_updater.cpython-313.pyc
Normal file
Binary file not shown.
0
gui/__init__.py
Normal file
0
gui/__init__.py
Normal file
BIN
gui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
gui/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
gui/__pycache__/installer_gui.cpython-313.pyc
Normal file
BIN
gui/__pycache__/installer_gui.cpython-313.pyc
Normal file
Binary file not shown.
BIN
gui/__pycache__/main_panel.cpython-313.pyc
Normal file
BIN
gui/__pycache__/main_panel.cpython-313.pyc
Normal file
Binary file not shown.
BIN
gui/__pycache__/process_runner.cpython-313.pyc
Normal file
BIN
gui/__pycache__/process_runner.cpython-313.pyc
Normal file
Binary file not shown.
BIN
gui/__pycache__/terminal_helper.cpython-313.pyc
Normal file
BIN
gui/__pycache__/terminal_helper.cpython-313.pyc
Normal file
Binary file not shown.
BIN
gui/__pycache__/updater_gui.cpython-313.pyc
Normal file
BIN
gui/__pycache__/updater_gui.cpython-313.pyc
Normal file
Binary file not shown.
13
gui/assets/jibo.svg
Normal file
13
gui/assets/jibo.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#f2f2f2"/>
|
||||
<stop offset="1" stop-color="#d9d9d9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="256" height="256" fill="none"/>
|
||||
<circle cx="128" cy="128" r="110" fill="url(#g)" stroke="#9a9a9a" stroke-width="6"/>
|
||||
<circle cx="128" cy="128" r="70" fill="#ffffff" stroke="#b3b3b3" stroke-width="6"/>
|
||||
<rect x="88" y="168" width="80" height="26" rx="13" fill="#c7c7c7"/>
|
||||
<text x="128" y="128" font-family="sans-serif" font-size="28" text-anchor="middle" fill="#444">Jibo</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 696 B |
37
gui/installer_gui.py
Normal file
37
gui/installer_gui.py
Normal file
@@ -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())
|
||||
59
gui/main_panel.py
Normal file
59
gui/main_panel.py
Normal file
@@ -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())
|
||||
185
gui/process_runner.py
Normal file
185
gui/process_runner.py
Normal file
@@ -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()
|
||||
168
gui/qml/MainPanel.qml
Normal file
168
gui/qml/MainPanel.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
gui/qml/ToolRunner.qml
Normal file
142
gui/qml/ToolRunner.qml
Normal file
@@ -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<parts.length; i++) {
|
||||
if (parts[i].length > 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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
gui/terminal_helper.py
Normal file
12
gui/terminal_helper.py
Normal file
@@ -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)
|
||||
37
gui/updater_gui.py
Normal file
37
gui/updater_gui.py
Normal file
@@ -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())
|
||||
490
jibo_automod.py
490
jibo_automod.py
@@ -220,6 +220,10 @@ def check_windows_dependencies() -> Tuple[bool, List[str], List[str]]:
|
||||
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,91 +617,52 @@ 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()
|
||||
data = bytearray(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"',
|
||||
# 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"'
|
||||
for old_json, new_json in json_patterns:
|
||||
offset = bytes(data).find(old_json)
|
||||
if offset == -1:
|
||||
continue
|
||||
|
||||
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
|
||||
print_info(f"Found mode.json JSON at offset {offset} (0x{offset:x})")
|
||||
end_offset = offset + len(old_json)
|
||||
|
||||
# Find offset
|
||||
offset = data.find(pattern)
|
||||
print_info(f"Found pattern at offset {offset} (0x{offset:x})")
|
||||
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
|
||||
|
||||
# This simple replacement won't work due to size difference
|
||||
# We need a smarter approach
|
||||
modified = True
|
||||
break
|
||||
region_len = len(new_json)
|
||||
# Overwrite the JSON plus the padding region; do NOT shift bytes.
|
||||
data[offset:offset + region_len] = new_json
|
||||
|
||||
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"}'),
|
||||
]
|
||||
f.seek(0)
|
||||
f.write(data)
|
||||
print_success("mode.json modified successfully (raw in-place overwrite)")
|
||||
return True
|
||||
|
||||
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...")
|
||||
print_warning("mode.json pattern not found (raw). Will try filesystem mount if available...")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
@@ -738,9 +703,48 @@ 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')}")
|
||||
|
||||
@@ -752,10 +756,18 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
||||
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)
|
||||
|
||||
|
||||
21
jibo_gui.bat
Normal file
21
jibo_gui.bat
Normal file
@@ -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%
|
||||
17
jibo_gui.sh
Executable file
17
jibo_gui.sh
Executable file
@@ -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 "$@"
|
||||
23
jibo_updater.bat
Normal file
23
jibo_updater.bat
Normal file
@@ -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%
|
||||
642
jibo_updater.py
Normal file
642
jibo_updater.py
Normal file
@@ -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)
|
||||
18
jibo_updater.sh
Executable file
18
jibo_updater.sh
Executable file
@@ -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" "$@"
|
||||
1
jibo_work/_t1.bin
Normal file
1
jibo_work/_t1.bin
Normal file
File diff suppressed because one or more lines are too long
1
jibo_work/_t2.bin
Normal file
1
jibo_work/_t2.bin
Normal file
File diff suppressed because one or more lines are too long
3
jibo_work/update_state.json
Normal file
3
jibo_work/update_state.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"192.168.1.15": "v3.3.0"
|
||||
}
|
||||
1
requirements-gui.txt
Normal file
1
requirements-gui.txt
Normal file
@@ -0,0 +1 @@
|
||||
PySide6>=6.7.0
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
paramiko>=3.4.0
|
||||
Reference in New Issue
Block a user