tinyGuiUpdate , Updating script v1 finnished

This commit is contained in:
2026-03-17 00:08:41 +02:00
parent 2f07910512
commit 8dfb15ac40
29 changed files with 1881 additions and 98 deletions

View File

@@ -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 Jibos `/` 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 cant 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.

0
gui/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

13
gui/assets/jibo.svg Normal file
View 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
View 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
View 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
View 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 dont 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
View 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
View 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
View 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
View 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())

View File

@@ -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)

21
jibo_gui.bat Normal file
View 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
View 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
View 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
View 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="Dont prompt for confirmation")
parser.add_argument("--dry-run", action="store_true", help="Download/extract + connect, but dont 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
View 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

File diff suppressed because one or more lines are too long

1
jibo_work/_t2.bin Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{
"192.168.1.15": "v3.3.0"
}

1
requirements-gui.txt Normal file
View File

@@ -0,0 +1 @@
PySide6>=6.7.0

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
paramiko>=3.4.0