ummm new tui thing ?
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
*.bak
|
||||||
8
Distributors.json
Normal file
8
Distributors.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"LastUpdated": "28-3-26",
|
||||||
|
"OfficialHosts": [
|
||||||
|
"https://code.zane.org/ZaneDev/JiboOs-Mirror/releases",
|
||||||
|
"https://kevinblog.sytes.net/Code/api/v1/repos/Kevin/JiboOs/releases"
|
||||||
|
],
|
||||||
|
"UnoficialHosts": []
|
||||||
|
}
|
||||||
@@ -67,13 +67,11 @@ def load_config_entries_from_values_md() -> list[ConfigEntry]:
|
|||||||
if path in seen:
|
if path in seen:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter out non-robot/server dev configs the user doesn't want here.
|
|
||||||
if path.startswith("/hub-shim/"):
|
if path.startswith("/hub-shim/"):
|
||||||
continue
|
continue
|
||||||
if path.lower().endswith(".md"):
|
if path.lower().endswith(".md"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Keep it focused on JSON files (these are strict JSON configs).
|
|
||||||
if not path.lower().endswith(".json"):
|
if not path.lower().endswith(".json"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ class MainWindowController:
|
|||||||
self._identity: Optional[dict] = None
|
self._identity: Optional[dict] = None
|
||||||
self._connecting = False
|
self._connecting = False
|
||||||
|
|
||||||
# Tabs + connection pill
|
|
||||||
self.tab_widget = require_child(self.window, "tabWidget", QTabWidget)
|
self.tab_widget = require_child(self.window, "tabWidget", QTabWidget)
|
||||||
self.connection_pill, self.conn_dot, self.conn_text = self._create_connection_pill()
|
self.connection_pill, self.conn_dot, self.conn_text = self._create_connection_pill()
|
||||||
self.tab_widget.setCornerWidget(self.connection_pill, Qt.TopRightCorner)
|
self.tab_widget.setCornerWidget(self.connection_pill, Qt.TopRightCorner)
|
||||||
|
|
||||||
# Jibo/config
|
|
||||||
self.jibo_ip = require_child(self.window, "JiboIpField", QLineEdit)
|
self.jibo_ip = require_child(self.window, "JiboIpField", QLineEdit)
|
||||||
self.connect_button = require_child(self.window, "TryToConnect", QPushButton)
|
self.connect_button = require_child(self.window, "TryToConnect", QPushButton)
|
||||||
self.jibo_title = require_child(self.window, "jiboTitle", QLabel)
|
self.jibo_title = require_child(self.window, "jiboTitle", QLabel)
|
||||||
@@ -65,7 +63,6 @@ class MainWindowController:
|
|||||||
self.ha_enable = require_child(self.window, "haEnableCheck", QCheckBox)
|
self.ha_enable = require_child(self.window, "haEnableCheck", QCheckBox)
|
||||||
self.ha_server_ip = require_child(self.window, "haServerIpField", QLineEdit)
|
self.ha_server_ip = require_child(self.window, "haServerIpField", QLineEdit)
|
||||||
|
|
||||||
# AI Bridge (formerly "AI Provider")
|
|
||||||
self.ai_enable = require_child(self.window, "aiEnableCheck", QCheckBox)
|
self.ai_enable = require_child(self.window, "aiEnableCheck", QCheckBox)
|
||||||
self.ai_mode = require_child(self.window, "aiProviderCombo", QComboBox)
|
self.ai_mode = require_child(self.window, "aiProviderCombo", QComboBox)
|
||||||
self.ai_server_base_url = require_child(self.window, "aiEndpointField", QLineEdit)
|
self.ai_server_base_url = require_child(self.window, "aiEndpointField", QLineEdit)
|
||||||
@@ -82,10 +79,8 @@ class MainWindowController:
|
|||||||
|
|
||||||
self._ai_bridge_obj: Optional[dict[str, Any]] = None
|
self._ai_bridge_obj: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
# Tool settings
|
|
||||||
self.enable_logging_check = require_child(self.window, "enableLoggingCheck", QCheckBox)
|
self.enable_logging_check = require_child(self.window, "enableLoggingCheck", QCheckBox)
|
||||||
|
|
||||||
# Config editor (main panel "Config" section)
|
|
||||||
self.config_file_combo = require_child(self.window, "configFileCombo", QComboBox)
|
self.config_file_combo = require_child(self.window, "configFileCombo", QComboBox)
|
||||||
self.config_read_button = require_child(self.window, "configReadButton", QPushButton)
|
self.config_read_button = require_child(self.window, "configReadButton", QPushButton)
|
||||||
self.config_write_button = require_child(self.window, "configWriteButton", QPushButton)
|
self.config_write_button = require_child(self.window, "configWriteButton", QPushButton)
|
||||||
@@ -96,18 +91,15 @@ class MainWindowController:
|
|||||||
self._config_last_read_text: Optional[str] = None
|
self._config_last_read_text: Optional[str] = None
|
||||||
self._config_paths: list[str] = []
|
self._config_paths: list[str] = []
|
||||||
|
|
||||||
# Jibo card controls
|
|
||||||
self.robot_settings_button = require_child(self.window, "RobotSettings", QPushButton)
|
self.robot_settings_button = require_child(self.window, "RobotSettings", QPushButton)
|
||||||
self.robot_action_combo = require_child(self.window, "comboBox", QComboBox)
|
self.robot_action_combo = require_child(self.window, "comboBox", QComboBox)
|
||||||
self.jibo_image = require_child(self.window, "jiboImage", QLabel)
|
self.jibo_image = require_child(self.window, "jiboImage", QLabel)
|
||||||
|
|
||||||
self._robot_settings_window: Optional[object] = None
|
self._robot_settings_window: Optional[object] = None
|
||||||
|
|
||||||
# Update page
|
|
||||||
self.install_button = require_child(self.window, "installButton", QPushButton)
|
self.install_button = require_child(self.window, "installButton", QPushButton)
|
||||||
self.check_updates_button = require_child(self.window, "checkUpdatesButton", QPushButton)
|
self.check_updates_button = require_child(self.window, "checkUpdatesButton", QPushButton)
|
||||||
|
|
||||||
# Status page
|
|
||||||
self.status_dot = require_child(self.window, "statusDot", QLabel)
|
self.status_dot = require_child(self.window, "statusDot", QLabel)
|
||||||
self.status_text = require_child(self.window, "statusText", QLabel)
|
self.status_text = require_child(self.window, "statusText", QLabel)
|
||||||
|
|
||||||
@@ -137,7 +129,6 @@ class MainWindowController:
|
|||||||
return self.session_connected
|
return self.session_connected
|
||||||
|
|
||||||
def _configure_ui(self) -> None:
|
def _configure_ui(self) -> None:
|
||||||
# Simple styling, roughly matching the previous QML look.
|
|
||||||
self.connection_pill.setStyleSheet(
|
self.connection_pill.setStyleSheet(
|
||||||
"QFrame#connectionPill {"
|
"QFrame#connectionPill {"
|
||||||
"background-color: #f6f6f6;"
|
"background-color: #f6f6f6;"
|
||||||
@@ -146,22 +137,18 @@ class MainWindowController:
|
|||||||
"}"
|
"}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# AI Bridge mode choices
|
|
||||||
self.ai_mode.clear()
|
self.ai_mode.clear()
|
||||||
self.ai_mode.addItems(["TEXT", "AUDIO"])
|
self.ai_mode.addItems(["TEXT", "AUDIO"])
|
||||||
|
|
||||||
# Robot controls start disabled until connected.
|
|
||||||
self.robot_settings_button.setEnabled(False)
|
self.robot_settings_button.setEnabled(False)
|
||||||
self.robot_action_combo.setEnabled(False)
|
self.robot_action_combo.setEnabled(False)
|
||||||
|
|
||||||
# Config editor defaults
|
|
||||||
self.config_editor.setPlaceholderText("Select a config file, then Read")
|
self.config_editor.setPlaceholderText("Select a config file, then Read")
|
||||||
self.config_activity_log.setReadOnly(True)
|
self.config_activity_log.setReadOnly(True)
|
||||||
self.config_activity_log.setPlaceholderText("Logging is disabled")
|
self.config_activity_log.setPlaceholderText("Logging is disabled")
|
||||||
self.config_read_button.setEnabled(False)
|
self.config_read_button.setEnabled(False)
|
||||||
self.config_write_button.setEnabled(False)
|
self.config_write_button.setEnabled(False)
|
||||||
|
|
||||||
# Defaults
|
|
||||||
self.connect_button.setText("Connect")
|
self.connect_button.setText("Connect")
|
||||||
self.jibo_title.setText("Connect Your Jibo")
|
self.jibo_title.setText("Connect Your Jibo")
|
||||||
|
|
||||||
@@ -179,7 +166,6 @@ class MainWindowController:
|
|||||||
|
|
||||||
self.edit_ai_bridge_button.clicked.connect(self._jump_to_ai_bridge_config)
|
self.edit_ai_bridge_button.clicked.connect(self._jump_to_ai_bridge_config)
|
||||||
|
|
||||||
# Keep AI Bridge in-memory config in sync with UI edits.
|
|
||||||
self.ai_enable.toggled.connect(self._sync_ai_bridge_obj_from_ui)
|
self.ai_enable.toggled.connect(self._sync_ai_bridge_obj_from_ui)
|
||||||
self.ai_mode.currentIndexChanged.connect(self._sync_ai_bridge_obj_from_ui)
|
self.ai_mode.currentIndexChanged.connect(self._sync_ai_bridge_obj_from_ui)
|
||||||
self.ai_server_base_url.textChanged.connect(self._sync_ai_bridge_obj_from_ui)
|
self.ai_server_base_url.textChanged.connect(self._sync_ai_bridge_obj_from_ui)
|
||||||
@@ -218,7 +204,6 @@ class MainWindowController:
|
|||||||
ssh_client=self._ssh_client,
|
ssh_client=self._ssh_client,
|
||||||
logging_enabled_check=self.enable_logging_check,
|
logging_enabled_check=self.enable_logging_check,
|
||||||
)
|
)
|
||||||
# Refresh the SSH client reference in case we reconnected.
|
|
||||||
try:
|
try:
|
||||||
self._robot_settings_window.set_ssh_client(self._ssh_client) # type: ignore[attr-defined]
|
self._robot_settings_window.set_ssh_client(self._ssh_client) # type: ignore[attr-defined]
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -245,12 +230,10 @@ class MainWindowController:
|
|||||||
self.ai_followup_enabled.setEnabled(ai_enabled)
|
self.ai_followup_enabled.setEnabled(ai_enabled)
|
||||||
self.ai_followup_delay_ms.setEnabled(ai_enabled)
|
self.ai_followup_delay_ms.setEnabled(ai_enabled)
|
||||||
|
|
||||||
# Connection button enabled unless a connect attempt is in progress.
|
|
||||||
self.connect_button.setEnabled(not self._connecting)
|
self.connect_button.setEnabled(not self._connecting)
|
||||||
|
|
||||||
connected = self.session_connected
|
connected = self.session_connected
|
||||||
self.config_read_button.setEnabled(connected and self.config_file_combo.count() > 0)
|
self.config_read_button.setEnabled(connected and self.config_file_combo.count() > 0)
|
||||||
# write button is controlled by editor dirty state
|
|
||||||
|
|
||||||
def _sync_all(self) -> None:
|
def _sync_all(self) -> None:
|
||||||
host = self.host
|
host = self.host
|
||||||
@@ -285,7 +268,6 @@ class MainWindowController:
|
|||||||
else:
|
else:
|
||||||
self.status_text.setText("No Jibo IP configured")
|
self.status_text.setText("No Jibo IP configured")
|
||||||
|
|
||||||
# Image swap
|
|
||||||
assets = Path(__file__).resolve().parent / "Assets" / "Jibo"
|
assets = Path(__file__).resolve().parent / "Assets" / "Jibo"
|
||||||
img_path = assets / ("JiboFaceForward.png" if visual_connected else "NoJiboConnected.png")
|
img_path = assets / ("JiboFaceForward.png" if visual_connected else "NoJiboConnected.png")
|
||||||
pm = QPixmap(str(img_path))
|
pm = QPixmap(str(img_path))
|
||||||
@@ -340,7 +322,6 @@ class MainWindowController:
|
|||||||
self.config_activity_log.appendPlainText(message)
|
self.config_activity_log.appendPlainText(message)
|
||||||
|
|
||||||
def _populate_config_file_combo(self) -> None:
|
def _populate_config_file_combo(self) -> None:
|
||||||
# Populate from inventory, excluding /usr/local/etc (those belong under Robot Settings)
|
|
||||||
entries = load_config_entries_from_values_md()
|
entries = load_config_entries_from_values_md()
|
||||||
paths = [e.remote_path for e in entries if not e.is_usr_local_etc]
|
paths = [e.remote_path for e in entries if not e.is_usr_local_etc]
|
||||||
paths = sorted(paths)
|
paths = sorted(paths)
|
||||||
@@ -369,8 +350,6 @@ class MainWindowController:
|
|||||||
self.config_file_combo.setCurrentIndex(idx)
|
self.config_file_combo.setCurrentIndex(idx)
|
||||||
self._read_selected_config()
|
self._read_selected_config()
|
||||||
|
|
||||||
# Seed editor with the current AI Bridge UI state so the user can
|
|
||||||
# immediately press Write.
|
|
||||||
try:
|
try:
|
||||||
merged = self._merged_ai_bridge_obj_from_ui()
|
merged = self._merged_ai_bridge_obj_from_ui()
|
||||||
desired_text = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
desired_text = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
||||||
@@ -539,7 +518,6 @@ class MainWindowController:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
def _sync_ai_bridge_obj_from_ui(self, *_args: Any) -> None:
|
def _sync_ai_bridge_obj_from_ui(self, *_args: Any) -> None:
|
||||||
# Keep unknown keys (if any) from the on-robot JSON.
|
|
||||||
try:
|
try:
|
||||||
self._ai_bridge_obj = self._merged_ai_bridge_obj_from_ui()
|
self._ai_bridge_obj = self._merged_ai_bridge_obj_from_ui()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -577,7 +555,6 @@ class MainWindowController:
|
|||||||
except Exception:
|
except Exception:
|
||||||
old_obj = MISSING
|
old_obj = MISSING
|
||||||
|
|
||||||
# Safety: if a /usr/local path ever ends up here, handle remount.
|
|
||||||
if p.startswith("/usr/local/"):
|
if p.startswith("/usr/local/"):
|
||||||
cmd = "mount -o remount,rw /usr/local"
|
cmd = "mount -o remount,rw /usr/local"
|
||||||
self._log(f"EXEC {cmd}")
|
self._log(f"EXEC {cmd}")
|
||||||
@@ -666,12 +643,10 @@ class MainWindowController:
|
|||||||
|
|
||||||
identity = json.loads(raw_text)
|
identity = json.loads(raw_text)
|
||||||
|
|
||||||
# Success: store session.
|
|
||||||
self._ssh_client = client
|
self._ssh_client = client
|
||||||
self._identity = identity if isinstance(identity, dict) else None
|
self._identity = identity if isinstance(identity, dict) else None
|
||||||
self.status_text.setText(f"Connected via SSH to {host}")
|
self.status_text.setText(f"Connected via SSH to {host}")
|
||||||
|
|
||||||
# Auto-populate AI Bridge section when connected.
|
|
||||||
try:
|
try:
|
||||||
self._load_ai_bridge_from_robot()
|
self._load_ai_bridge_from_robot()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -705,7 +680,6 @@ class MainWindowController:
|
|||||||
layout.addWidget(dot)
|
layout.addWidget(dot)
|
||||||
layout.addWidget(text)
|
layout.addWidget(text)
|
||||||
|
|
||||||
# Keep it tight on the tab bar.
|
|
||||||
pill.setSizePolicy(pill.sizePolicy().horizontalPolicy(), pill.sizePolicy().verticalPolicy())
|
pill.setSizePolicy(pill.sizePolicy().horizontalPolicy(), pill.sizePolicy().verticalPolicy())
|
||||||
pill.setMinimumHeight(28)
|
pill.setMinimumHeight(28)
|
||||||
return pill, dot, text
|
return pill, dot, text
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ def resolve_python_invocation() -> tuple[str, list[str]]:
|
|||||||
if venv_py.exists():
|
if venv_py.exists():
|
||||||
return (str(venv_py), [])
|
return (str(venv_py), [])
|
||||||
|
|
||||||
# Prefer the current interpreter when running inside a venv (e.g. Qt Creator).
|
|
||||||
try:
|
try:
|
||||||
if sys.executable and Path(sys.executable).exists():
|
if sys.executable and Path(sys.executable).exists():
|
||||||
return (sys.executable, [])
|
return (sys.executable, [])
|
||||||
@@ -64,7 +63,6 @@ def resolve_python_invocation() -> tuple[str, list[str]]:
|
|||||||
def resolve_python() -> str:
|
def resolve_python() -> str:
|
||||||
program, prefix = resolve_python_invocation()
|
program, prefix = resolve_python_invocation()
|
||||||
if prefix:
|
if prefix:
|
||||||
# Best-effort string representation (mostly for display)
|
|
||||||
return " ".join([program] + prefix)
|
return " ".join([program] + prefix)
|
||||||
return program
|
return program
|
||||||
|
|
||||||
@@ -82,7 +80,6 @@ def _pick_terminal_command() -> Optional[list[str]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
candidates: list[list[str]] = []
|
candidates: list[list[str]] = []
|
||||||
# Debian/Ubuntu alternative system
|
|
||||||
candidates.append(["x-terminal-emulator", "-e"])
|
candidates.append(["x-terminal-emulator", "-e"])
|
||||||
candidates.append(["gnome-terminal", "--"])
|
candidates.append(["gnome-terminal", "--"])
|
||||||
candidates.append(["konsole", "-e"])
|
candidates.append(["konsole", "-e"])
|
||||||
@@ -102,8 +99,6 @@ def spawn_in_terminal(argv: list[str]) -> bool:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if os.name == "nt":
|
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)
|
cmdline = " ".join(shlex.quote(a) for a in argv)
|
||||||
subprocess.Popen(["cmd", "/c", "start", "cmd", "/k", cmdline], shell=False)
|
subprocess.Popen(["cmd", "/c", "start", "cmd", "/k", cmdline], shell=False)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -44,13 +44,11 @@ class RobotSettingsWindow:
|
|||||||
splitter = QSplitter(Qt.Horizontal)
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
outer.addWidget(splitter, 1)
|
outer.addWidget(splitter, 1)
|
||||||
|
|
||||||
# Left: tree
|
|
||||||
self.tree = QTreeWidget()
|
self.tree = QTreeWidget()
|
||||||
self.tree.setHeaderHidden(True)
|
self.tree.setHeaderHidden(True)
|
||||||
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.tree.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
splitter.addWidget(self.tree)
|
splitter.addWidget(self.tree)
|
||||||
|
|
||||||
# Right: editor + buttons + log
|
|
||||||
right = QWidget()
|
right = QWidget()
|
||||||
right_layout = QVBoxLayout(right)
|
right_layout = QVBoxLayout(right)
|
||||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -168,7 +166,6 @@ class RobotSettingsWindow:
|
|||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def _on_editor_changed(self) -> None:
|
def _on_editor_changed(self) -> None:
|
||||||
# Enable write only if we have a loaded file and text changed.
|
|
||||||
if not self._current_remote_path or self._last_read_text is None:
|
if not self._current_remote_path or self._last_read_text is None:
|
||||||
self.write_button.setEnabled(False)
|
self.write_button.setEnabled(False)
|
||||||
return
|
return
|
||||||
@@ -243,14 +240,12 @@ class RobotSettingsWindow:
|
|||||||
|
|
||||||
new_text_raw = self.editor.toPlainText()
|
new_text_raw = self.editor.toPlainText()
|
||||||
|
|
||||||
# Validate JSON if possible; this tool is focused on strict JSON configs.
|
|
||||||
try:
|
try:
|
||||||
new_obj = json.loads(new_text_raw)
|
new_obj = json.loads(new_text_raw)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self.window, "Invalid JSON", f"JSON parse failed: {e}")
|
QMessageBox.warning(self.window, "Invalid JSON", f"JSON parse failed: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Canonicalize to keep robot-side JSON strict/clean.
|
|
||||||
new_text = json.dumps(new_obj, indent=2, ensure_ascii=False) + "\n"
|
new_text = json.dumps(new_obj, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -264,7 +259,6 @@ class RobotSettingsWindow:
|
|||||||
except Exception:
|
except Exception:
|
||||||
old_obj = MISSING
|
old_obj = MISSING
|
||||||
|
|
||||||
# Mounted dir special case: /usr/local/* is often read-only until remount.
|
|
||||||
if remote_path.startswith("/usr/local/"):
|
if remote_path.startswith("/usr/local/"):
|
||||||
cmd = "mount -o remount,rw /usr/local"
|
cmd = "mount -o remount,rw /usr/local"
|
||||||
self._log(f"EXEC {cmd}")
|
self._log(f"EXEC {cmd}")
|
||||||
@@ -280,7 +274,6 @@ class RobotSettingsWindow:
|
|||||||
if out.strip():
|
if out.strip():
|
||||||
self._log(out.strip())
|
self._log(out.strip())
|
||||||
|
|
||||||
# Compute diffs (best-effort).
|
|
||||||
if old_obj is not MISSING:
|
if old_obj is not MISSING:
|
||||||
diffs = diff_json(old_obj, new_obj)
|
diffs = diff_json(old_obj, new_obj)
|
||||||
if diffs:
|
if diffs:
|
||||||
@@ -302,7 +295,6 @@ class RobotSettingsWindow:
|
|||||||
try:
|
try:
|
||||||
self._sftp_write_text(remote_path, new_text)
|
self._sftp_write_text(remote_path, new_text)
|
||||||
self._log(f"WROTE {remote_path} ({len(new_text)} bytes)")
|
self._log(f"WROTE {remote_path} ({len(new_text)} bytes)")
|
||||||
# Refresh read baseline.
|
|
||||||
self.editor.setPlainText(new_text)
|
self.editor.setPlainText(new_text)
|
||||||
self._last_read_text = new_text
|
self._last_read_text = new_text
|
||||||
self.write_button.setEnabled(False)
|
self.write_button.setEnabled(False)
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
|
|
||||||
self._host_field.setVisible(self._is_updater)
|
self._host_field.setVisible(self._is_updater)
|
||||||
|
|
||||||
# Installer-specific UX
|
|
||||||
self._use_existing_dump.setVisible(self._is_installer)
|
self._use_existing_dump.setVisible(self._is_installer)
|
||||||
self._dump_path.setVisible(self._is_installer)
|
self._dump_path.setVisible(self._is_installer)
|
||||||
self._browse_dump.setVisible(self._is_installer)
|
self._browse_dump.setVisible(self._is_installer)
|
||||||
@@ -108,7 +107,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
self._sync_buttons()
|
self._sync_buttons()
|
||||||
self._sync_status()
|
self._sync_status()
|
||||||
|
|
||||||
# Ensure the process is stopped when the window closes.
|
|
||||||
self.window.closeEvent = self._on_close # type: ignore[assignment]
|
self.window.closeEvent = self._on_close # type: ignore[assignment]
|
||||||
|
|
||||||
def show(self) -> None:
|
def show(self) -> None:
|
||||||
@@ -125,7 +123,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
extra = self._extra_args.text().strip()
|
extra = self._extra_args.text().strip()
|
||||||
extra_args: list[str] = shlex.split(extra) if extra else []
|
extra_args: list[str] = shlex.split(extra) if extra else []
|
||||||
|
|
||||||
# Installer convenience: if the user has an existing dump, pass it via --dump-path
|
|
||||||
if self._is_installer and self._use_existing_dump.isChecked():
|
if self._is_installer and self._use_existing_dump.isChecked():
|
||||||
dump_path = self._dump_path.text().strip()
|
dump_path = self._dump_path.text().strip()
|
||||||
if dump_path and "--dump-path" not in extra_args:
|
if dump_path and "--dump-path" not in extra_args:
|
||||||
@@ -150,7 +147,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
self._status.setText("Dump file not found")
|
self._status.setText("Dump file not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Reset progress state for a new run.
|
|
||||||
self._output_buffer = ""
|
self._output_buffer = ""
|
||||||
self._last_step_total = None
|
self._last_step_total = None
|
||||||
if self._is_installer:
|
if self._is_installer:
|
||||||
@@ -168,7 +164,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def _append_output(self, chunk: str) -> None:
|
def _append_output(self, chunk: str) -> None:
|
||||||
# Keep it simple: append and scroll to end.
|
|
||||||
self._log.moveCursor(QTextCursor.End)
|
self._log.moveCursor(QTextCursor.End)
|
||||||
self._log.insertPlainText(chunk)
|
self._log.insertPlainText(chunk)
|
||||||
self._log.moveCursor(QTextCursor.End)
|
self._log.moveCursor(QTextCursor.End)
|
||||||
@@ -181,7 +176,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
self._start_stop.setText("Stop" if running else "Start")
|
self._start_stop.setText("Stop" if running else "Start")
|
||||||
self._open_terminal.setEnabled(not running)
|
self._open_terminal.setEnabled(not running)
|
||||||
if not running and self._is_installer:
|
if not running and self._is_installer:
|
||||||
# Leave progress/status in a meaningful final state.
|
|
||||||
if self.runner.exitCode == 0 and self._last_step_total:
|
if self.runner.exitCode == 0 and self._last_step_total:
|
||||||
self._progress.setRange(0, self._last_step_total)
|
self._progress.setRange(0, self._last_step_total)
|
||||||
self._progress.setValue(self._last_step_total)
|
self._progress.setValue(self._last_step_total)
|
||||||
@@ -194,7 +188,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
def _sync_status(self) -> None:
|
def _sync_status(self) -> None:
|
||||||
if self.runner.running:
|
if self.runner.running:
|
||||||
self._status.setText("Running...")
|
self._status.setText("Running...")
|
||||||
# Indeterminate until we see a structured step marker.
|
|
||||||
if self._is_installer and self._last_step_total is None:
|
if self._is_installer and self._last_step_total is None:
|
||||||
self._progress.setRange(0, 0)
|
self._progress.setRange(0, 0)
|
||||||
return
|
return
|
||||||
@@ -233,7 +226,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
self._output_buffer += chunk
|
self._output_buffer += chunk
|
||||||
lines = self._output_buffer.splitlines(keepends=True)
|
lines = self._output_buffer.splitlines(keepends=True)
|
||||||
|
|
||||||
# Keep any partial line for the next chunk.
|
|
||||||
if lines and not (lines[-1].endswith("\n") or lines[-1].endswith("\r")):
|
if lines and not (lines[-1].endswith("\n") or lines[-1].endswith("\r")):
|
||||||
self._output_buffer = lines[-1]
|
self._output_buffer = lines[-1]
|
||||||
lines = lines[:-1]
|
lines = lines[:-1]
|
||||||
@@ -245,7 +237,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
if not clean:
|
if not clean:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Also surface meaningful non-step status lines (RCM detection, warnings, etc.)
|
|
||||||
if clean.startswith(("ℹ", "⚠", "✓", "✗")) or "RCM" in clean:
|
if clean.startswith(("ℹ", "⚠", "✓", "✗")) or "RCM" in clean:
|
||||||
msg = _clean_status_line(clean)
|
msg = _clean_status_line(clean)
|
||||||
if msg and not msg.startswith("["):
|
if msg and not msg.startswith("["):
|
||||||
@@ -258,7 +249,6 @@ class ToolRunnerWindow(QObject):
|
|||||||
total = int(m.group(2))
|
total = int(m.group(2))
|
||||||
msg = m.group(3).strip()
|
msg = m.group(3).strip()
|
||||||
|
|
||||||
# Some flows use [0/6] for dependency checks.
|
|
||||||
if total > 0:
|
if total > 0:
|
||||||
self._last_step_total = total
|
self._last_step_total = total
|
||||||
self._progress.setRange(0, total)
|
self._progress.setRange(0, total)
|
||||||
@@ -284,9 +274,7 @@ def _strip_ansi(s: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _clean_status_line(s: str) -> str:
|
def _clean_status_line(s: str) -> str:
|
||||||
# Drop leading glyphs used by the CLI (info/warn/success/error)
|
|
||||||
s = re.sub(r"^[✓⚠✗ℹ]\s+", "", s).strip()
|
s = re.sub(r"^[✓⚠✗ℹ]\s+", "", s).strip()
|
||||||
# Collapse extra whitespace
|
|
||||||
s = re.sub(r"\s+", " ", s).strip()
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ def load_ui(ui_path: Path) -> object:
|
|||||||
|
|
||||||
|
|
||||||
def require_child(parent: object, name: str, typ: type[T]) -> T:
|
def require_child(parent: object, name: str, typ: type[T]) -> T:
|
||||||
# Qt objects implement findChild; keep typing light.
|
|
||||||
child = parent.findChild(typ, name) # type: ignore[attr-defined]
|
child = parent.findChild(typ, name) # type: ignore[attr-defined]
|
||||||
if child is None:
|
if child is None:
|
||||||
raise RuntimeError(f"UI is missing required widget '{name}' ({typ.__name__})")
|
raise RuntimeError(f"UI is missing required widget '{name}' ({typ.__name__})")
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
################################################################################
|
|
||||||
## Form generated from reading UI file 'tool_runner.ui'
|
|
||||||
##
|
|
||||||
## Created by: Qt User Interface Compiler version 6.10.2
|
|
||||||
##
|
|
||||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
|
||||||
################################################################################
|
|
||||||
|
|
||||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
QMetaObject, QObject, QPoint, QRect,
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
@@ -149,7 +142,6 @@ class Ui_ToolRunnerWindow(object):
|
|||||||
self.retranslateUi(ToolRunnerWindow)
|
self.retranslateUi(ToolRunnerWindow)
|
||||||
|
|
||||||
QMetaObject.connectSlotsByName(ToolRunnerWindow)
|
QMetaObject.connectSlotsByName(ToolRunnerWindow)
|
||||||
# setupUi
|
|
||||||
|
|
||||||
def retranslateUi(self, ToolRunnerWindow):
|
def retranslateUi(self, ToolRunnerWindow):
|
||||||
ToolRunnerWindow.setWindowTitle(QCoreApplication.translate("ToolRunnerWindow", u"Tool", None))
|
ToolRunnerWindow.setWindowTitle(QCoreApplication.translate("ToolRunnerWindow", u"Tool", None))
|
||||||
@@ -165,5 +157,4 @@ class Ui_ToolRunnerWindow(object):
|
|||||||
self.currentStepLabel.setText(QCoreApplication.translate("ToolRunnerWindow", u"Idle", None))
|
self.currentStepLabel.setText(QCoreApplication.translate("ToolRunnerWindow", u"Idle", None))
|
||||||
self.statusLabel.setText(QCoreApplication.translate("ToolRunnerWindow", u"Idle", None))
|
self.statusLabel.setText(QCoreApplication.translate("ToolRunnerWindow", u"Idle", None))
|
||||||
self.clearLogButton.setText(QCoreApplication.translate("ToolRunnerWindow", u"Clear log", None))
|
self.clearLogButton.setText(QCoreApplication.translate("ToolRunnerWindow", u"Clear log", None))
|
||||||
# retranslateUi
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
################################################################################
|
|
||||||
## Form generated from reading UI file 'form.ui'
|
|
||||||
##
|
|
||||||
## Created by: Qt User Interface Compiler version 6.10.2
|
|
||||||
##
|
|
||||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
|
||||||
################################################################################
|
|
||||||
|
|
||||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
QMetaObject, QObject, QPoint, QRect,
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
@@ -557,7 +550,6 @@ class Ui_MainWindow(object):
|
|||||||
|
|
||||||
|
|
||||||
QMetaObject.connectSlotsByName(MainWindow)
|
QMetaObject.connectSlotsByName(MainWindow)
|
||||||
# setupUi
|
|
||||||
|
|
||||||
def retranslateUi(self, MainWindow):
|
def retranslateUi(self, MainWindow):
|
||||||
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"Jibo Tools", None))
|
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"Jibo Tools", None))
|
||||||
@@ -627,5 +619,4 @@ class Ui_MainWindow(object):
|
|||||||
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabStatus), QCoreApplication.translate("MainWindow", u"Status", None))
|
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabStatus), QCoreApplication.translate("MainWindow", u"Status", None))
|
||||||
self.robotOsComingSoon.setText(QCoreApplication.translate("MainWindow", u"Coming soon.", None))
|
self.robotOsComingSoon.setText(QCoreApplication.translate("MainWindow", u"Coming soon.", None))
|
||||||
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabRobotOs), QCoreApplication.translate("MainWindow", u"Robot OS", None))
|
self.tabWidget.setTabText(self.tabWidget.indexOf(self.tabRobotOs), QCoreApplication.translate("MainWindow", u"Robot OS", None))
|
||||||
# retranslateUi
|
|
||||||
|
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -398,6 +398,41 @@ Returning to normal mode
|
|||||||
After update, set /var/jibo/mode.json back to normal (no prompt).
|
After update, set /var/jibo/mode.json back to normal (no prompt).
|
||||||
--no-return-normal
|
--no-return-normal
|
||||||
|
|
||||||
|
## Updater: Interactive TUI and GUI integration
|
||||||
|
|
||||||
|
The bundled `jibo_updater.py` now supports a simple interactive text UI and a small programmatic interface intended for later GUI integration.
|
||||||
|
|
||||||
|
- `--tui`: Launch a brief interactive text UI to pick a distribution host and release, or select a local archive under `jibo_work/updates/downloads/`.
|
||||||
|
- `--distributors <path>`: Use `Distributors.json` (default) to get a list of release hosts to probe for latency and releases.
|
||||||
|
|
||||||
|
Standalone TUI helper
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
If you want a more polished terminal UI, use the new `jibo_updater_tui.py` curses-based helper. It provides
|
||||||
|
keyboard navigation and prints a JSON selection to stdout suitable for piping into other scripts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 jibo_updater_tui.py --distributors Distributors.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI outputs a JSON object describing the selected host and release, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"host": "https://code.zane.org/..", "source":"remote", "tag":"v3.3.0", "tarball_url":"https://..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- The TUI will probe hosts listed in `Distributors.json` and present latency and available releases.
|
||||||
|
- Local archives found in `jibo_work/updates/downloads/` are shown as a "local" source and can be chosen without downloading.
|
||||||
|
- When updating, uploaded files and directories are set to permissive `0777` to avoid boot failures caused by missing execute/read permissions.
|
||||||
|
|
||||||
|
GUI integration:
|
||||||
|
- `jibo_updater.py` includes a simple programmatic surface (CLI flags and a small interactive mode) designed so a GUI can call it or be wired to a future HTTP/JSON control API. For now, use `--tui` to exercise the flow; GUI hooks will be documented in `CHECKLIST.md` for the next steps.
|
||||||
|
|
||||||
|
Dependencies: no additional Python packages were added for this change (uses standard library + `paramiko` already required). If you use the GUI in future, update `requirements-gui.txt` accordingly.
|
||||||
|
|
||||||
Never prompt and never change mode back.
|
Never prompt and never change mode back.
|
||||||
Examples
|
Examples
|
||||||
Update to latest:
|
Update to latest:
|
||||||
|
|||||||
BIN
__pycache__/jibo_updater.cpython-314.pyc
Normal file
BIN
__pycache__/jibo_updater.cpython-314.pyc
Normal file
Binary file not shown.
133
jibo_automod.py
133
jibo_automod.py
@@ -24,19 +24,14 @@ from pathlib import Path
|
|||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Configuration
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
SHOFEL_DIR = SCRIPT_DIR / "Shofel"
|
SHOFEL_DIR = SCRIPT_DIR / "Shofel"
|
||||||
WORK_DIR = SCRIPT_DIR / "jibo_work"
|
WORK_DIR = SCRIPT_DIR / "jibo_work"
|
||||||
|
|
||||||
# eMMC dump parameters
|
|
||||||
EMMC_TOTAL_SECTORS = 0x1D60000 # Total sectors to dump (~15GB)
|
EMMC_TOTAL_SECTORS = 0x1D60000 # Total sectors to dump (~15GB)
|
||||||
EMMC_SECTOR_SIZE = 512
|
EMMC_SECTOR_SIZE = 512
|
||||||
|
|
||||||
# Colors for terminal output
|
|
||||||
class Colors:
|
class Colors:
|
||||||
RED = '\033[91m'
|
RED = '\033[91m'
|
||||||
GREEN = '\033[92m'
|
GREEN = '\033[92m'
|
||||||
@@ -47,7 +42,6 @@ class Colors:
|
|||||||
RESET = '\033[0m'
|
RESET = '\033[0m'
|
||||||
BOLD = '\033[1m'
|
BOLD = '\033[1m'
|
||||||
|
|
||||||
# Disable colors on Windows unless using Windows Terminal
|
|
||||||
if platform.system() == "Windows" and "WT_SESSION" not in os.environ:
|
if platform.system() == "Windows" and "WT_SESSION" not in os.environ:
|
||||||
for attr in dir(Colors):
|
for attr in dir(Colors):
|
||||||
if not attr.startswith('_'):
|
if not attr.startswith('_'):
|
||||||
@@ -64,9 +58,6 @@ class PartitionInfo:
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Utilities
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def print_banner():
|
def print_banner():
|
||||||
"""Print the tool banner"""
|
"""Print the tool banner"""
|
||||||
@@ -151,22 +142,17 @@ def _check_payloads_exist() -> bool:
|
|||||||
return all((SHOFEL_DIR / p).exists() for p in critical_payloads)
|
return all((SHOFEL_DIR / p).exists() for p in critical_payloads)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Dependency Checking
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def check_linux_dependencies() -> Tuple[bool, List[str], List[str]]:
|
def check_linux_dependencies() -> Tuple[bool, List[str], List[str]]:
|
||||||
"""Check for required Linux dependencies"""
|
"""Check for required Linux dependencies"""
|
||||||
missing = []
|
missing = []
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
||||||
# Required tools for host build
|
|
||||||
required_tools = {
|
required_tools = {
|
||||||
"gcc": "build-essential or base-devel",
|
"gcc": "build-essential or base-devel",
|
||||||
"make": "build-essential or base-devel",
|
"make": "build-essential or base-devel",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional tools (have fallbacks)
|
|
||||||
optional_tools = {
|
optional_tools = {
|
||||||
"lsusb": "usbutils (optional, used for device detection)",
|
"lsusb": "usbutils (optional, used for device detection)",
|
||||||
"fdisk": "util-linux (optional, has Python fallback)",
|
"fdisk": "util-linux (optional, has Python fallback)",
|
||||||
@@ -180,12 +166,10 @@ def check_linux_dependencies() -> Tuple[bool, List[str], List[str]]:
|
|||||||
if not shutil.which(tool):
|
if not shutil.which(tool):
|
||||||
warnings.append(f"{tool} ({package})")
|
warnings.append(f"{tool} ({package})")
|
||||||
|
|
||||||
# Check ARM toolchain only if payloads are missing
|
|
||||||
if not _check_payloads_exist():
|
if not _check_payloads_exist():
|
||||||
if not shutil.which("arm-none-eabi-gcc"):
|
if not shutil.which("arm-none-eabi-gcc"):
|
||||||
missing.append("arm-none-eabi-gcc (arm-none-eabi-gcc or arm-none-eabi-toolchain)")
|
missing.append("arm-none-eabi-gcc (arm-none-eabi-gcc or arm-none-eabi-toolchain)")
|
||||||
|
|
||||||
# Check for libusb
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pkg-config", "--exists", "libusb-1.0"],
|
["pkg-config", "--exists", "libusb-1.0"],
|
||||||
@@ -194,7 +178,6 @@ def check_linux_dependencies() -> Tuple[bool, List[str], List[str]]:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
missing.append("libusb-1.0-dev or libusb1-devel")
|
missing.append("libusb-1.0-dev or libusb1-devel")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# pkg-config not found, try alternative check
|
|
||||||
if not Path("/usr/include/libusb-1.0").exists() and \
|
if not Path("/usr/include/libusb-1.0").exists() and \
|
||||||
not Path("/usr/local/include/libusb-1.0").exists():
|
not Path("/usr/local/include/libusb-1.0").exists():
|
||||||
missing.append("libusb-1.0-dev or libusb1-devel")
|
missing.append("libusb-1.0-dev or libusb1-devel")
|
||||||
@@ -207,20 +190,16 @@ def check_windows_dependencies() -> Tuple[bool, List[str], List[str]]:
|
|||||||
missing = []
|
missing = []
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
||||||
# Check for MinGW or MSYS2
|
|
||||||
if not shutil.which("gcc") and not shutil.which("x86_64-w64-mingw32-gcc"):
|
if not shutil.which("gcc") and not shutil.which("x86_64-w64-mingw32-gcc"):
|
||||||
missing.append("MinGW-w64 or MSYS2")
|
missing.append("MinGW-w64 or MSYS2")
|
||||||
|
|
||||||
# Check for ARM toolchain only if payloads missing
|
|
||||||
if not _check_payloads_exist():
|
if not _check_payloads_exist():
|
||||||
if not shutil.which("arm-none-eabi-gcc"):
|
if not shutil.which("arm-none-eabi-gcc"):
|
||||||
missing.append("ARM GNU Toolchain (arm-none-eabi-gcc)")
|
missing.append("ARM GNU Toolchain (arm-none-eabi-gcc)")
|
||||||
|
|
||||||
# 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"):
|
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")
|
warnings.append("debugfs (e2fsprogs) - optional but recommended for reliable mode.json edits on Windows")
|
||||||
|
|
||||||
@@ -243,7 +222,6 @@ def print_install_instructions(system: str, missing: List[str], warnings: List[s
|
|||||||
print(f"\n{Colors.BOLD}Installation instructions:{Colors.RESET}")
|
print(f"\n{Colors.BOLD}Installation instructions:{Colors.RESET}")
|
||||||
|
|
||||||
if system == "Linux":
|
if system == "Linux":
|
||||||
# Detect distro
|
|
||||||
distro = "unknown"
|
distro = "unknown"
|
||||||
if Path("/etc/arch-release").exists():
|
if Path("/etc/arch-release").exists():
|
||||||
distro = "arch"
|
distro = "arch"
|
||||||
@@ -289,9 +267,6 @@ def print_install_instructions(system: str, missing: List[str], warnings: List[s
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Shofel Building
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def check_shofel_built() -> bool:
|
def check_shofel_built() -> bool:
|
||||||
"""Check if shofel2_t124 is already built"""
|
"""Check if shofel2_t124 is already built"""
|
||||||
@@ -333,18 +308,14 @@ def build_shofel(force_rebuild: bool = False) -> bool:
|
|||||||
print_info("Compiling shofel2_t124...")
|
print_info("Compiling shofel2_t124...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Only clean host build (preserves payload .bin files)
|
|
||||||
if force_rebuild:
|
if force_rebuild:
|
||||||
run_command(["make", "clean"], cwd=SHOFEL_DIR, capture_output=True, check=False)
|
run_command(["make", "clean"], cwd=SHOFEL_DIR, capture_output=True, check=False)
|
||||||
|
|
||||||
# Build (Makefile will skip existing payload .bin files)
|
|
||||||
result = run_command(["make"], cwd=SHOFEL_DIR, capture_output=True, check=False)
|
result = run_command(["make"], cwd=SHOFEL_DIR, capture_output=True, check=False)
|
||||||
|
|
||||||
# Check if the main executable was built
|
|
||||||
if check_shofel_built():
|
if check_shofel_built():
|
||||||
print_success("Host tool (shofel2_t124) built successfully!")
|
print_success("Host tool (shofel2_t124) built successfully!")
|
||||||
|
|
||||||
# Check payloads again
|
|
||||||
payloads_ok, missing_payloads = check_payloads_built()
|
payloads_ok, missing_payloads = check_payloads_built()
|
||||||
if not payloads_ok:
|
if not payloads_ok:
|
||||||
print_error("ARM payload binaries are missing!")
|
print_error("ARM payload binaries are missing!")
|
||||||
@@ -353,7 +324,6 @@ def build_shofel(force_rebuild: bool = False) -> bool:
|
|||||||
print(f"{Colors.YELLOW}The ARM toolchain (arm-none-eabi-gcc) is required to build payloads.{Colors.RESET}")
|
print(f"{Colors.YELLOW}The ARM toolchain (arm-none-eabi-gcc) is required to build payloads.{Colors.RESET}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Detect distro and provide instructions
|
|
||||||
if Path("/etc/arch-release").exists():
|
if Path("/etc/arch-release").exists():
|
||||||
print(f" {Colors.CYAN}Arch/CachyOS:{Colors.RESET} sudo pacman -S arm-none-eabi-gcc arm-none-eabi-newlib")
|
print(f" {Colors.CYAN}Arch/CachyOS:{Colors.RESET} sudo pacman -S arm-none-eabi-gcc arm-none-eabi-newlib")
|
||||||
elif Path("/etc/debian_version").exists():
|
elif Path("/etc/debian_version").exists():
|
||||||
@@ -381,20 +351,15 @@ def build_shofel(force_rebuild: bool = False) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Jibo Detection
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def detect_jibo_rcm() -> bool:
|
def detect_jibo_rcm() -> bool:
|
||||||
"""Detect if Jibo is connected in RCM mode"""
|
"""Detect if Jibo is connected in RCM mode"""
|
||||||
print_info("Looking for Jibo in RCM mode (NVIDIA APX device)...")
|
print_info("Looking for Jibo in RCM mode (NVIDIA APX device)...")
|
||||||
|
|
||||||
if platform.system() == "Linux":
|
if platform.system() == "Linux":
|
||||||
# Try lsusb first
|
|
||||||
if shutil.which("lsusb"):
|
if shutil.which("lsusb"):
|
||||||
try:
|
try:
|
||||||
result = run_command(["lsusb"], capture_output=True)
|
result = run_command(["lsusb"], capture_output=True)
|
||||||
# Jibo uses 0955:7740 (NVIDIA APX)
|
|
||||||
if "0955:7740" in result.stdout:
|
if "0955:7740" in result.stdout:
|
||||||
print_success("Found Jibo in RCM mode!")
|
print_success("Found Jibo in RCM mode!")
|
||||||
return True
|
return True
|
||||||
@@ -408,7 +373,6 @@ def detect_jibo_rcm() -> bool:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_error(f"lsusb failed: {e}")
|
print_error(f"lsusb failed: {e}")
|
||||||
|
|
||||||
# Fallback: check /sys/bus/usb/devices
|
|
||||||
try:
|
try:
|
||||||
usb_devices = Path("/sys/bus/usb/devices")
|
usb_devices = Path("/sys/bus/usb/devices")
|
||||||
if usb_devices.exists():
|
if usb_devices.exists():
|
||||||
@@ -424,16 +388,13 @@ def detect_jibo_rcm() -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Final fallback: assume user will connect it
|
|
||||||
print_warning("Cannot detect USB devices. Please ensure Jibo is in RCM mode.")
|
print_warning("Cannot detect USB devices. Please ensure Jibo is in RCM mode.")
|
||||||
print_info("The tool will attempt to connect anyway.")
|
print_info("The tool will attempt to connect anyway.")
|
||||||
return True # Let shofel try
|
return True # Let shofel try
|
||||||
|
|
||||||
elif platform.system() == "Windows":
|
elif platform.system() == "Windows":
|
||||||
# On Windows, we need to use different methods
|
|
||||||
print_warning("Windows USB detection - please ensure Zadig drivers are installed")
|
print_warning("Windows USB detection - please ensure Zadig drivers are installed")
|
||||||
print_info("Run Zadig and install WinUSB driver for 'APX' device")
|
print_info("Run Zadig and install WinUSB driver for 'APX' device")
|
||||||
# Try to proceed anyway, shofel will detect it
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -459,36 +420,25 @@ def wait_for_jibo_rcm(timeout: int = 60) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# GPT Partition Parsing
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def parse_gpt_partitions(dump_path: Path) -> List[PartitionInfo]:
|
def parse_gpt_partitions(dump_path: Path) -> List[PartitionInfo]:
|
||||||
"""Parse GPT partition table from dump file"""
|
"""Parse GPT partition table from dump file"""
|
||||||
partitions = []
|
partitions = []
|
||||||
|
|
||||||
with open(dump_path, "rb") as f:
|
with open(dump_path, "rb") as f:
|
||||||
# Read MBR (sector 0) - skip it
|
|
||||||
f.seek(512)
|
f.seek(512)
|
||||||
|
|
||||||
# Read GPT header (sector 1)
|
|
||||||
gpt_header = f.read(512)
|
gpt_header = f.read(512)
|
||||||
|
|
||||||
# Check GPT signature
|
|
||||||
signature = gpt_header[:8]
|
signature = gpt_header[:8]
|
||||||
if signature != b'EFI PART':
|
if signature != b'EFI PART':
|
||||||
print_warning("GPT signature not found, trying fdisk parsing...")
|
print_warning("GPT signature not found, trying fdisk parsing...")
|
||||||
return parse_partitions_fdisk(dump_path)
|
return parse_partitions_fdisk(dump_path)
|
||||||
|
|
||||||
# Parse GPT header
|
|
||||||
# Offset 72: Partition entries start LBA (8 bytes)
|
|
||||||
# Offset 80: Number of partition entries (4 bytes)
|
|
||||||
# Offset 84: Size of partition entry (4 bytes)
|
|
||||||
partition_entries_lba = struct.unpack("<Q", gpt_header[72:80])[0]
|
partition_entries_lba = struct.unpack("<Q", gpt_header[72:80])[0]
|
||||||
num_entries = struct.unpack("<I", gpt_header[80:84])[0]
|
num_entries = struct.unpack("<I", gpt_header[80:84])[0]
|
||||||
entry_size = struct.unpack("<I", gpt_header[84:88])[0]
|
entry_size = struct.unpack("<I", gpt_header[84:88])[0]
|
||||||
|
|
||||||
# Seek to partition entries
|
|
||||||
f.seek(partition_entries_lba * 512)
|
f.seek(partition_entries_lba * 512)
|
||||||
|
|
||||||
for i in range(num_entries):
|
for i in range(num_entries):
|
||||||
@@ -496,11 +446,6 @@ def parse_gpt_partitions(dump_path: Path) -> List[PartitionInfo]:
|
|||||||
if len(entry) < 128:
|
if len(entry) < 128:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Parse partition entry
|
|
||||||
# Offset 0: Partition type GUID (16 bytes)
|
|
||||||
# Offset 32: First LBA (8 bytes)
|
|
||||||
# Offset 40: Last LBA (8 bytes)
|
|
||||||
# Offset 56: Partition name (72 bytes, UTF-16LE)
|
|
||||||
|
|
||||||
type_guid = entry[:16]
|
type_guid = entry[:16]
|
||||||
if type_guid == b'\x00' * 16:
|
if type_guid == b'\x00' * 16:
|
||||||
@@ -509,7 +454,6 @@ def parse_gpt_partitions(dump_path: Path) -> List[PartitionInfo]:
|
|||||||
first_lba = struct.unpack("<Q", entry[32:40])[0]
|
first_lba = struct.unpack("<Q", entry[32:40])[0]
|
||||||
last_lba = struct.unpack("<Q", entry[40:48])[0]
|
last_lba = struct.unpack("<Q", entry[40:48])[0]
|
||||||
|
|
||||||
# Parse name (UTF-16LE, null-terminated)
|
|
||||||
name_bytes = entry[56:128]
|
name_bytes = entry[56:128]
|
||||||
try:
|
try:
|
||||||
name = name_bytes.decode('utf-16le').rstrip('\x00')
|
name = name_bytes.decode('utf-16le').rstrip('\x00')
|
||||||
@@ -538,14 +482,11 @@ def parse_partitions_fdisk(dump_path: Path) -> List[PartitionInfo]:
|
|||||||
check=False
|
check=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse fdisk output
|
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
# Look for lines like: dump.bin1 34 2048033 2048000 1000M Microsoft basic data
|
|
||||||
if dump_path.name in line and not line.startswith("Disk"):
|
if dump_path.name in line and not line.startswith("Disk"):
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 4:
|
if len(parts) >= 4:
|
||||||
try:
|
try:
|
||||||
# Extract partition number from name (e.g., dump.bin5 -> 5)
|
|
||||||
part_name = parts[0]
|
part_name = parts[0]
|
||||||
part_num = int(''.join(c for c in part_name if c.isdigit()) or '0')
|
part_num = int(''.join(c for c in part_name if c.isdigit()) or '0')
|
||||||
|
|
||||||
@@ -570,15 +511,12 @@ def parse_partitions_fdisk(dump_path: Path) -> List[PartitionInfo]:
|
|||||||
|
|
||||||
def find_var_partition(partitions: List[PartitionInfo]) -> Optional[PartitionInfo]:
|
def find_var_partition(partitions: List[PartitionInfo]) -> Optional[PartitionInfo]:
|
||||||
"""Find the /var partition (partition 5, ~500MB)"""
|
"""Find the /var partition (partition 5, ~500MB)"""
|
||||||
# The var partition is typically partition 5 with ~500MB size
|
|
||||||
for part in partitions:
|
for part in partitions:
|
||||||
if part.number == 5:
|
if part.number == 5:
|
||||||
# Verify it's roughly the right size (450-550 MB)
|
|
||||||
size_mb = (part.size_sectors * EMMC_SECTOR_SIZE) / (1024 * 1024)
|
size_mb = (part.size_sectors * EMMC_SECTOR_SIZE) / (1024 * 1024)
|
||||||
if 400 < size_mb < 600:
|
if 400 < size_mb < 600:
|
||||||
return part
|
return part
|
||||||
|
|
||||||
# Fallback: look for any ~500MB partition
|
|
||||||
for part in partitions:
|
for part in partitions:
|
||||||
size_mb = (part.size_sectors * EMMC_SECTOR_SIZE) / (1024 * 1024)
|
size_mb = (part.size_sectors * EMMC_SECTOR_SIZE) / (1024 * 1024)
|
||||||
if 450 < size_mb < 550:
|
if 450 < size_mb < 550:
|
||||||
@@ -588,9 +526,6 @@ def find_var_partition(partitions: List[PartitionInfo]) -> Optional[PartitionInf
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Partition Extraction and Modification
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def extract_partition(dump_path: Path, partition: PartitionInfo, output_path: Path) -> bool:
|
def extract_partition(dump_path: Path, partition: PartitionInfo, output_path: Path) -> bool:
|
||||||
"""Extract a partition from the dump"""
|
"""Extract a partition from the dump"""
|
||||||
@@ -626,8 +561,6 @@ def modify_mode_json_direct(partition_path: Path) -> bool:
|
|||||||
with open(partition_path, "r+b") as f:
|
with open(partition_path, "r+b") as f:
|
||||||
data = bytearray(f.read())
|
data = bytearray(f.read())
|
||||||
|
|
||||||
# Best-effort raw replacement.
|
|
||||||
# IMPORTANT: never change image length and never shift bytes; only overwrite in-place.
|
|
||||||
json_patterns = [
|
json_patterns = [
|
||||||
(b'{"mode":"normal"}', b'{"mode":"int-developer"}'),
|
(b'{"mode":"normal"}', b'{"mode":"int-developer"}'),
|
||||||
(b'{"mode": "normal"}', b'{"mode": "int-developer"}'),
|
(b'{"mode": "normal"}', b'{"mode": "int-developer"}'),
|
||||||
@@ -654,7 +587,6 @@ def modify_mode_json_direct(partition_path: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
region_len = len(new_json)
|
region_len = len(new_json)
|
||||||
# Overwrite the JSON plus the padding region; do NOT shift bytes.
|
|
||||||
data[offset:offset + region_len] = new_json
|
data[offset:offset + region_len] = new_json
|
||||||
|
|
||||||
f.seek(0)
|
f.seek(0)
|
||||||
@@ -680,18 +612,15 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
mount_point.mkdir(parents=True, exist_ok=True)
|
mount_point.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Mount the partition
|
|
||||||
print_info(f"Mounting partition at {mount_point}...")
|
print_info(f"Mounting partition at {mount_point}...")
|
||||||
run_command(
|
run_command(
|
||||||
["mount", "-o", "loop", str(partition_path), str(mount_point)],
|
["mount", "-o", "loop", str(partition_path), str(mount_point)],
|
||||||
sudo=True
|
sudo=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find and modify mode.json
|
|
||||||
mode_json_path = mount_point / "jibo" / "mode.json"
|
mode_json_path = mount_point / "jibo" / "mode.json"
|
||||||
|
|
||||||
if not mode_json_path.exists():
|
if not mode_json_path.exists():
|
||||||
# Try alternative paths
|
|
||||||
for alt_path in [
|
for alt_path in [
|
||||||
mount_point / "mode.json",
|
mount_point / "mode.json",
|
||||||
mount_point / "etc" / "jibo" / "mode.json",
|
mount_point / "etc" / "jibo" / "mode.json",
|
||||||
@@ -703,7 +632,6 @@ 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}")
|
||||||
|
|
||||||
# Capture original permissions/ownership so we can restore after copy-write
|
|
||||||
perm = None
|
perm = None
|
||||||
uid = None
|
uid = None
|
||||||
gid = None
|
gid = None
|
||||||
@@ -720,7 +648,6 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Save a raw backup copy of mode.json for debugging/recovery
|
|
||||||
try:
|
try:
|
||||||
backup_text = run_command(
|
backup_text = run_command(
|
||||||
["cat", str(mode_json_path)],
|
["cat", str(mode_json_path)],
|
||||||
@@ -732,7 +659,6 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Read current content (prefer sudo cat so permissions don't bite us)
|
|
||||||
try:
|
try:
|
||||||
mode_text = run_command(
|
mode_text = run_command(
|
||||||
["cat", str(mode_json_path)],
|
["cat", str(mode_json_path)],
|
||||||
@@ -742,23 +668,19 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
).stdout
|
).stdout
|
||||||
content = json.loads(mode_text)
|
content = json.loads(mode_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback: direct open (works if script is run with sudo)
|
|
||||||
with open(mode_json_path, "r") as f:
|
with open(mode_json_path, "r") as f:
|
||||||
content = json.load(f)
|
content = json.load(f)
|
||||||
|
|
||||||
print_info(f"Current mode: {content.get('mode', 'unknown')}")
|
print_info(f"Current mode: {content.get('mode', 'unknown')}")
|
||||||
|
|
||||||
# Modify
|
|
||||||
content["mode"] = "int-developer"
|
content["mode"] = "int-developer"
|
||||||
|
|
||||||
# Write back (need sudo)
|
|
||||||
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(["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:
|
if perm is not None:
|
||||||
run_command(["chmod", perm, str(mode_json_path)], sudo=True, check=False)
|
run_command(["chmod", perm, str(mode_json_path)], sudo=True, check=False)
|
||||||
if uid is not None and gid is not None:
|
if uid is not None and gid is not None:
|
||||||
@@ -784,7 +706,6 @@ def modify_partition_mounted(partition_path: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Always unmount
|
|
||||||
try:
|
try:
|
||||||
run_command(["umount", str(mount_point)], sudo=True, check=False)
|
run_command(["umount", str(mount_point)], sudo=True, check=False)
|
||||||
except:
|
except:
|
||||||
@@ -811,14 +732,12 @@ def modify_partition_debugfs(partition_path: Path) -> bool:
|
|||||||
|
|
||||||
print_info("Attempting mode.json edit via debugfs (no mount)...")
|
print_info("Attempting mode.json edit via debugfs (no mount)...")
|
||||||
|
|
||||||
# Potential locations inside /var
|
|
||||||
candidate_paths = [
|
candidate_paths = [
|
||||||
"/jibo/mode.json",
|
"/jibo/mode.json",
|
||||||
"/mode.json",
|
"/mode.json",
|
||||||
"/etc/jibo/mode.json",
|
"/etc/jibo/mode.json",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Find which path exists by trying to cat it
|
|
||||||
existing_path: Optional[str] = None
|
existing_path: Optional[str] = None
|
||||||
original_text: Optional[str] = None
|
original_text: Optional[str] = None
|
||||||
for p in candidate_paths:
|
for p in candidate_paths:
|
||||||
@@ -828,7 +747,6 @@ def modify_partition_debugfs(partition_path: Path) -> bool:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
# debugfs prints to stdout for cat
|
|
||||||
if res.stdout and "File not found" not in res.stdout:
|
if res.stdout and "File not found" not in res.stdout:
|
||||||
existing_path = p
|
existing_path = p
|
||||||
original_text = res.stdout
|
original_text = res.stdout
|
||||||
@@ -840,7 +758,6 @@ def modify_partition_debugfs(partition_path: Path) -> bool:
|
|||||||
print_warning("debugfs could not locate mode.json inside the image")
|
print_warning("debugfs could not locate mode.json inside the image")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Save backup
|
|
||||||
try:
|
try:
|
||||||
(WORK_DIR / "mode.json.original").write_text(original_text)
|
(WORK_DIR / "mode.json.original").write_text(original_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -858,9 +775,6 @@ def modify_partition_debugfs(partition_path: Path) -> bool:
|
|||||||
temp_json = WORK_DIR / "mode_temp.json"
|
temp_json = WORK_DIR / "mode_temp.json"
|
||||||
temp_json.write_text(new_text)
|
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:
|
try:
|
||||||
run_command([debugfs, "-w", "-R", f"rm {existing_path}", str(partition_path)], check=False, capture_output=True)
|
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)
|
run_command([debugfs, "-w", "-R", f"write {str(temp_json)} {existing_path}", str(partition_path)], capture_output=True)
|
||||||
@@ -881,17 +795,14 @@ 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")
|
||||||
|
|
||||||
# On Linux, prefer mounting: it's the only truly safe way to update a file in an ext filesystem.
|
|
||||||
if platform.system() == "Linux":
|
if platform.system() == "Linux":
|
||||||
if modify_partition_mounted(partition_path):
|
if modify_partition_mounted(partition_path):
|
||||||
return True
|
return True
|
||||||
print_warning("Mount-based edit failed; falling back to raw in-place patch")
|
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):
|
if modify_partition_debugfs(partition_path):
|
||||||
return True
|
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
|
||||||
|
|
||||||
@@ -969,7 +880,6 @@ def compute_changed_sector_ranges(original_path: Path, modified_path: Path, sect
|
|||||||
base_sector += len(b1) // sector_size
|
base_sector += len(b1) // sector_size
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Chunk differs; identify sector-level diffs within this chunk
|
|
||||||
sectors_in_chunk = min(len(b1), len(b2)) // sector_size
|
sectors_in_chunk = min(len(b1), len(b2)) // sector_size
|
||||||
for i in range(sectors_in_chunk):
|
for i in range(sectors_in_chunk):
|
||||||
s1 = b1[i * sector_size:(i + 1) * sector_size]
|
s1 = b1[i * sector_size:(i + 1) * sector_size]
|
||||||
@@ -1025,9 +935,6 @@ def write_partition_patch_to_emmc(original_path: Path, modified_path: Path, base
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# eMMC Operations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_shofel_path() -> Path:
|
def get_shofel_path() -> Path:
|
||||||
"""Get the path to shofel2_t124 executable"""
|
"""Get the path to shofel2_t124 executable"""
|
||||||
@@ -1058,7 +965,6 @@ def dump_emmc(output_path: Path, start_sector: int = 0, num_sectors: int = EMMC_
|
|||||||
str(output_path)
|
str(output_path)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Run with sudo on Linux
|
|
||||||
if platform.system() == "Linux":
|
if platform.system() == "Linux":
|
||||||
cmd = ["sudo"] + cmd
|
cmd = ["sudo"] + cmd
|
||||||
|
|
||||||
@@ -1136,7 +1042,6 @@ def verify_write(partition_path: Path, start_sector: int, num_sectors: int) -> b
|
|||||||
|
|
||||||
subprocess.run(cmd, cwd=SHOFEL_DIR, check=True)
|
subprocess.run(cmd, cwd=SHOFEL_DIR, check=True)
|
||||||
|
|
||||||
# Compare hashes
|
|
||||||
with open(partition_path, "rb") as f:
|
with open(partition_path, "rb") as f:
|
||||||
original_hash = hashlib.md5(f.read()).hexdigest()
|
original_hash = hashlib.md5(f.read()).hexdigest()
|
||||||
|
|
||||||
@@ -1157,22 +1062,17 @@ def verify_write(partition_path: Path, start_sector: int, num_sectors: int) -> b
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Main Workflow
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def run_full_mod(args) -> bool:
|
def run_full_mod(args) -> bool:
|
||||||
"""Run the complete modding workflow"""
|
"""Run the complete modding workflow"""
|
||||||
print_banner()
|
print_banner()
|
||||||
|
|
||||||
# Check system
|
|
||||||
sys_info = get_system_info()
|
sys_info = get_system_info()
|
||||||
print_info(f"System: {sys_info['os']} ({sys_info['arch']})")
|
print_info(f"System: {sys_info['os']} ({sys_info['arch']})")
|
||||||
|
|
||||||
if sys_info['is_wsl']:
|
if sys_info['is_wsl']:
|
||||||
print_info("Running in WSL - USB passthrough may require additional setup")
|
print_info("Running in WSL - USB passthrough may require additional setup")
|
||||||
|
|
||||||
# Check dependencies
|
|
||||||
print_step(0, 6, "Checking dependencies")
|
print_step(0, 6, "Checking dependencies")
|
||||||
|
|
||||||
if sys_info['os'] == "Linux":
|
if sys_info['os'] == "Linux":
|
||||||
@@ -1190,25 +1090,20 @@ def run_full_mod(args) -> bool:
|
|||||||
|
|
||||||
print_success("All required dependencies found!")
|
print_success("All required dependencies found!")
|
||||||
|
|
||||||
# Create work directory
|
|
||||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Build Shofel
|
|
||||||
if not build_shofel(force_rebuild=args.rebuild_shofel):
|
if not build_shofel(force_rebuild=args.rebuild_shofel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Detect or wait for Jibo
|
|
||||||
if not args.skip_detection:
|
if not args.skip_detection:
|
||||||
if not detect_jibo_rcm():
|
if not detect_jibo_rcm():
|
||||||
if not wait_for_jibo_rcm(timeout=120):
|
if not wait_for_jibo_rcm(timeout=120):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Paths
|
|
||||||
dump_path = WORK_DIR / "jibo_full_dump.bin"
|
dump_path = WORK_DIR / "jibo_full_dump.bin"
|
||||||
var_partition_path = WORK_DIR / "var_partition.bin"
|
var_partition_path = WORK_DIR / "var_partition.bin"
|
||||||
backup_var_path = WORK_DIR / "var_partition_backup.bin"
|
backup_var_path = WORK_DIR / "var_partition_backup.bin"
|
||||||
|
|
||||||
# Dump eMMC (or use existing dump)
|
|
||||||
if args.dump_path:
|
if args.dump_path:
|
||||||
dump_path = Path(args.dump_path)
|
dump_path = Path(args.dump_path)
|
||||||
if not dump_path.exists():
|
if not dump_path.exists():
|
||||||
@@ -1222,7 +1117,6 @@ def run_full_mod(args) -> bool:
|
|||||||
if not dump_emmc(dump_path):
|
if not dump_emmc(dump_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Parse partitions
|
|
||||||
print_step(3, 6, "Analyzing partition table")
|
print_step(3, 6, "Analyzing partition table")
|
||||||
|
|
||||||
partitions = parse_gpt_partitions(dump_path)
|
partitions = parse_gpt_partitions(dump_path)
|
||||||
@@ -1235,7 +1129,6 @@ def run_full_mod(args) -> bool:
|
|||||||
size_mb = (part.size_sectors * EMMC_SECTOR_SIZE) / (1024 * 1024)
|
size_mb = (part.size_sectors * EMMC_SECTOR_SIZE) / (1024 * 1024)
|
||||||
print(f" {part.number}: sectors {part.start_sector}-{part.end_sector} ({size_mb:.1f} MB) - {part.name}")
|
print(f" {part.number}: sectors {part.start_sector}-{part.end_sector} ({size_mb:.1f} MB) - {part.name}")
|
||||||
|
|
||||||
# Find var partition
|
|
||||||
var_partition = find_var_partition(partitions)
|
var_partition = find_var_partition(partitions)
|
||||||
if not var_partition:
|
if not var_partition:
|
||||||
print_error("Could not identify /var partition")
|
print_error("Could not identify /var partition")
|
||||||
@@ -1243,35 +1136,28 @@ def run_full_mod(args) -> bool:
|
|||||||
|
|
||||||
print_success(f"Identified /var partition: partition {var_partition.number}")
|
print_success(f"Identified /var partition: partition {var_partition.number}")
|
||||||
|
|
||||||
# Extract var partition
|
|
||||||
if not extract_partition(dump_path, var_partition, var_partition_path):
|
if not extract_partition(dump_path, var_partition, var_partition_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create backup
|
|
||||||
shutil.copy(var_partition_path, backup_var_path)
|
shutil.copy(var_partition_path, backup_var_path)
|
||||||
print_info(f"Backup created: {backup_var_path}")
|
print_info(f"Backup created: {backup_var_path}")
|
||||||
|
|
||||||
# Modify partition
|
|
||||||
if not modify_var_partition(var_partition_path):
|
if not modify_var_partition(var_partition_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if Jibo still connected (may need to re-enter RCM)
|
|
||||||
if not args.skip_detection:
|
if not args.skip_detection:
|
||||||
print_info("Please ensure Jibo is still in RCM mode")
|
print_info("Please ensure Jibo is still in RCM mode")
|
||||||
print_info("If Jibo rebooted, re-enter RCM mode now")
|
print_info("If Jibo rebooted, re-enter RCM mode now")
|
||||||
if not wait_for_jibo_rcm(timeout=60):
|
if not wait_for_jibo_rcm(timeout=60):
|
||||||
print_warning("Continuing anyway...")
|
print_warning("Continuing anyway...")
|
||||||
|
|
||||||
# Write modified partition
|
|
||||||
if not write_partition_to_emmc(var_partition_path, var_partition.start_sector):
|
if not write_partition_to_emmc(var_partition_path, var_partition.start_sector):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Verify
|
|
||||||
if args.verify:
|
if args.verify:
|
||||||
if not verify_write(var_partition_path, var_partition.start_sector, var_partition.size_sectors):
|
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_warning("Verification failed, but write may still be successful")
|
||||||
|
|
||||||
# Done!
|
|
||||||
print(f"""
|
print(f"""
|
||||||
{Colors.GREEN}╔═══════════════════════════════════════════════════════════════════╗
|
{Colors.GREEN}╔═══════════════════════════════════════════════════════════════════╗
|
||||||
║ {Colors.BOLD}MODDING COMPLETE!{Colors.RESET}{Colors.GREEN} ║
|
║ {Colors.BOLD}MODDING COMPLETE!{Colors.RESET}{Colors.GREEN} ║
|
||||||
@@ -1302,11 +1188,9 @@ def run_dump_only(args) -> bool:
|
|||||||
|
|
||||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Build Shofel
|
|
||||||
if not build_shofel(force_rebuild=args.rebuild_shofel):
|
if not build_shofel(force_rebuild=args.rebuild_shofel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Wait for Jibo
|
|
||||||
if not args.skip_detection:
|
if not args.skip_detection:
|
||||||
if not wait_for_jibo_rcm(timeout=120):
|
if not wait_for_jibo_rcm(timeout=120):
|
||||||
return False
|
return False
|
||||||
@@ -1325,11 +1209,9 @@ def run_write_only(args) -> bool:
|
|||||||
print_error(f"Partition file not found: {partition_path}")
|
print_error(f"Partition file not found: {partition_path}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Build Shofel if needed
|
|
||||||
if not build_shofel():
|
if not build_shofel():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Wait for Jibo
|
|
||||||
if not args.skip_detection:
|
if not args.skip_detection:
|
||||||
if not wait_for_jibo_rcm(timeout=120):
|
if not wait_for_jibo_rcm(timeout=120):
|
||||||
return False
|
return False
|
||||||
@@ -1344,16 +1226,13 @@ def run_mode_json_only(args) -> bool:
|
|||||||
|
|
||||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Build Shofel
|
|
||||||
if not build_shofel(force_rebuild=args.rebuild_shofel):
|
if not build_shofel(force_rebuild=args.rebuild_shofel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Wait for Jibo
|
|
||||||
if not args.skip_detection:
|
if not args.skip_detection:
|
||||||
if not wait_for_jibo_rcm(timeout=120):
|
if not wait_for_jibo_rcm(timeout=120):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Dump GPT / partition table (small read)
|
|
||||||
gpt_path = WORK_DIR / "gpt_dump.bin"
|
gpt_path = WORK_DIR / "gpt_dump.bin"
|
||||||
gpt_sectors = 4096 # 2MB; safely covers typical GPT entry area
|
gpt_sectors = 4096 # 2MB; safely covers typical GPT entry area
|
||||||
print_info(f"Dumping GPT header/table ({gpt_sectors} sectors)...")
|
print_info(f"Dumping GPT header/table ({gpt_sectors} sectors)...")
|
||||||
@@ -1375,7 +1254,6 @@ def run_mode_json_only(args) -> bool:
|
|||||||
f"(start=0x{var_partition.start_sector:x}, sectors={var_partition.size_sectors})"
|
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"
|
original_var_path = WORK_DIR / "var_partition_original.bin"
|
||||||
var_partition_path = WORK_DIR / "var_partition.bin"
|
var_partition_path = WORK_DIR / "var_partition.bin"
|
||||||
backup_var_path = WORK_DIR / "var_partition_backup.bin"
|
backup_var_path = WORK_DIR / "var_partition_backup.bin"
|
||||||
@@ -1388,17 +1266,14 @@ def run_mode_json_only(args) -> bool:
|
|||||||
shutil.copy(original_var_path, backup_var_path)
|
shutil.copy(original_var_path, backup_var_path)
|
||||||
print_info(f"Backup created: {backup_var_path}")
|
print_info(f"Backup created: {backup_var_path}")
|
||||||
|
|
||||||
# Modify mode.json inside /var
|
|
||||||
if not modify_var_partition(var_partition_path):
|
if not modify_var_partition(var_partition_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Re-check connectivity (optional)
|
|
||||||
if not args.skip_detection:
|
if not args.skip_detection:
|
||||||
print_info("Please ensure Jibo is still in RCM mode")
|
print_info("Please ensure Jibo is still in RCM mode")
|
||||||
if not wait_for_jibo_rcm(timeout=60):
|
if not wait_for_jibo_rcm(timeout=60):
|
||||||
print_warning("Continuing anyway...")
|
print_warning("Continuing anyway...")
|
||||||
|
|
||||||
# Write back: patch by default, full write if requested
|
|
||||||
if args.full_var_write:
|
if args.full_var_write:
|
||||||
print_info("Writing full /var partition back to device...")
|
print_info("Writing full /var partition back to device...")
|
||||||
if not write_partition_to_emmc(var_partition_path, var_partition.start_sector):
|
if not write_partition_to_emmc(var_partition_path, var_partition.start_sector):
|
||||||
@@ -1408,7 +1283,6 @@ def run_mode_json_only(args) -> bool:
|
|||||||
if not write_partition_patch_to_emmc(original_var_path, var_partition_path, var_partition.start_sector):
|
if not write_partition_patch_to_emmc(original_var_path, var_partition_path, var_partition.start_sector):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Verify (reads back full /var; optional)
|
|
||||||
if args.verify:
|
if args.verify:
|
||||||
if not verify_write(var_partition_path, var_partition.start_sector, var_partition.size_sectors):
|
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_warning("Verification failed, but write may still be successful")
|
||||||
@@ -1419,9 +1293,6 @@ def run_mode_json_only(args) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# CLI
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -1436,7 +1307,6 @@ Examples:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Operation modes
|
|
||||||
mode_group = parser.add_mutually_exclusive_group()
|
mode_group = parser.add_mutually_exclusive_group()
|
||||||
mode_group.add_argument("--dump-only", action="store_true",
|
mode_group.add_argument("--dump-only", action="store_true",
|
||||||
help="Only dump the eMMC without modifying")
|
help="Only dump the eMMC without modifying")
|
||||||
@@ -1445,7 +1315,6 @@ Examples:
|
|||||||
mode_group.add_argument("--mode-json-only", action="store_true",
|
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")
|
help="Fast mode: dump GPT + /var only, patch /var/jibo/mode.json, write back minimal changes")
|
||||||
|
|
||||||
# Options
|
|
||||||
parser.add_argument("--dump-path", metavar="FILE",
|
parser.add_argument("--dump-path", metavar="FILE",
|
||||||
help="Use existing dump file instead of dumping")
|
help="Use existing dump file instead of dumping")
|
||||||
parser.add_argument("--output", "-o", metavar="FILE",
|
parser.add_argument("--output", "-o", metavar="FILE",
|
||||||
@@ -1467,11 +1336,9 @@ Examples:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Validate arguments
|
|
||||||
if args.write_partition and not args.start_sector:
|
if args.write_partition and not args.start_sector:
|
||||||
parser.error("--write-partition requires --start-sector")
|
parser.error("--write-partition requires --start-sector")
|
||||||
|
|
||||||
# Run appropriate mode
|
|
||||||
try:
|
try:
|
||||||
if args.dump_only:
|
if args.dump_only:
|
||||||
success = run_dump_only(args)
|
success = run_dump_only(args)
|
||||||
|
|||||||
316
jibo_updater.py
316
jibo_updater.py
@@ -31,9 +31,14 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Optional
|
from typing import Iterable, Optional
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import http.server
|
||||||
|
import socketserver
|
||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
@@ -43,6 +48,10 @@ WORK_DIR = SCRIPT_DIR / "jibo_work"
|
|||||||
UPDATES_DIR = WORK_DIR / "updates"
|
UPDATES_DIR = WORK_DIR / "updates"
|
||||||
STATE_FILE_DEFAULT = WORK_DIR / "update_state.json"
|
STATE_FILE_DEFAULT = WORK_DIR / "update_state.json"
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
|
DEFAULT_UPDATER_RELEASES_API = "https://kevinblog.sytes.net/Code/api/v1/repos/Kevin/JiboUpdater/releases"
|
||||||
|
|
||||||
DEFAULT_RELEASES_API = "https://kevinblog.sytes.net/Code/api/v1/repos/Kevin/JiboOs/releases"
|
DEFAULT_RELEASES_API = "https://kevinblog.sytes.net/Code/api/v1/repos/Kevin/JiboOs/releases"
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +124,65 @@ def http_get_json(url: str, timeout: int = 20) -> object:
|
|||||||
return json.loads(data.decode("utf-8", errors="replace"))
|
return json.loads(data.decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_updater_version(releases_api: str, current_version: str) -> tuple[Optional[str], bool]:
|
||||||
|
"""Return (latest_tag, is_newer) comparing semantic-ish tags.
|
||||||
|
|
||||||
|
If the check fails, returns (None, False).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = http_get_json(releases_api)
|
||||||
|
except Exception:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
if not isinstance(raw, list) or not raw:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
tags: list[str] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
tags.append(str(item.get("tag_name", "")))
|
||||||
|
|
||||||
|
tags = [t for t in tags if t]
|
||||||
|
if not tags:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
tags.sort(key=_version_tuple, reverse=True)
|
||||||
|
latest = tags[0]
|
||||||
|
try:
|
||||||
|
is_newer = _version_tuple(latest) > _version_tuple(current_version)
|
||||||
|
except Exception:
|
||||||
|
is_newer = False
|
||||||
|
return latest, is_newer
|
||||||
|
|
||||||
|
|
||||||
|
class _Spinner:
|
||||||
|
def __init__(self, message: str = ""):
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
def _spin():
|
||||||
|
chars = "|/-\\"
|
||||||
|
i = 0
|
||||||
|
while not self._stop.is_set():
|
||||||
|
sys.stdout.write(f"\r{self.message} {chars[i % len(chars)]}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
i += 1
|
||||||
|
time.sleep(0.12)
|
||||||
|
sys.stdout.write("\r" + " " * (len(self.message) + 4) + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=_spin, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=1)
|
||||||
|
|
||||||
|
|
||||||
_VERSION_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?")
|
_VERSION_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?")
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +221,6 @@ def get_latest_release(releases_api: str, allow_prerelease: bool) -> Release:
|
|||||||
if not releases:
|
if not releases:
|
||||||
raise RuntimeError("No releases found (after prerelease filtering)")
|
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)
|
releases.sort(key=lambda r: _version_tuple(r.tag_name), reverse=True)
|
||||||
return releases[0]
|
return releases[0]
|
||||||
|
|
||||||
@@ -171,11 +238,9 @@ def normalize_download_url(download_url: str, base_url: str) -> str:
|
|||||||
base = urllib.parse.urlparse(base_url)
|
base = urllib.parse.urlparse(base_url)
|
||||||
dl = urllib.parse.urlparse(download_url)
|
dl = urllib.parse.urlparse(download_url)
|
||||||
|
|
||||||
# If already matches, keep as-is.
|
|
||||||
if dl.scheme == base.scheme and dl.netloc == base.netloc:
|
if dl.scheme == base.scheme and dl.netloc == base.netloc:
|
||||||
return download_url
|
return download_url
|
||||||
|
|
||||||
# If download URL is missing components or has a different host, rewrite it.
|
|
||||||
return urllib.parse.urlunparse(
|
return urllib.parse.urlunparse(
|
||||||
(base.scheme, base.netloc, dl.path, dl.params, dl.query, dl.fragment)
|
(base.scheme, base.netloc, dl.path, dl.params, dl.query, dl.fragment)
|
||||||
)
|
)
|
||||||
@@ -261,7 +326,6 @@ def _extract(archive: Path, extract_dir: Path, *, force: bool = False) -> Path:
|
|||||||
member_path = extract_dir / member.name
|
member_path = extract_dir / member.name
|
||||||
if not _is_within(extract_dir, member_path):
|
if not _is_within(extract_dir, member_path):
|
||||||
raise RuntimeError(f"Unsafe path in tar archive: {member.name}")
|
raise RuntimeError(f"Unsafe path in tar archive: {member.name}")
|
||||||
# Python 3.14 changes tar default filtering behavior; be explicit.
|
|
||||||
try:
|
try:
|
||||||
tf.extractall(extract_dir, filter="data")
|
tf.extractall(extract_dir, filter="data")
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@@ -291,7 +355,6 @@ def _score_build_dir(path: Path) -> int:
|
|||||||
for name, weight in (("etc", 5), ("opt", 5), ("var", 2), ("usr", 2), ("lib", 1), ("bin", 1)):
|
for name, weight in (("etc", 5), ("opt", 5), ("var", 2), ("usr", 2), ("lib", 1), ("bin", 1)):
|
||||||
if (path / name).exists():
|
if (path / name).exists():
|
||||||
score += weight
|
score += weight
|
||||||
# Prefer build dirs that are under a version folder like V3.1/build
|
|
||||||
parts = {p.lower() for p in path.parts}
|
parts = {p.lower() for p in path.parts}
|
||||||
if any(re.fullmatch(r"v\d+(?:\.\d+)*", p, flags=re.IGNORECASE) for p in parts):
|
if any(re.fullmatch(r"v\d+(?:\.\d+)*", p, flags=re.IGNORECASE) for p in parts):
|
||||||
score += 2
|
score += 2
|
||||||
@@ -362,7 +425,6 @@ def ssh_exec(client: paramiko.SSHClient, command: str, timeout: int = 60) -> tup
|
|||||||
|
|
||||||
|
|
||||||
def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
|
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]
|
parts = [p for p in remote_dir.split("/") if p]
|
||||||
cur = "/"
|
cur = "/"
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@@ -394,6 +456,10 @@ def upload_tree(
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
continue
|
continue
|
||||||
ensure_remote_dir(sftp, remote_path)
|
ensure_remote_dir(sftp, remote_path)
|
||||||
|
try:
|
||||||
|
sftp.chmod(remote_path, 0o777)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if p.is_symlink():
|
if p.is_symlink():
|
||||||
@@ -401,19 +467,20 @@ def upload_tree(
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
sent += 1
|
sent += 1
|
||||||
continue
|
continue
|
||||||
# Ensure parent exists
|
|
||||||
ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
||||||
try:
|
try:
|
||||||
# Remove if exists
|
|
||||||
try:
|
try:
|
||||||
sftp.remove(remote_path)
|
sftp.remove(remote_path)
|
||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
sftp.symlink(target, remote_path)
|
sftp.symlink(target, remote_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback: dereference and upload file content
|
|
||||||
real_path = p.resolve()
|
real_path = p.resolve()
|
||||||
sftp.put(str(real_path), remote_path)
|
sftp.put(str(real_path), remote_path)
|
||||||
|
try:
|
||||||
|
sftp.chmod(remote_path, 0o777)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
sent += 1
|
sent += 1
|
||||||
if sent % 200 == 0:
|
if sent % 200 == 0:
|
||||||
print_info(f"Uploaded {sent}/{total} entries...")
|
print_info(f"Uploaded {sent}/{total} entries...")
|
||||||
@@ -427,8 +494,7 @@ def upload_tree(
|
|||||||
ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
ensure_remote_dir(sftp, posixpath.dirname(remote_path))
|
||||||
sftp.put(str(p), remote_path)
|
sftp.put(str(p), remote_path)
|
||||||
try:
|
try:
|
||||||
mode = p.stat().st_mode & 0o777
|
sftp.chmod(remote_path, 0o777)
|
||||||
sftp.chmod(remote_path, mode)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -455,16 +521,179 @@ def set_mode_json_to_normal(sftp: paramiko.SFTPClient) -> None:
|
|||||||
data["mode"] = "normal"
|
data["mode"] = "normal"
|
||||||
new_content = json.dumps(data, separators=(",", ": ")) + "\n"
|
new_content = json.dumps(data, separators=(",", ": ")) + "\n"
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback for non-standard formatting
|
|
||||||
new_content = re.sub(r'("mode"\s*:\s*")([^"]+)(")', r'\1normal\3', content)
|
new_content = re.sub(r'("mode"\s*:\s*")([^"]+)(")', r'\1normal\3', content)
|
||||||
if new_content == content:
|
if new_content == content:
|
||||||
# As a last resort, overwrite with a minimal JSON.
|
|
||||||
new_content = '{"mode": "normal"}\n'
|
new_content = '{"mode": "normal"}\n'
|
||||||
|
|
||||||
with sftp.open(remote, "w") as f:
|
with sftp.open(remote, "w") as f:
|
||||||
f.write(new_content.encode("utf-8"))
|
f.write(new_content.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_distributors_file(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text("utf-8"))
|
||||||
|
return raw if isinstance(raw, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def measure_host_latency(url: str, timeout: int = 5) -> float:
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "JiboUpdater/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
resp.read(512)
|
||||||
|
return time.time() - start
|
||||||
|
except Exception:
|
||||||
|
return float("inf")
|
||||||
|
|
||||||
|
|
||||||
|
def get_releases_from_host(api_url: str) -> list[Release]:
|
||||||
|
try:
|
||||||
|
raw = http_get_json(api_url)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
releases: list[Release] = []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
releases.append(
|
||||||
|
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", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return releases
|
||||||
|
|
||||||
|
|
||||||
|
def list_local_archives() -> list[Release]:
|
||||||
|
dl = UPDATES_DIR / "downloads"
|
||||||
|
found: list[Release] = []
|
||||||
|
if not dl.exists():
|
||||||
|
return found
|
||||||
|
for p in dl.iterdir():
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
name = p.name
|
||||||
|
if name.endswith((".tar.gz", ".tgz", ".zip")):
|
||||||
|
tag = name.rsplit(".", 2)[0]
|
||||||
|
found.append(Release(tag_name=tag, name=tag, prerelease=False, tarball_url=str(p), zipball_url=""))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def robots_config_path() -> Path:
|
||||||
|
return WORK_DIR / "robots.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_robots() -> dict:
|
||||||
|
p = robots_config_path()
|
||||||
|
if not p.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_robots(data: dict) -> None:
|
||||||
|
p = robots_config_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_robot_identity(host: str, user: str, password: str, timeout: int = 10) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
client = ssh_connect(host, user, password, timeout=timeout)
|
||||||
|
try:
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
try:
|
||||||
|
with sftp.open("/var/jibo/identity.json", "r") as f:
|
||||||
|
content = f.read().decode("utf-8", errors="replace")
|
||||||
|
data = json.loads(content)
|
||||||
|
name = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
name = data.get("name") or data.get("robot_name")
|
||||||
|
if isinstance(name, str):
|
||||||
|
return name
|
||||||
|
finally:
|
||||||
|
sftp.close()
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_select_release_and_host(distributors_file: Path) -> tuple[Optional[Release], Optional[str], str]:
|
||||||
|
d = load_distributors_file(distributors_file)
|
||||||
|
hosts = d.get("UpdateHosts") or d.get("OfficialHosts") or []
|
||||||
|
hosts = [h for h in hosts if isinstance(h, str)]
|
||||||
|
|
||||||
|
print_info("Checking hosts for latency and available releases...")
|
||||||
|
host_infos = []
|
||||||
|
for h in hosts:
|
||||||
|
lat = measure_host_latency(h)
|
||||||
|
releases = get_releases_from_host(h)
|
||||||
|
host_infos.append((h, lat, releases))
|
||||||
|
|
||||||
|
local_releases = list_local_archives()
|
||||||
|
if local_releases:
|
||||||
|
host_infos.append(("local", 0.0, local_releases))
|
||||||
|
|
||||||
|
print("Hosts (lower latency preferred):")
|
||||||
|
host_infos.sort(key=lambda t: (t[1] if isinstance(t[1], float) else float("inf")))
|
||||||
|
for idx, (h, lat, rels) in enumerate(host_infos, start=1):
|
||||||
|
label = f"{h} ({'local' if h=='local' else f'{lat:.2f}s'}) - {len(rels)} releases"
|
||||||
|
print(f"{idx}) {label}")
|
||||||
|
|
||||||
|
chosen_host_idx = None
|
||||||
|
while chosen_host_idx is None:
|
||||||
|
ans = input("Choose host number to browse releases (or q to cancel): ").strip()
|
||||||
|
if ans.lower() in {"q", "quit", "exit"}:
|
||||||
|
return None, None, ""
|
||||||
|
if not ans.isdigit():
|
||||||
|
print("Enter a number.")
|
||||||
|
continue
|
||||||
|
i = int(ans)
|
||||||
|
if i < 1 or i > len(host_infos):
|
||||||
|
print("Out of range")
|
||||||
|
continue
|
||||||
|
chosen_host_idx = i - 1
|
||||||
|
|
||||||
|
host, lat, releases = host_infos[chosen_host_idx]
|
||||||
|
if not releases:
|
||||||
|
print_warning("No releases found for that host.")
|
||||||
|
return None, host, "remote"
|
||||||
|
|
||||||
|
releases.sort(key=lambda r: _version_tuple(r.tag_name), reverse=True)
|
||||||
|
for idx, r in enumerate(releases, start=1):
|
||||||
|
pre = " [prerelease]" if r.prerelease else ""
|
||||||
|
print(f"{idx}) {r.tag_name}{pre} - {r.name}")
|
||||||
|
ans = input("Choose release number (or 'l' to list release notes, number to pick, q to cancel): ").strip()
|
||||||
|
if ans.lower() == "q":
|
||||||
|
return None, host, ""
|
||||||
|
if ans.lower() == "l":
|
||||||
|
sub = input("Release number to show notes: ").strip()
|
||||||
|
if sub.isdigit():
|
||||||
|
si = int(sub) - 1
|
||||||
|
if 0 <= si < len(releases):
|
||||||
|
print(releases[si].name)
|
||||||
|
print(releases[si].tag_name)
|
||||||
|
return None, host, ""
|
||||||
|
if not ans.isdigit():
|
||||||
|
return None, host, ""
|
||||||
|
ri = int(ans) - 1
|
||||||
|
if ri < 0 or ri >= len(releases):
|
||||||
|
return None, host, ""
|
||||||
|
chosen = releases[ri]
|
||||||
|
source = "local" if host == "local" else "remote"
|
||||||
|
return chosen, host, source
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
_no_color_if_not_tty()
|
_no_color_if_not_tty()
|
||||||
|
|
||||||
@@ -473,6 +702,9 @@ def main() -> int:
|
|||||||
parser.add_argument("--user", default="root", help="SSH username (default: root)")
|
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("--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("--releases-api", default=DEFAULT_RELEASES_API, help="Gitea releases API URL")
|
||||||
|
parser.add_argument("--distributors", type=Path, default=Path("Distributors.json"), help="Path to Distributors.json to check multiple hosts")
|
||||||
|
parser.add_argument("--tui", action="store_true", help="Run an interactive text UI to pick host/release")
|
||||||
|
parser.add_argument("--updater-releases-api", default=DEFAULT_UPDATER_RELEASES_API, help="Releases API to check for updater updates")
|
||||||
|
|
||||||
parser.add_argument("--stable", action="store_true", help="Ignore prereleases")
|
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("--tag", help="Install a specific tag (e.g. v3.3.0) instead of latest")
|
||||||
@@ -501,11 +733,48 @@ def main() -> int:
|
|||||||
|
|
||||||
_ensure_dirs()
|
_ensure_dirs()
|
||||||
|
|
||||||
|
logp = WORK_DIR / "updater.log"
|
||||||
|
logp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s: %(message)s",
|
||||||
|
handlers=[logging.FileHandler(logp, encoding="utf-8"), logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logging.info("jibo_updater starting, version %s", __version__)
|
||||||
|
|
||||||
|
spinner = _Spinner("Checking updater version...")
|
||||||
|
spinner.start()
|
||||||
|
try:
|
||||||
|
latest_tag, is_newer = check_updater_version(args.updater_releases_api, __version__)
|
||||||
|
finally:
|
||||||
|
spinner.stop()
|
||||||
|
|
||||||
|
if latest_tag:
|
||||||
|
if is_newer:
|
||||||
|
msg = f"Updater update available: {latest_tag} (current {__version__})"
|
||||||
|
print_warning(msg)
|
||||||
|
logging.info(msg)
|
||||||
|
else:
|
||||||
|
msg = f"Updater is up-to-date ({__version__})"
|
||||||
|
print_info(msg)
|
||||||
|
logging.info(msg)
|
||||||
|
else:
|
||||||
|
logging.info("Updater version check failed or no releases found")
|
||||||
|
|
||||||
allow_prerelease = not args.stable
|
allow_prerelease = not args.stable
|
||||||
|
|
||||||
print_info("Checking latest release...")
|
print_info("Checking latest release...")
|
||||||
if args.tag:
|
chosen_remote_source: Optional[str] = None
|
||||||
# Fetch all releases and pick the one matching tag
|
chosen_source_type = "remote"
|
||||||
|
if args.tui:
|
||||||
|
rel_choice, host_choice, source = prompt_select_release_and_host(args.distributors)
|
||||||
|
if rel_choice is None:
|
||||||
|
print_info("No release selected; aborting.")
|
||||||
|
return 2
|
||||||
|
release = rel_choice
|
||||||
|
chosen_remote_source = host_choice
|
||||||
|
chosen_source_type = source
|
||||||
|
elif args.tag:
|
||||||
raw = http_get_json(args.releases_api)
|
raw = http_get_json(args.releases_api)
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise RuntimeError("Unexpected releases API response")
|
raise RuntimeError("Unexpected releases API response")
|
||||||
@@ -550,27 +819,33 @@ def main() -> int:
|
|||||||
print_info("Aborted.")
|
print_info("Aborted.")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
# Download + extract
|
|
||||||
archive_name = f"{release.tag_name}.tar.gz"
|
archive_name = f"{release.tag_name}.tar.gz"
|
||||||
archive_path = UPDATES_DIR / "downloads" / archive_name
|
archive_path = UPDATES_DIR / "downloads" / archive_name
|
||||||
extract_dir = UPDATES_DIR / "extracted" / release.tag_name
|
extract_dir = UPDATES_DIR / "extracted" / release.tag_name
|
||||||
|
|
||||||
tarball_url = normalize_download_url(release.tarball_url, args.releases_api)
|
if chosen_remote_source and chosen_source_type == "remote":
|
||||||
|
tarball_url = normalize_download_url(release.tarball_url, chosen_remote_source)
|
||||||
|
elif chosen_source_type == "local":
|
||||||
|
tarball_url = release.tarball_url
|
||||||
|
else:
|
||||||
|
tarball_url = normalize_download_url(release.tarball_url, args.releases_api)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_download(tarball_url, archive_path, force=args.force)
|
if isinstance(tarball_url, str) and Path(tarball_url).exists():
|
||||||
|
archive_path = Path(tarball_url)
|
||||||
|
print_info(f"Using local archive: {archive_path}")
|
||||||
|
else:
|
||||||
|
_download(tarball_url, archive_path, force=args.force)
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
raise RuntimeError(f"Download failed: {e}")
|
raise RuntimeError(f"Download failed: {e}")
|
||||||
|
|
||||||
_extract(archive_path, extract_dir, force=args.force)
|
_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()]
|
children = [p for p in extract_dir.iterdir() if p.is_dir()]
|
||||||
search_root = children[0] if len(children) == 1 else extract_dir
|
search_root = children[0] if len(children) == 1 else extract_dir
|
||||||
|
|
||||||
build_dir = find_build_dir(search_root, args.build_path)
|
build_dir = find_build_dir(search_root, args.build_path)
|
||||||
|
|
||||||
# Connect and update
|
|
||||||
print_info(f"Connecting to {args.user}@{args.host} ...")
|
print_info(f"Connecting to {args.user}@{args.host} ...")
|
||||||
client = ssh_connect(args.host, args.user, args.password, timeout=args.ssh_timeout)
|
client = ssh_connect(args.host, args.user, args.password, timeout=args.ssh_timeout)
|
||||||
try:
|
try:
|
||||||
@@ -619,7 +894,6 @@ def main() -> int:
|
|||||||
sftp.close()
|
sftp.close()
|
||||||
|
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
# Update local state
|
|
||||||
if isinstance(state, dict):
|
if isinstance(state, dict):
|
||||||
state[args.host] = release.tag_name
|
state[args.host] = release.tag_name
|
||||||
save_state(args.state_file, state)
|
save_state(args.state_file, state)
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ if command -v python3 >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
exec python "$SCRIPT_DIR/jibo_updater.py" "$@"
|
exec python "$SCRIPT_DIR/jibo_updater.py" "$@"
|
||||||
|
|
||||||
|
# NOTE: The updater uses only the standard library and `paramiko`.
|
||||||
|
# If you don't have `paramiko` installed in your environment, install it:
|
||||||
|
# python3 -m pip install paramiko
|
||||||
|
|||||||
190
jibo_updater_tui.py
Normal file
190
jibo_updater_tui.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple curses TUI for selecting a distribution host and release.
|
||||||
|
|
||||||
|
This TUI reuses helper functions from `jibo_updater.py` to probe hosts and
|
||||||
|
list local archives. It prints a JSON object to stdout with the chosen
|
||||||
|
selection so other tools or a GUI wrapper can invoke it.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 jibo_updater_tui.py [--distributors Distributors.json]
|
||||||
|
|
||||||
|
Output (on success): JSON to stdout, e.g.
|
||||||
|
{"host": "https://...","source":"remote","tag":"v3.3.0","tarball_url":"https://..."}
|
||||||
|
|
||||||
|
Keyboard:
|
||||||
|
- Up/Down: navigate
|
||||||
|
- Enter: select
|
||||||
|
- b: back
|
||||||
|
- v: view release notes/name
|
||||||
|
- q: quit
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jibo_updater import (
|
||||||
|
load_distributors_file,
|
||||||
|
measure_host_latency,
|
||||||
|
get_releases_from_host,
|
||||||
|
list_local_archives,
|
||||||
|
_version_tuple,
|
||||||
|
check_updater_version,
|
||||||
|
__version__ as UPDATER_VERSION,
|
||||||
|
DEFAULT_UPDATER_RELEASES_API,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to import helpers from jibo_updater: {e}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def gather_host_infos(distributors_path: Path):
|
||||||
|
d = load_distributors_file(distributors_path)
|
||||||
|
hosts = d.get("UpdateHosts") or d.get("OfficialHosts") or []
|
||||||
|
hosts = [h for h in hosts if isinstance(h, str)]
|
||||||
|
|
||||||
|
infos = []
|
||||||
|
for h in hosts:
|
||||||
|
lat = measure_host_latency(h)
|
||||||
|
rels = get_releases_from_host(h)
|
||||||
|
infos.append({"host": h, "lat": lat, "rels": rels})
|
||||||
|
|
||||||
|
local = list_local_archives()
|
||||||
|
if local:
|
||||||
|
infos.append({"host": "local", "lat": 0.0, "rels": local})
|
||||||
|
|
||||||
|
infos.sort(key=lambda x: x["lat"] if isinstance(x["lat"], float) else float("inf"))
|
||||||
|
return infos
|
||||||
|
|
||||||
|
|
||||||
|
class TUI:
|
||||||
|
def __init__(self, stdscr, distributors: Path):
|
||||||
|
self.stdscr = stdscr
|
||||||
|
self.distributors = distributors
|
||||||
|
curses.curs_set(0)
|
||||||
|
try:
|
||||||
|
latest, is_newer = check_updater_version(DEFAULT_UPDATER_RELEASES_API, UPDATER_VERSION)
|
||||||
|
if latest and is_newer:
|
||||||
|
self.updater_status = f"Updater update available: {latest} (current {UPDATER_VERSION})"
|
||||||
|
elif latest:
|
||||||
|
self.updater_status = f"Updater up-to-date ({UPDATER_VERSION})"
|
||||||
|
else:
|
||||||
|
self.updater_status = "Updater version check failed"
|
||||||
|
except Exception:
|
||||||
|
self.updater_status = "Updater version check failed"
|
||||||
|
|
||||||
|
self.hosts = gather_host_infos(distributors)
|
||||||
|
|
||||||
|
def draw_list(self, items: List[str], title: str, idx: int, offset: int = 0):
|
||||||
|
self.stdscr.clear()
|
||||||
|
h, w = self.stdscr.getmaxyx()
|
||||||
|
header = title[: w - 1]
|
||||||
|
status = (" - " + self.updater_status) if hasattr(self, "updater_status") else ""
|
||||||
|
if len(header) + len(status) < w - 1:
|
||||||
|
header = header + status
|
||||||
|
self.stdscr.addstr(0, 0, header[: w - 1])
|
||||||
|
for i, line in enumerate(items[offset : offset + h - 3]):
|
||||||
|
y = i + 2
|
||||||
|
style = curses.A_REVERSE if (i + offset) == idx else curses.A_NORMAL
|
||||||
|
try:
|
||||||
|
self.stdscr.addstr(y, 0, line[: w - 1], style)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
self.stdscr.addstr(h - 1, 0, "Enter=select v=view b=back q=quit")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if not self.hosts:
|
||||||
|
self.stdscr.addstr(0, 0, "No hosts found in distributors file or no local archives.")
|
||||||
|
self.stdscr.addstr(2, 0, "Press any key to exit.")
|
||||||
|
self.stdscr.getch()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
items = [f"{h['host']} ({'local' if h['host']=='local' else f'{h['lat']:.2f}s'}) - {len(h['rels'])} releases" for h in self.hosts]
|
||||||
|
self.draw_list(items, "Select host:", idx, offset)
|
||||||
|
c = self.stdscr.getch()
|
||||||
|
if c in (curses.KEY_DOWN, ord('j')):
|
||||||
|
if idx < len(items) - 1:
|
||||||
|
idx += 1
|
||||||
|
elif c in (curses.KEY_UP, ord('k')):
|
||||||
|
if idx > 0:
|
||||||
|
idx -= 1
|
||||||
|
elif c in (ord('\n'), ord('\r')):
|
||||||
|
choice = self.hosts[idx]
|
||||||
|
res = self.show_releases(choice)
|
||||||
|
if res:
|
||||||
|
print(json.dumps(res))
|
||||||
|
return 0
|
||||||
|
elif c in (ord('q'), 27):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def show_releases(self, host_info):
|
||||||
|
rels = host_info["rels"]
|
||||||
|
if not rels:
|
||||||
|
return None
|
||||||
|
rels.sort(key=lambda r: _version_tuple(r.tag_name), reverse=True)
|
||||||
|
idx = 0
|
||||||
|
while True:
|
||||||
|
items = [f"{r.tag_name}{' [prerelease]' if r.prerelease else ''} - {r.name}" for r in rels]
|
||||||
|
self.draw_list(items, f"Host: {host_info['host']}", idx)
|
||||||
|
c = self.stdscr.getch()
|
||||||
|
if c in (curses.KEY_DOWN, ord('j')):
|
||||||
|
if idx < len(items) - 1:
|
||||||
|
idx += 1
|
||||||
|
elif c in (curses.KEY_UP, ord('k')):
|
||||||
|
if idx > 0:
|
||||||
|
idx -= 1
|
||||||
|
elif c in (ord('b'), 8):
|
||||||
|
return None
|
||||||
|
elif c == ord('v'):
|
||||||
|
self.show_text(rels[idx].name or "(no notes)")
|
||||||
|
elif c in (ord('\n'), ord('\r')):
|
||||||
|
chosen = rels[idx]
|
||||||
|
res = {
|
||||||
|
"host": host_info["host"],
|
||||||
|
"source": "local" if host_info["host"] == "local" else "remote",
|
||||||
|
"tag": chosen.tag_name,
|
||||||
|
"tarball_url": chosen.tarball_url,
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
elif c in (ord('q'), 27):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def show_text(self, text: str):
|
||||||
|
self.stdscr.clear()
|
||||||
|
h, w = self.stdscr.getmaxyx()
|
||||||
|
lines = []
|
||||||
|
for ln in text.splitlines():
|
||||||
|
while ln:
|
||||||
|
lines.append(ln[: w - 1])
|
||||||
|
ln = ln[w - 1 :]
|
||||||
|
for i, ln in enumerate(lines[: h - 2]):
|
||||||
|
try:
|
||||||
|
self.stdscr.addstr(i, 0, ln)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
self.stdscr.addstr(h - 1, 0, "Press any key to return")
|
||||||
|
self.stdscr.refresh()
|
||||||
|
self.stdscr.getch()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Curses TUI for jibo_updater selection")
|
||||||
|
parser.add_argument("--distributors", type=Path, default=Path("Distributors.json"), help="Path to Distributors.json")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
curses.wrapper(lambda stdscr: TUI(stdscr, args.distributors).run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]) or 0)
|
||||||
Reference in New Issue
Block a user