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+
|
- Python 3.8+
|
||||||
- MSYS2 with MinGW-w64 toolchain
|
- MSYS2 with MinGW-w64 toolchain
|
||||||
- Zadig (for USB driver installation)
|
- Zadig (for USB driver installation)
|
||||||
|
- e2fsprogs (provides `debugfs`, used to edit `mode.json` inside the ext filesystem image without mounting)
|
||||||
- ~20GB free disk space
|
- ~20GB free disk space
|
||||||
|
|
||||||
## What Does It Do?
|
## What Does It Do?
|
||||||
@@ -75,6 +76,28 @@ sudo pacman -S --needed base-devel libusb git python \
|
|||||||
./jibo_automod.sh
|
./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)
|
### Just Dump (no modification)
|
||||||
```bash
|
```bash
|
||||||
./jibo_automod.sh --dump-only -o my_jibo_backup.bin
|
./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
|
./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
|
## Command Line Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
@@ -102,6 +139,8 @@ sudo pacman -S --needed base-devel libusb git python \
|
|||||||
| `--rebuild-shofel` | Force rebuild of exploit tool |
|
| `--rebuild-shofel` | Force rebuild of exploit tool |
|
||||||
| `--skip-detection` | Skip USB device detection |
|
| `--skip-detection` | Skip USB device detection |
|
||||||
| `--no-verify` | Skip write verification |
|
| `--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
|
## Entering RCM Mode
|
||||||
|
|
||||||
@@ -134,6 +173,42 @@ Once the tool completes successfully:
|
|||||||
# Password: jibo
|
# 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
|
## Troubleshooting
|
||||||
|
|
||||||
### "Jibo not found in RCM mode"
|
### "Jibo not found in RCM mode"
|
||||||
@@ -145,6 +220,15 @@ Once the tool completes successfully:
|
|||||||
- Run with sudo: `sudo ./jibo_automod.sh`
|
- Run with sudo: `sudo ./jibo_automod.sh`
|
||||||
- Or add udev rules for the Nvidia APX device
|
- 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
|
### Build fails
|
||||||
- Make sure ARM toolchain is installed
|
- Make sure ARM toolchain is installed
|
||||||
- On Arch: `pacman -S arm-none-eabi-gcc arm-none-eabi-newlib`
|
- 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())
|
||||||
514
jibo_automod.py
514
jibo_automod.py
@@ -219,6 +219,10 @@ def check_windows_dependencies() -> Tuple[bool, List[str], List[str]]:
|
|||||||
# Check for make
|
# Check for make
|
||||||
if not shutil.which("make") and not shutil.which("mingw32-make"):
|
if not shutil.which("make") and not shutil.which("mingw32-make"):
|
||||||
missing.append("GNU 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
|
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.
|
Modify mode.json directly in the partition image by searching for the pattern.
|
||||||
This works on both Linux and Windows without mounting.
|
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:
|
try:
|
||||||
with open(partition_path, "r+b") as f:
|
with open(partition_path, "r+b") as f:
|
||||||
data = f.read()
|
data = bytearray(f.read())
|
||||||
|
|
||||||
# Search for the mode.json content pattern
|
# Best-effort raw replacement.
|
||||||
# The file contains: {"mode": "normal"} or similar
|
# IMPORTANT: never change image length and never shift bytes; only overwrite in-place.
|
||||||
patterns_to_find = [
|
json_patterns = [
|
||||||
b'"mode": "normal"',
|
(b'{"mode":"normal"}', b'{"mode":"int-developer"}'),
|
||||||
b'"mode":"normal"',
|
(b'{"mode": "normal"}', b'{"mode": "int-developer"}'),
|
||||||
b'"mode" : "normal"',
|
(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)
|
||||||
modified = False
|
if offset == -1:
|
||||||
for pattern in patterns_to_find:
|
continue
|
||||||
if pattern in data:
|
|
||||||
# Calculate padding needed
|
print_info(f"Found mode.json JSON at offset {offset} (0x{offset:x})")
|
||||||
pad_len = len(pattern) - len(replacement)
|
end_offset = offset + len(old_json)
|
||||||
if pad_len > 0:
|
|
||||||
# Original is longer, we need to pad replacement
|
if len(new_json) <= len(old_json):
|
||||||
# Actually, we need to be careful here - let's make them same size
|
region_len = len(old_json)
|
||||||
replacement_padded = replacement + b' ' * pad_len
|
replacement = new_json + b" " * (region_len - len(new_json))
|
||||||
elif pad_len < 0:
|
data[offset:offset + region_len] = replacement
|
||||||
# Replacement is longer - this is a problem
|
else:
|
||||||
# "normal" (6 chars) vs "int-developer" (13 chars)
|
extra = len(new_json) - len(old_json)
|
||||||
# Original: "mode": "normal" (16 chars)
|
following = data[end_offset:end_offset + extra]
|
||||||
# New: "mode": "int-developer" (23 chars)
|
if len(following) != extra or not all(_is_safe_pad_byte(b) for b in following):
|
||||||
# We need to find the full JSON object and replace it
|
print_warning("Raw edit would require growing the file and no safe padding was found")
|
||||||
continue
|
return False
|
||||||
else:
|
|
||||||
replacement_padded = replacement
|
region_len = len(new_json)
|
||||||
|
# Overwrite the JSON plus the padding region; do NOT shift bytes.
|
||||||
# Find offset
|
data[offset:offset + region_len] = new_json
|
||||||
offset = data.find(pattern)
|
|
||||||
print_info(f"Found pattern at offset {offset} (0x{offset:x})")
|
f.seek(0)
|
||||||
|
f.write(data)
|
||||||
# This simple replacement won't work due to size difference
|
print_success("mode.json modified successfully (raw in-place overwrite)")
|
||||||
# We need a smarter approach
|
return True
|
||||||
modified = True
|
|
||||||
break
|
print_warning("mode.json pattern not found (raw). Will try filesystem mount if available...")
|
||||||
|
|
||||||
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...")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(f"Direct modification failed: {e}")
|
print_error(f"Direct modification failed: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -737,10 +702,49 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
|
|
||||||
if mode_json_path.exists():
|
if mode_json_path.exists():
|
||||||
print_info(f"Found mode.json at {mode_json_path}")
|
print_info(f"Found mode.json at {mode_json_path}")
|
||||||
|
|
||||||
# Read current content
|
# Capture original permissions/ownership so we can restore after copy-write
|
||||||
with open(mode_json_path, "r") as f:
|
perm = None
|
||||||
content = json.load(f)
|
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')}")
|
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"
|
temp_json = WORK_DIR / "mode_temp.json"
|
||||||
with open(temp_json, "w") as f:
|
with open(temp_json, "w") as f:
|
||||||
json.dump(content, f)
|
json.dump(content, f)
|
||||||
|
|
||||||
run_command(
|
run_command(["cp", str(temp_json), str(mode_json_path)], sudo=True)
|
||||||
["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'")
|
print_success("mode.json modified to 'int-developer'")
|
||||||
|
|
||||||
@@ -779,22 +791,240 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
pass
|
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:
|
def modify_var_partition(partition_path: Path) -> bool:
|
||||||
"""Modify the var partition to enable developer mode"""
|
"""Modify the var partition to enable developer mode"""
|
||||||
print_step(4, 6, "Modifying var partition")
|
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):
|
if modify_mode_json_direct(partition_path):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Fall back to mounting (Linux only)
|
|
||||||
if platform.system() == "Linux":
|
|
||||||
return modify_partition_mounted(partition_path)
|
|
||||||
|
|
||||||
print_error("Could not modify partition")
|
print_error("Could not modify partition")
|
||||||
return False
|
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
|
# eMMC Operations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1107,6 +1337,88 @@ def run_write_only(args) -> bool:
|
|||||||
return write_partition_to_emmc(partition_path, args.start_sector)
|
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
|
# CLI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1130,6 +1442,8 @@ Examples:
|
|||||||
help="Only dump the eMMC without modifying")
|
help="Only dump the eMMC without modifying")
|
||||||
mode_group.add_argument("--write-partition", metavar="FILE",
|
mode_group.add_argument("--write-partition", metavar="FILE",
|
||||||
help="Write a partition file to Jibo (requires --start-sector)")
|
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
|
# Options
|
||||||
parser.add_argument("--dump-path", metavar="FILE",
|
parser.add_argument("--dump-path", metavar="FILE",
|
||||||
@@ -1148,6 +1462,8 @@ Examples:
|
|||||||
help="Verify write by reading back (default: True)")
|
help="Verify write by reading back (default: True)")
|
||||||
parser.add_argument("--no-verify", action="store_false", dest="verify",
|
parser.add_argument("--no-verify", action="store_false", dest="verify",
|
||||||
help="Skip write verification")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1162,6 +1478,8 @@ Examples:
|
|||||||
elif args.write_partition:
|
elif args.write_partition:
|
||||||
args.partition = args.write_partition
|
args.partition = args.write_partition
|
||||||
success = run_write_only(args)
|
success = run_write_only(args)
|
||||||
|
elif args.mode_json_only:
|
||||||
|
success = run_mode_json_only(args)
|
||||||
else:
|
else:
|
||||||
success = run_full_mod(args)
|
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