From 8dfb15ac4035ba529adf9c084c2a783bda962987 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 17 Mar 2026 00:08:41 +0200 Subject: [PATCH] tinyGuiUpdate , Updating script v1 finnished --- README.md | 84 +++ __pycache__/jibo_automod.cpython-313.pyc | Bin 48769 -> 62949 bytes __pycache__/jibo_updater.cpython-313.pyc | Bin 0 -> 31507 bytes gui/__init__.py | 0 gui/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 148 bytes gui/__pycache__/installer_gui.cpython-313.pyc | Bin 0 -> 2270 bytes gui/__pycache__/main_panel.cpython-313.pyc | Bin 0 -> 3658 bytes .../process_runner.cpython-313.pyc | Bin 0 -> 10297 bytes .../terminal_helper.cpython-313.pyc | Bin 0 -> 962 bytes gui/__pycache__/updater_gui.cpython-313.pyc | Bin 0 -> 2266 bytes gui/assets/jibo.svg | 13 + gui/installer_gui.py | 37 + gui/main_panel.py | 59 ++ gui/process_runner.py | 185 +++++ gui/qml/MainPanel.qml | 168 +++++ gui/qml/ToolRunner.qml | 142 ++++ gui/terminal_helper.py | 12 + gui/updater_gui.py | 37 + jibo_automod.py | 514 +++++++++++--- jibo_gui.bat | 21 + jibo_gui.sh | 17 + jibo_updater.bat | 23 + jibo_updater.py | 642 ++++++++++++++++++ jibo_updater.sh | 18 + jibo_work/_t1.bin | 1 + jibo_work/_t2.bin | 1 + jibo_work/update_state.json | 3 + requirements-gui.txt | 1 + requirements.txt | 1 + 29 files changed, 1881 insertions(+), 98 deletions(-) create mode 100644 __pycache__/jibo_updater.cpython-313.pyc create mode 100644 gui/__init__.py create mode 100644 gui/__pycache__/__init__.cpython-313.pyc create mode 100644 gui/__pycache__/installer_gui.cpython-313.pyc create mode 100644 gui/__pycache__/main_panel.cpython-313.pyc create mode 100644 gui/__pycache__/process_runner.cpython-313.pyc create mode 100644 gui/__pycache__/terminal_helper.cpython-313.pyc create mode 100644 gui/__pycache__/updater_gui.cpython-313.pyc create mode 100644 gui/assets/jibo.svg create mode 100644 gui/installer_gui.py create mode 100644 gui/main_panel.py create mode 100644 gui/process_runner.py create mode 100644 gui/qml/MainPanel.qml create mode 100644 gui/qml/ToolRunner.qml create mode 100644 gui/terminal_helper.py create mode 100644 gui/updater_gui.py create mode 100644 jibo_gui.bat create mode 100755 jibo_gui.sh create mode 100644 jibo_updater.bat create mode 100644 jibo_updater.py create mode 100755 jibo_updater.sh create mode 100644 jibo_work/_t1.bin create mode 100644 jibo_work/_t2.bin create mode 100644 jibo_work/update_state.json create mode 100644 requirements-gui.txt create mode 100644 requirements.txt diff --git a/README.md b/README.md index 0ec608b..b4ce3cb 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ sudo pacman -S --needed base-devel libusb git python \ - Python 3.8+ - MSYS2 with MinGW-w64 toolchain - Zadig (for USB driver installation) +- e2fsprogs (provides `debugfs`, used to edit `mode.json` inside the ext filesystem image without mounting) - ~20GB free disk space ## What Does It Do? @@ -75,6 +76,28 @@ sudo pacman -S --needed base-devel libusb git python \ ./jibo_automod.sh ``` +## Optional GUI + +The GUI is separate from the CLI tools. You can still run `jibo_automod.sh` / `jibo_updater.sh` directly. + +Install GUI deps: + +```bash +python3 -m pip install -r requirements-gui.txt +``` + +Launch main panel (Linux): + +```bash +./jibo_gui.sh +``` + +Launch main panel (Windows): + +```bat +jibo_gui.bat +``` + ### Just Dump (no modification) ```bash ./jibo_automod.sh --dump-only -o my_jibo_backup.bin @@ -90,6 +113,20 @@ sudo pacman -S --needed base-devel libusb git python \ ./jibo_automod.sh --write-partition var_modified.bin --start-sector 0x7E9022 ``` +### Fast Mode (GPT + /var only) +This avoids the 15GB full dump by reading just the partition table + the ~500MB `/var` partition, +editing `/var/jibo/mode.json`, and writing back only the changed sectors. + +```bash +./jibo_automod.sh --mode-json-only +``` + +If patch-writing is not desired (or your filesystem changes a lot of blocks), force a full `/var` write: + +```bash +./jibo_automod.sh --mode-json-only --full-var-write +``` + ## Command Line Options | Option | Description | @@ -102,6 +139,8 @@ sudo pacman -S --needed base-devel libusb git python \ | `--rebuild-shofel` | Force rebuild of exploit tool | | `--skip-detection` | Skip USB device detection | | `--no-verify` | Skip write verification | +| `--mode-json-only` | Fast mode: dump GPT + /var only, patch `mode.json`, write back minimal changes | +| `--full-var-write` | With `--mode-json-only`: write entire /var partition instead of patch-writing | ## Entering RCM Mode @@ -134,6 +173,42 @@ Once the tool completes successfully: # Password: jibo ``` +## Updating Jibo (JiboOs releases) + +This repo also includes an updater that can pull the latest release from the JiboOs Gitea repo, +then upload the release `build/` overlay into Jibo’s `/` over SFTP. + +### Install updater dependency + +```bash +python3 -m pip install -r requirements.txt +``` + +### Run updater + +```bash +./jibo_updater.sh --ip +``` + +Windows: + +```bat +jibo_updater.bat --ip +``` + +Stable-only (ignore prereleases): + +```bash +python3 jibo_updater.py --ip --stable +``` + +If the release archive layout changes and the tool can’t find the `build/` folder automatically, +pass it explicitly (path is relative to the extracted archive root): + +```bash +python3 jibo_updater.py --ip --build-path V3.1/build +``` + ## Troubleshooting ### "Jibo not found in RCM mode" @@ -145,6 +220,15 @@ Once the tool completes successfully: - Run with sudo: `sudo ./jibo_automod.sh` - Or add udev rules for the Nvidia APX device +### Windows: mode.json edit fails / raw patch warning +If you see messages about raw edits needing padding, install `debugfs` via MSYS2 so the tool can edit the ext filesystem image safely: + +1. Install MSYS2: https://www.msys2.org/ +2. In an MSYS2 terminal: + - `pacman -S --needed e2fsprogs` + +Then re-run with `--mode-json-only`. + ### Build fails - Make sure ARM toolchain is installed - On Arch: `pacman -S arm-none-eabi-gcc arm-none-eabi-newlib` diff --git a/__pycache__/jibo_automod.cpython-313.pyc b/__pycache__/jibo_automod.cpython-313.pyc index 09ba9d5d6dc50ffed57b26e6007891d3db99d3a8..f306e0b9aec10e17dff3ef9462a5671ff6bb67b0 100644 GIT binary patch delta 18099 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 V_AklPK4R40ZiSuCq!XF-{{XVBuJ!-` delta 5986 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 literal 0 HcmV?d00001 diff --git a/gui/__pycache__/installer_gui.cpython-313.pyc b/gui/__pycache__/installer_gui.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a808daef3e4f6fdbaf3c5c1487445ced8b061f60 GIT binary patch literal 2270 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 literal 0 HcmV?d00001 diff --git a/gui/__pycache__/process_runner.cpython-313.pyc b/gui/__pycache__/process_runner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd7c1d731fca4c52b79b27cd6806765fee429d5c GIT binary patch literal 10297 zcmcIqYj7J?mcA{uq}I!lAM!Ja+m9qx;>Q3ElRyY$0wy>U+i}|vutCUDTNaTeak~}A zE(#1x4K@R7vsnrUW(!lWv&j~_McAo@pELh_+bP=9Ww2l6k2`!;j+yky5I&}zvqIqJzX zJgt$+C4uE?r3$H1a$^1&j@C(4l8f={rGVsSxrSQOOe9Y|k-Vec16I99x`T1cfa}}D z-5Ik4tFK{|Ag8&*$z)0mtMOD)>4SHEAgoTnQ|M14FPsS4HOHy`v1jCnstKnCXeuHr zie^7G6rYr-W|!hIsHE{yA_e78oR+CNeGS(jqK`tSI3)OS8%u0^M$9sa>u?h?Vlfzl zypbzBCCfdh2?;|T$=c6J{GZgq1}*H^B1wkHprg>Mkux@>EzbyhVLVQwbeI?$+C{o8 zt+zK)5qe;vioFbajJ^s_*er+9ulZfrf5{;UT^#$)54Cr0YY!_YIrF0|Grl$GH*$sN zeVp;lD6A<6wq0)(bY=btY7NS2h9Vyn6(y5L_DiThJXXWJCd&LxR=~P^i zS=aemPp7BFj=P=~6X8rU0_Co$2|0-s6gjDiN=lrP#b_$|ud^3aF)F9!WK@K&Ln|Cb zr+iL+S{#$($ry46jt(8~bj{d0@q3yjscH@>LgQ&w(X3r^@yG=5rWMTv zV~J!`SQ8mE$$>)%rKc6t2~i8gGVv~0SyZDC%_NgD?MzQ=bui6T;;al6aXKMf;s{eq z+6e6`;HSI~WR~14Bfi?Dz1gy+UpIfU_wwFPhyK3ny~lr($(EhGQd|G7zd>Ttd?_2SfG>&q|v z^6qyJzkT>3pKIvIHgv4}s}|M8@ww+$gq>F%-dCP`>AA&6a*kbD$F6mM-O`nxasPpGYaH#-~$M)tvd}Aw|Ini^fvABjKdc zoOVIUkKm`=17wz5xA3;W6>nwEyZbZm?qzj3@#!P0-k$Z!n(wTn>|rkF?fDmP&wpKY zS8Nc@7Wl$fb=?Zj|5RF_`AgN^WXaNEeOu@z@4N1B{H?W{T!!DTT}L_VZwUu90@cif zE)+(_ZS=q*fuxxQr50Y|m|KCGG18^Ghdhcfn?XjhNY*ZkA#EZ$ky5pI$#xsH?6=jI zzEDZ`quJCLo!<^*ADHwCw>y_27 z_TI1&e?8b_k6*VOAbw`T?PB6fZeM)6QAA6Mq_rM|wUQV{*4XYJ77z)9S+WCl40By} zNsyd}Kp`o6-oFZ;X5s&@g4z;rKb@SsTkvv-1^soo|&4^3XAhA`a1vEXK#haG};zSP_+Ucq%DJ+r^%60#JvEKRR7Z zwrMjACbD@a(3akXhNKTNEkp!kf@^uafhm|ibfBfo3M!Z}m$T$en@CRXBza}}v2~og z#Pxw}+mwlfd``1Hn@OoMyjwbdq;cUQ33ncj$w~QKnjXBz_3e^hW@FSYFEDDL2lr7HphQNvV) z?3o^L5HKt`kr;xUH8K?q3Iz?NEzm-9DVec6deLkHfcBG2;b<)sQi#b2dJF)HXEg^X zY$BeNH6Hh?*k6=yn+`n=ZF}LTB!B?Q@esjf029!b%Ide;-e}9#AH3YXT7Pi0;^3U) zicpagYCjWdmv%2#El+)9`$+xtv6Z@q{&Dy-K>}UB>TX(fx6D5HJGXD$S9KK?yy`Q) zYH{d|kq;st2H#J9#vfRB`*baYkD4iO-not61M4Z+`B6eSi9i6!QR^t*Y-$v>6}{Q< zZPdi|RFK@mq??m05TXeX&^bq4Q0^YJN!CLa$s_R?x*3{8J*?C_3N8Xly^^h{v~1Le zeUJJLR3Mc}_M#f)Si|T|a_d2)@lGp%qX(0fl1~pMDTI+UFih^_Xf*&Rzvdm#Vd)@4 zrPo3*`y!nH%cNVUr$AGVaGTxh5I0Pogmp8%Cz6r;DuO#P9nCOFU>_`j8#UdO!2^9V z zIhtoFS`7fa==2~ENd+GnrqTY4n$AE}l*fFUGXy~gtECC!@F6e*QO$!)^UW24z()l? z8|BbTe`=0@&9yFg7F_eLoY0mP+CHq!1s});A6OHPn*4oPVP8(TGb`NrQTHcDE+1JF zeqxq2WQB&D(2^BemLJTu9>}&HSQGAEZ)}?5U)NRr5~tbW$Rj>JT_oHHNI*Eb4LE6F z#(of8B?}-sWRP+$^806_Gcre2Yrjc!@t1qRZ+ClhwiK7Tx^>9&- zR2LXj3IUeY`pEv6y@nI&KQDi>zvXL7#Atgms^*Yv^Z-0p)}`0qQoA{^WfIMC&> zS)oa20slAHc$=q@x3sQk)u8q(;Psl34Z<= zMs=&P7+@m|bz%@Bg4*C5Sc1{>M!xX;z9S!IH1NAy(x9n|g@S@pzhx^E)NE>HsU(kD z6-yUUAa@+VPSQGJnxA2-VFCs$C&Bz6;#Mq4lY7fAl^4EX{sKme z!qbpz)&?Q^mh}DW8iRT0a~UV}Bs2|rC~7_vAJ{g<%Y>5?GI==owIl2jdjN)fAm(z=J*j-9u2Xt4h9O&^1&h+KOdPz$N*v z?t^!Rg&>60j|24PmBk9ipplAoF8~4Yb^8_$FC1R?RIJzU08`}yvy&5cWQ83$p(!gg ztqCorA=;T0b}q%%ggqvwF)K7)u3HuF{kZPp(GMz@PyMR;a$Q!ucTG5K*4dL4_I#jx zcA+4UNKM zg&10Z{ILVHHB+hZqDVeUwJCAbDw)98(Bmi=o?Cpg1QaOxW(hDbSV98}ZDM#z6u$z% zBizg{asc0sFc~2el&PX(#G6b3N=NIZ8xFRHEIxh#c>_ogU(jD5i6X)AQ9y`Ps2`oQ;=tkx*DwX?!dL>H$OX=23VALibPz0OTmNU=ZyA9gkyRO_5+sr~CzwKm440 z5okDndbaP2>K*4NX8Yl7>Dc*^*}m_1yRG?#pV-UhqDx1zwtX8G&UR?SO6>cuWA26> ziy}*1`JzU%s1b`?b1ie~%b^X6)7F9|f$NyNQ4J+8Ke}PD>s-%u%-!@`ZTr7-S#2%f z5+H1E*)St8LNn#f6}RyZ!1`0damiT}emh152%(+%@Y}T|{C17H21}qE2%C!oaTn{w zlMmB9TY_+}b{J>ZLfD8Ode*YvxR zWpi*{>iQDOO~Vh4;WoXeRE$MSwK5Oz%pD_x<%PVFgKwI+)Zi7)pc%?6)q9{&*T`)) zS>Qr6(i8tZ*wmn#_Cpmt__T%CwS~*Ll>nE3PD-+2)Uc?wz7dVwmFgW!L+2Babt4>b&%1L1 z0>fo~P1tR6Hb=34-TPken$TsI)n$dc&xHnETb_c?70D1t&Yu#|A+FtuYG^w!wd(B? z#U*_Y$`lU}GuT`a>U2zE3L}(a9`;06S_tO2ZFkPxAP#j)GPX9!o%WXkqJ;~a)BgW+ z;P<@%lYvEwTN<=ss1BFM6ch=dGj$YTTQwQb^Z!|%26P& zbmi4=H7qsf{4H63%c_6(oNe9hf2;S6-c|Q51d4%rb_@dNn}Gat0>%<+LL-U*9E`l~ z(bs$P`eB}}_w5DpZ$tI1+panDhd~gJHEiE}v+(b*Z5)XRq)7O9V6ajA3Np6};gg)i z!M#D@vKORj1X}3{E~hNU8E4XRS}YaP-ve$j+(gJ}F=5Ian3odAE~U;B4H-kk1G{YP z>$!%8A8w<(M-TLOeF7aKwz7S06JOgbTtfO5Bshy^kCkBa4bh< zih4m2`ZE`(?=HGphC&hDj>j3Ig&2(e4GQ*6*@;UP*22F(VSgOKZua%-p5w^tMS@#; zNjC-hVWn_bhsa0hCs2fTNkJ3~m--y{6>0v4IKL#GFNv4^FaMIbz9O|>k%oUGKh2S! z0)q6GFV_A7{-UHg>uFx~wB|hRSx@`LQ&~^ftn;S5l54-&ZsRKd!*AmPH~l`&f3vj$ d$i6$dmc@r}5_sHvhTF{rmmL32;K4Tie*pLJ$NT^Q literal 0 HcmV?d00001 diff --git a/gui/__pycache__/terminal_helper.cpython-313.pyc b/gui/__pycache__/terminal_helper.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ccac6407cb591c6bd31f20dc66c725c8c083ddbb GIT binary patch literal 962 zcmYjP&ubGw6n?X_yGi;Zsq~k&iZPYe1=}Lnh#(%UNSk79S)+#r#!b43TQ;-l%&gLz zC(Y4IK|!R~`WN_TSbB0$5JaRmF?#UkOg0f8?6=?Jy?OJ!_og#EJOIjGZ@mv5A%L&i z>6?`h)VZa|KDgkbH7Fs5@|qiKMhP=qGMTA-?3!y<$!2yQPD24)YYJSuf|qbogQXfN zCw-zF2gyN?(nxqBh$wdqncBQp-SKNenr;}06D30@o#HL;DJ20V!e`BZdSNZ8m7tyc zQ0J;5`%nRfTwsQa7_OKFs8|)dl5&mf*tK0e1v}Pdj!UN2WMc|69WC1AN(&D>T#!EfEVcZ=7 z7=*maqj+9Lsk!y9cG>LbT~zTTr><12tHEF{7}Ga9mNdB^)@7>2q6YJtGUKsE-1Mp7 z5_!^8-V?6(mDeUG8ga1L9~*hx?QWs9Cx@bzPy0>F={`%^U-&P6Cwu)X-3;|sQC~fyRjGcA~{0p0plVJ zs81t8m~JN)$+gH=@`ZSCx#kTAWQ-y58N+ zu2IaPN>6RYp{fc}D^)5|duY`Q$NU*Fwq!a=BqXRRZbaLo#;gg???HbT=|~qeR1%0V!*EJVkrYT#6HA!jNQbq7 zl0>8k4`}I9hGZfvX@e!1$PrF!N@<7;0nX@yTDFuUxspoMXe?{Pr4cd$IIAm@=msXE zR}bhz3!fllW)Zr2uz;?P7SKGLhqZYjmX@%dD(0TDK~bQJ>AGHMhPLMhmtl;rnBjFM z%T?llRLdVW?Q_2G*!5@+8p)$g?iR3<& z1Bs3!JylK3_ir(SUcz6jiiv-1jIYc0Y{J_9#g|wYS8=lLOZX2|2VTiv($lZx4=I!Q z*IwgsspUE1FJ5__YZA$$SD8w59O?3#zz|(wy(>Xvrnx1NwHb~p1&$I~K((WMRF(KS z{wpIk9OLs6$TN->$k5ubYm6|gnxwFSXT6(IvjrzR>RA-f-c;aN(Cz_cne$+Zleli%&jMMtaJj zwsNSaOt+QkhwAVnCHKvlug~0G*v4beA$HM|vuGLyd@&NS!X|O4XuDx?jLN`N>!e0#`Wlfi-;Z(f>GEt8;ruT-U>piadE>Ae?w%JU4UgZ z&Aiv}R-2CXK3RYv`(1-`&^;3cLD)rw$4K5q`9IP0E-LP#6T9fGztDwUH2b$SE{s1r giSW=@w>sj)AM(L(SHAnWBTsGPsb?a>lhGFc0#do%761SM literal 0 HcmV?d00001 diff --git a/gui/assets/jibo.svg b/gui/assets/jibo.svg new file mode 100644 index 0000000..38bedf3 --- /dev/null +++ b/gui/assets/jibo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + Jibo + diff --git a/gui/installer_gui.py b/gui/installer_gui.py new file mode 100644 index 0000000..87f0d9d --- /dev/null +++ b/gui/installer_gui.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from .process_runner import ProcessRunner, resolve_python +from .terminal_helper import TerminalHelper + + +def main() -> int: + app = QGuiApplication(sys.argv) + + engine = QQmlApplicationEngine() + + runner = ProcessRunner() + terminal = TerminalHelper() + engine.rootContext().setContextProperty("runner", runner) + engine.rootContext().setContextProperty("terminal", terminal) + engine.rootContext().setContextProperty("pyExec", resolve_python()) + engine.rootContext().setContextProperty("toolScript", "jibo_automod.py") + engine.rootContext().setContextProperty("toolTitle", "Installer") + + qml_path = Path(__file__).resolve().parent / "qml" / "ToolRunner.qml" + engine.load(QUrl.fromLocalFile(str(qml_path))) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui/main_panel.py b/gui/main_panel.py new file mode 100644 index 0000000..af4aaec --- /dev/null +++ b/gui/main_panel.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl, QObject, Slot +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from .process_runner import ConnectionMonitor, resolve_python, resolve_python_invocation + + +class Launcher(QObject): + def __init__(self, python_program: str, python_prefix: list[str]) -> None: + super().__init__() + self._python_program = python_program + self._python_prefix = list(python_prefix) + + @Slot() + def launchInstaller(self) -> None: + # Start installer GUI in a separate process. + import subprocess + subprocess.Popen( + [self._python_program, *self._python_prefix, "-m", "gui.installer_gui"], + cwd=str(Path(__file__).resolve().parents[1]), + ) + + @Slot() + def launchUpdater(self) -> None: + import subprocess + subprocess.Popen( + [self._python_program, *self._python_prefix, "-m", "gui.updater_gui"], + cwd=str(Path(__file__).resolve().parents[1]), + ) + + +def main() -> int: + app = QGuiApplication(sys.argv) + + engine = QQmlApplicationEngine() + + conn = ConnectionMonitor() + py_program, py_prefix = resolve_python_invocation() + py_exec = resolve_python() + engine.rootContext().setContextProperty("conn", conn) + engine.rootContext().setContextProperty("pyExec", py_exec) + engine.rootContext().setContextProperty("launcher", Launcher(py_program, py_prefix)) + + qml_path = Path(__file__).resolve().parent / "qml" / "MainPanel.qml" + engine.load(QUrl.fromLocalFile(str(qml_path))) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui/process_runner.py b/gui/process_runner.py new file mode 100644 index 0000000..4db3ac8 --- /dev/null +++ b/gui/process_runner.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import os +import shlex +import shutil +import socket +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QProcess, QTimer, Signal, Slot, Property + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def resolve_python_invocation() -> tuple[str, list[str]]: + """Return (program, prefix_args) to invoke Python reliably. + + On Windows, prefer the repo-local venv; otherwise prefer the `py -3` launcher + when present so we don’t depend on `python.exe` being on PATH. + """ + + venv_py = REPO_ROOT / ".venv" / ("Scripts" if os.name == "nt" else "bin") / ( + "python.exe" if os.name == "nt" else "python" + ) + if venv_py.exists(): + return (str(venv_py), []) + + if os.name == "nt" and shutil.which("py"): + return ("py", ["-3"]) + + if shutil.which("python3"): + return ("python3", []) + + return (sys.executable or "python", []) + + +def resolve_python() -> str: + program, prefix = resolve_python_invocation() + if prefix: + # Best-effort string representation (mostly for display) + return " ".join([program] + prefix) + return program + + +def can_connect(host: str, port: int, timeout_s: float = 0.8) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout_s): + return True + except OSError: + return False + + +def _pick_terminal_command() -> Optional[list[str]]: + if os.name == "nt": + return None + + candidates: list[list[str]] = [] + # Debian/Ubuntu alternative system + candidates.append(["x-terminal-emulator", "-e"]) + candidates.append(["gnome-terminal", "--"]) + candidates.append(["konsole", "-e"]) + candidates.append(["xfce4-terminal", "-e"]) + candidates.append(["xterm", "-e"]) + + for cmd in candidates: + if shutil.which(cmd[0]): + return cmd + return None + + +def spawn_in_terminal(argv: list[str]) -> bool: + """Best-effort external terminal launcher. + + Returns True if a terminal was spawned, False otherwise. + """ + + if os.name == "nt": + # Use cmd.exe window, keep it open (/k) + # Build a single string command for cmd. + cmdline = " ".join(shlex.quote(a) for a in argv) + subprocess.Popen(["cmd", "/c", "start", "cmd", "/k", cmdline], shell=False) + return True + + term = _pick_terminal_command() + if not term: + return False + + subprocess.Popen(term + argv, cwd=str(REPO_ROOT)) + return True + + +class ProcessRunner(QObject): + runningChanged = Signal() + exitCodeChanged = Signal() + outputAppended = Signal(str) + + def __init__(self) -> None: + super().__init__() + self._proc = QProcess(self) + self._proc.setProcessChannelMode(QProcess.MergedChannels) + self._proc.readyReadStandardOutput.connect(self._on_ready) + self._proc.finished.connect(self._on_finished) + self._exit_code: int = -1 + + @Property(bool, notify=runningChanged) + def running(self) -> bool: + return self._proc.state() != QProcess.NotRunning + + @Property(int, notify=exitCodeChanged) + def exitCode(self) -> int: + return self._exit_code + + @Slot(str, list) + def start(self, program: str, arguments: list) -> None: + if self.running: + return + self._exit_code = -1 + self.exitCodeChanged.emit() + self._proc.setProgram(program) + self._proc.setArguments([str(a) for a in arguments]) + self._proc.setWorkingDirectory(str(REPO_ROOT)) + self._proc.start() + self.runningChanged.emit() + + @Slot() + def stop(self) -> None: + if not self.running: + return + self._proc.terminate() + if not self._proc.waitForFinished(1500): + self._proc.kill() + self.runningChanged.emit() + + def _on_ready(self) -> None: + data = bytes(self._proc.readAllStandardOutput()).decode("utf-8", errors="replace") + if data: + self.outputAppended.emit(data) + + def _on_finished(self, exit_code: int, _status) -> None: + self._exit_code = int(exit_code) + self.exitCodeChanged.emit() + self.runningChanged.emit() + + +class ConnectionMonitor(QObject): + hostChanged = Signal() + connectedChanged = Signal() + + def __init__(self) -> None: + super().__init__() + self._host = "" + self._connected = False + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._poll) + self._timer.start() + + @Property(str, notify=hostChanged) + def host(self) -> str: + return self._host + + @host.setter + def host(self, value: str) -> None: + value = (value or "").strip() + if value == self._host: + return + self._host = value + self.hostChanged.emit() + self._poll() + + @Property(bool, notify=connectedChanged) + def connected(self) -> bool: + return self._connected + + def _poll(self) -> None: + host = self._host + connected = False + if host: + connected = can_connect(host, 22) + if connected != self._connected: + self._connected = connected + self.connectedChanged.emit() diff --git a/gui/qml/MainPanel.qml b/gui/qml/MainPanel.qml new file mode 100644 index 0000000..0f2dd7b --- /dev/null +++ b/gui/qml/MainPanel.qml @@ -0,0 +1,168 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window + +ApplicationWindow { + id: win + width: 880 + height: 520 + visible: true + title: "Jibo Tools" + + property string host: hostField.text.trim() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 14 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Text { + text: "Connection" + font.pixelSize: 18 + font.bold: true + } + + Rectangle { + width: 10 + height: 10 + radius: 5 + color: conn.connected ? "#2ecc71" : (host.length > 0 ? "#e67e22" : "#bdc3c7") + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: conn.connected ? "SSH reachable" : (host.length > 0 ? "Not reachable" : "No IP") + color: "#555" + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } + + TextField { + id: hostField + placeholderText: "Jibo IP (e.g. 192.168.1.50)" + Layout.preferredWidth: 280 + onTextChanged: conn.host = text + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 16 + + Rectangle { + Layout.preferredWidth: 320 + Layout.fillHeight: true + radius: 14 + color: "#f6f6f6" + border.color: "#e4e4e4" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + + Text { + text: "Your Jibo" + font.pixelSize: 18 + font.bold: true + } + + Item { Layout.fillHeight: true } + + Rectangle { + id: jiboCard + Layout.alignment: Qt.AlignHCenter + width: 240 + height: 240 + radius: 18 + color: "#ffffff" + border.color: "#e4e4e4" + + Image { + anchors.centerIn: parent + width: 200 + height: 200 + source: "../assets/jibo.svg" + fillMode: Image.PreserveAspectFit + } + + MouseArea { + anchors.fill: parent + onClicked: { + // Best-effort: open Chrome remote devices page. + Qt.openUrlExternally("chrome://inspect/#devices") + } + } + } + + Item { Layout.fillHeight: true } + + Text { + text: "Click Jibo to open chrome://inspect" + color: "#666" + Layout.alignment: Qt.AlignHCenter + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 14 + color: "#f6f6f6" + border.color: "#e4e4e4" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + Text { + text: "Actions" + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "Installer and updater remain available via CLI.\nUse the buttons below to launch their GUIs." + color: "#555" + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Button { + Layout.fillWidth: true + text: "Install" + enabled: true + onClicked: { + launcher.launchInstaller() + } + } + + Button { + Layout.fillWidth: true + text: "Check for updates" + enabled: true + onClicked: { + launcher.launchUpdater() + } + } + } + } + } + } + } +} diff --git a/gui/qml/ToolRunner.qml b/gui/qml/ToolRunner.qml new file mode 100644 index 0000000..72d3e84 --- /dev/null +++ b/gui/qml/ToolRunner.qml @@ -0,0 +1,142 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: win + width: 900 + height: 560 + visible: true + title: (typeof toolTitle === "string" ? toolTitle : "Tool") + + property string script: (typeof toolScript === "string" ? toolScript : "") + property bool isUpdater: script.indexOf("jibo_updater.py") >= 0 + + function buildArgs() { + var args = [] + args.push(script) + + if (isUpdater) { + var h = hostField.text.trim() + if (h.length > 0) { + args.push("--ip") + args.push(h) + } + } + + var extra = extraArgs.text.trim() + if (extra.length > 0) { + // naive split (keeps GUI minimal) + var parts = extra.split(/\s+/) + for (var i=0; i 0) args.push(parts[i]) + } + } + + return args + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: title + font.pixelSize: 18 + font.bold: true + } + + Item { Layout.fillWidth: true } + + Button { + text: runner.running ? "Stop" : "Start" + onClicked: { + if (runner.running) { + runner.stop() + } else { + runner.start(pyExec, buildArgs()) + } + } + } + + Button { + text: "Open in terminal" + enabled: !runner.running + onClicked: { + terminal.openTerminal(pyExec, buildArgs()) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + TextField { + id: hostField + visible: isUpdater + placeholderText: "Jibo IP (required for updater)" + Layout.preferredWidth: 260 + } + + TextField { + id: extraArgs + placeholderText: "Extra arguments (optional)" + Layout.fillWidth: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: 12 + color: "#0f0f0f" + + ScrollView { + anchors.fill: parent + anchors.margins: 10 + clip: true + + TextArea { + id: log + readOnly: true + wrapMode: TextArea.Wrap + color: "#e8e8e8" + font.family: "monospace" + background: null + text: "" + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: runner.running ? "Running..." : (runner.exitCode >= 0 ? ("Exit: " + runner.exitCode) : "Idle") + color: "#666" + } + + Item { Layout.fillWidth: true } + + Button { + text: "Clear log" + onClicked: log.text = "" + } + } + } + + Connections { + target: runner + function onOutputAppended(chunk) { + log.text += chunk + log.cursorPosition = log.length + } + } +} diff --git a/gui/terminal_helper.py b/gui/terminal_helper.py new file mode 100644 index 0000000..f37382c --- /dev/null +++ b/gui/terminal_helper.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from PySide6.QtCore import QObject, Slot + +from .process_runner import spawn_in_terminal + + +class TerminalHelper(QObject): + @Slot(str, list, result=bool) + def openTerminal(self, program: str, arguments: list) -> bool: + argv = [program] + [str(a) for a in arguments] + return spawn_in_terminal(argv) diff --git a/gui/updater_gui.py b/gui/updater_gui.py new file mode 100644 index 0000000..9e52e5d --- /dev/null +++ b/gui/updater_gui.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QUrl +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +from .process_runner import ProcessRunner, resolve_python +from .terminal_helper import TerminalHelper + + +def main() -> int: + app = QGuiApplication(sys.argv) + + engine = QQmlApplicationEngine() + + runner = ProcessRunner() + terminal = TerminalHelper() + engine.rootContext().setContextProperty("runner", runner) + engine.rootContext().setContextProperty("terminal", terminal) + engine.rootContext().setContextProperty("pyExec", resolve_python()) + engine.rootContext().setContextProperty("toolScript", "jibo_updater.py") + engine.rootContext().setContextProperty("toolTitle", "Updater") + + qml_path = Path(__file__).resolve().parent / "qml" / "ToolRunner.qml" + engine.load(QUrl.fromLocalFile(str(qml_path))) + + if not engine.rootObjects(): + return 1 + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/jibo_automod.py b/jibo_automod.py index 99f84cb..f40fbed 100644 --- a/jibo_automod.py +++ b/jibo_automod.py @@ -219,6 +219,10 @@ def check_windows_dependencies() -> Tuple[bool, List[str], List[str]]: # Check for make if not shutil.which("make") and not shutil.which("mingw32-make"): missing.append("GNU Make") + + # Optional: debugfs for editing ext filesystem images without mounting + if not shutil.which("debugfs") and not shutil.which("debugfs.exe"): + warnings.append("debugfs (e2fsprogs) - optional but recommended for reliable mode.json edits on Windows") return len(missing) == 0, missing, warnings @@ -613,93 +617,54 @@ def modify_mode_json_direct(partition_path: Path) -> bool: Modify mode.json directly in the partition image by searching for the pattern. This works on both Linux and Windows without mounting. """ - print_info("Searching for mode.json in partition...") - + print_info("Searching for mode.json in partition (raw, no mount)...") + + def _is_safe_pad_byte(b: int) -> bool: + return b in (0x00, 0x09, 0x0A, 0x0D, 0x20) + try: with open(partition_path, "r+b") as f: - data = f.read() - - # Search for the mode.json content pattern - # The file contains: {"mode": "normal"} or similar - patterns_to_find = [ - b'"mode": "normal"', - b'"mode":"normal"', - b'"mode" : "normal"', + data = bytearray(f.read()) + + # Best-effort raw replacement. + # IMPORTANT: never change image length and never shift bytes; only overwrite in-place. + json_patterns = [ + (b'{"mode":"normal"}', b'{"mode":"int-developer"}'), + (b'{"mode": "normal"}', b'{"mode": "int-developer"}'), + (b'{ "mode": "normal" }', b'{"mode":"int-developer"}'), ] - - replacement = b'"mode": "int-developer"' - - modified = False - for pattern in patterns_to_find: - if pattern in data: - # Calculate padding needed - pad_len = len(pattern) - len(replacement) - if pad_len > 0: - # Original is longer, we need to pad replacement - # Actually, we need to be careful here - let's make them same size - replacement_padded = replacement + b' ' * pad_len - elif pad_len < 0: - # Replacement is longer - this is a problem - # "normal" (6 chars) vs "int-developer" (13 chars) - # Original: "mode": "normal" (16 chars) - # New: "mode": "int-developer" (23 chars) - # We need to find the full JSON object and replace it - continue - else: - replacement_padded = replacement - - # Find offset - offset = data.find(pattern) - print_info(f"Found pattern at offset {offset} (0x{offset:x})") - - # This simple replacement won't work due to size difference - # We need a smarter approach - modified = True - break - - if not modified: - # Try finding the full JSON object - json_patterns = [ - (b'{"mode":"normal"}', b'{"mode":"int-developer"}'), - (b'{"mode": "normal"}', b'{"mode": "int-developer"}'), - (b'{ "mode": "normal" }', b'{"mode":"int-developer"}'), - ] - - for old_json, new_json in json_patterns: - if old_json in data: - offset = data.find(old_json) - print_info(f"Found mode.json at offset {offset} (0x{offset:x})") - - # Check if there's enough space (look at surrounding nulls/padding) - end_offset = offset + len(old_json) - - # The new JSON is longer, so we need to check if there's padding - size_diff = len(new_json) - len(old_json) - - if size_diff > 0: - # Check if the bytes after the old JSON are nulls or whitespace - following_bytes = data[end_offset:end_offset + size_diff] - if all(b == 0 or b == 0x20 or b == 0x0a for b in following_bytes): - # Safe to overwrite - new_data = data[:offset] + new_json + data[end_offset + size_diff:] - else: - # Not safe, need to use filesystem modification - print_warning("Cannot safely modify in-place, using filesystem mount") - return False - else: - # Replacement is shorter or same size, pad with nulls - padding = b'\x00' * (-size_diff) - new_data = data[:offset] + new_json + padding + data[end_offset:] - - # Write modified data - f.seek(0) - f.write(new_data) - print_success("mode.json modified successfully!") - return True - - print_warning("mode.json pattern not found, trying filesystem mount...") + + for old_json, new_json in json_patterns: + offset = bytes(data).find(old_json) + if offset == -1: + continue + + print_info(f"Found mode.json JSON at offset {offset} (0x{offset:x})") + end_offset = offset + len(old_json) + + if len(new_json) <= len(old_json): + region_len = len(old_json) + replacement = new_json + b" " * (region_len - len(new_json)) + data[offset:offset + region_len] = replacement + else: + extra = len(new_json) - len(old_json) + following = data[end_offset:end_offset + extra] + if len(following) != extra or not all(_is_safe_pad_byte(b) for b in following): + print_warning("Raw edit would require growing the file and no safe padding was found") + return False + + region_len = len(new_json) + # Overwrite the JSON plus the padding region; do NOT shift bytes. + data[offset:offset + region_len] = new_json + + f.seek(0) + f.write(data) + print_success("mode.json modified successfully (raw in-place overwrite)") + return True + + print_warning("mode.json pattern not found (raw). Will try filesystem mount if available...") return False - + except Exception as e: print_error(f"Direct modification failed: {e}") return False @@ -737,10 +702,49 @@ def modify_partition_mounted(partition_path: Path) -> bool: if mode_json_path.exists(): print_info(f"Found mode.json at {mode_json_path}") - - # Read current content - with open(mode_json_path, "r") as f: - content = json.load(f) + + # Capture original permissions/ownership so we can restore after copy-write + perm = None + uid = None + gid = None + try: + stat_res = run_command( + ["stat", "-c", "%a %u %g", str(mode_json_path)], + sudo=True, + capture_output=True, + check=True, + ) + parts = stat_res.stdout.strip().split() + if len(parts) == 3: + perm, uid, gid = parts[0], parts[1], parts[2] + except Exception: + pass + + # Save a raw backup copy of mode.json for debugging/recovery + try: + backup_text = run_command( + ["cat", str(mode_json_path)], + sudo=True, + capture_output=True, + check=True, + ).stdout + (WORK_DIR / "mode.json.original").write_text(backup_text) + except Exception: + pass + + # Read current content (prefer sudo cat so permissions don't bite us) + try: + mode_text = run_command( + ["cat", str(mode_json_path)], + sudo=True, + capture_output=True, + check=True, + ).stdout + content = json.loads(mode_text) + except Exception: + # Fallback: direct open (works if script is run with sudo) + with open(mode_json_path, "r") as f: + content = json.load(f) print_info(f"Current mode: {content.get('mode', 'unknown')}") @@ -751,11 +755,19 @@ def modify_partition_mounted(partition_path: Path) -> bool: temp_json = WORK_DIR / "mode_temp.json" with open(temp_json, "w") as f: json.dump(content, f) - - run_command( - ["cp", str(temp_json), str(mode_json_path)], - sudo=True - ) + + run_command(["cp", str(temp_json), str(mode_json_path)], sudo=True) + + # Restore permissions/ownership if we captured them + if perm is not None: + run_command(["chmod", perm, str(mode_json_path)], sudo=True, check=False) + if uid is not None and gid is not None: + run_command(["chown", f"{uid}:{gid}", str(mode_json_path)], sudo=True, check=False) + + try: + (WORK_DIR / "mode.json.modified").write_text(json.dumps(content)) + except Exception: + pass print_success("mode.json modified to 'int-developer'") @@ -779,22 +791,240 @@ def modify_partition_mounted(partition_path: Path) -> bool: pass +def _find_debugfs_executable() -> Optional[str]: + """Find a usable debugfs executable (e2fsprogs).""" + for candidate in ("debugfs", "debugfs.exe"): + path = shutil.which(candidate) + if path: + return path + return None + + +def modify_partition_debugfs(partition_path: Path) -> bool: + """Modify mode.json using debugfs (e2fsprogs) without mounting. + + This can work on Windows if the user has MSYS2 e2fsprogs installed (debugfs.exe on PATH). + """ + debugfs = _find_debugfs_executable() + if not debugfs: + return False + + print_info("Attempting mode.json edit via debugfs (no mount)...") + + # Potential locations inside /var + candidate_paths = [ + "/jibo/mode.json", + "/mode.json", + "/etc/jibo/mode.json", + ] + + # Find which path exists by trying to cat it + existing_path: Optional[str] = None + original_text: Optional[str] = None + for p in candidate_paths: + try: + res = run_command( + [debugfs, "-R", f"cat {p}", str(partition_path)], + capture_output=True, + check=True, + ) + # debugfs prints to stdout for cat + if res.stdout and "File not found" not in res.stdout: + existing_path = p + original_text = res.stdout + break + except Exception: + continue + + if not existing_path or original_text is None: + print_warning("debugfs could not locate mode.json inside the image") + return False + + # Save backup + try: + (WORK_DIR / "mode.json.original").write_text(original_text) + except Exception: + pass + + try: + content = json.loads(original_text) + except Exception: + print_warning("mode.json content is not valid JSON; refusing to edit") + return False + + content["mode"] = "int-developer" + new_text = json.dumps(content) + + temp_json = WORK_DIR / "mode_temp.json" + temp_json.write_text(new_text) + + # Overwrite: remove then write to ensure replacement works even if size differs. + # This may change filesystem allocation, which is fine for full /var write, and + # our patch-write logic can still handle it. + try: + run_command([debugfs, "-w", "-R", f"rm {existing_path}", str(partition_path)], check=False, capture_output=True) + run_command([debugfs, "-w", "-R", f"write {str(temp_json)} {existing_path}", str(partition_path)], capture_output=True) + except Exception as e: + print_warning(f"debugfs write failed: {e}") + return False + + try: + (WORK_DIR / "mode.json.modified").write_text(new_text) + except Exception: + pass + + print_success("mode.json modified to 'int-developer' (debugfs)") + return True + + def modify_var_partition(partition_path: Path) -> bool: """Modify the var partition to enable developer mode""" print_step(4, 6, "Modifying var partition") - - # Try direct modification first (works on all platforms) + + # On Linux, prefer mounting: it's the only truly safe way to update a file in an ext filesystem. + if platform.system() == "Linux": + if modify_partition_mounted(partition_path): + return True + print_warning("Mount-based edit failed; falling back to raw in-place patch") + + # If mounting is unavailable (Windows/macOS) or failed, try debugfs (ext filesystem edit without mount) + if modify_partition_debugfs(partition_path): + return True + + # Raw patch is a best-effort last resort if modify_mode_json_direct(partition_path): return True - # Fall back to mounting (Linux only) - if platform.system() == "Linux": - return modify_partition_mounted(partition_path) - print_error("Could not modify partition") return False +def emmc_read_to_file(output_path: Path, start_sector: int, num_sectors: int) -> bool: + """Read a range of sectors from eMMC into a file.""" + shofel = get_shofel_path() + if not shofel.exists(): + print_error("shofel2_t124 not found. Please build it first.") + return False + + try: + cmd = [ + str(shofel), + "EMMC_READ", + f"0x{start_sector:x}", + f"0x{num_sectors:x}", + str(output_path), + ] + if platform.system() == "Linux": + cmd = ["sudo"] + cmd + subprocess.run(cmd, cwd=SHOFEL_DIR, check=True) + return output_path.exists() + except subprocess.CalledProcessError as e: + print_error(f"EMMC_READ failed: {e}") + return False + + +def emmc_write_file(input_path: Path, start_sector: int) -> bool: + """Write a file to eMMC starting at a given sector.""" + shofel = get_shofel_path() + if not shofel.exists(): + print_error("shofel2_t124 not found. Please build it first.") + return False + + try: + cmd = [ + str(shofel), + "EMMC_WRITE", + f"0x{start_sector:x}", + str(input_path), + ] + if platform.system() == "Linux": + cmd = ["sudo"] + cmd + subprocess.run(cmd, cwd=SHOFEL_DIR, check=True) + return True + except subprocess.CalledProcessError as e: + print_error(f"EMMC_WRITE failed: {e}") + return False + + +def compute_changed_sector_ranges(original_path: Path, modified_path: Path, sector_size: int = 512, + scan_chunk_bytes: int = 4 * 1024 * 1024) -> Tuple[int, List[Tuple[int, int]]]: + """Return (changed_sector_count, ranges) where ranges are (start_sector_offset, num_sectors).""" + if original_path.stat().st_size != modified_path.stat().st_size: + raise ValueError("Files differ in size; cannot compute sector diffs") + total_bytes = original_path.stat().st_size + if total_bytes % sector_size != 0: + raise ValueError("Partition image size is not a multiple of sector size") + + changed_sectors: List[int] = [] + scan_chunk_bytes = max(sector_size, (scan_chunk_bytes // sector_size) * sector_size) + + with open(original_path, "rb") as f1, open(modified_path, "rb") as f2: + base_sector = 0 + while True: + b1 = f1.read(scan_chunk_bytes) + b2 = f2.read(scan_chunk_bytes) + if not b1 and not b2: + break + if b1 == b2: + base_sector += len(b1) // sector_size + continue + + # Chunk differs; identify sector-level diffs within this chunk + sectors_in_chunk = min(len(b1), len(b2)) // sector_size + for i in range(sectors_in_chunk): + s1 = b1[i * sector_size:(i + 1) * sector_size] + s2 = b2[i * sector_size:(i + 1) * sector_size] + if s1 != s2: + changed_sectors.append(base_sector + i) + base_sector += sectors_in_chunk + + if not changed_sectors: + return 0, [] + + changed_sectors.sort() + ranges: List[Tuple[int, int]] = [] + start = prev = changed_sectors[0] + for s in changed_sectors[1:]: + if s == prev + 1: + prev = s + continue + ranges.append((start, prev - start + 1)) + start = prev = s + ranges.append((start, prev - start + 1)) + return len(changed_sectors), ranges + + +def write_partition_patch_to_emmc(original_path: Path, modified_path: Path, base_start_sector: int, + max_ranges: int = 128, max_changed_sectors: int = 131072) -> bool: + """Write only the changed sectors between two partition images.""" + try: + changed_count, ranges = compute_changed_sector_ranges(original_path, modified_path) + except Exception as e: + print_warning(f"Patch write unavailable ({e}); falling back to full partition write") + return write_partition_to_emmc(modified_path, base_start_sector) + + if changed_count == 0: + print_success("No changes detected in /var partition; nothing to write") + return True + + if len(ranges) > max_ranges or changed_count > max_changed_sectors: + print_warning(f"Too many changes for patch write (ranges={len(ranges)}, sectors={changed_count}); using full /var write") + return write_partition_to_emmc(modified_path, base_start_sector) + + print_info(f"Writing patch: {changed_count} sectors across {len(ranges)} ranges") + + sector_size = EMMC_SECTOR_SIZE + with open(modified_path, "rb") as src: + for idx, (start_off, count) in enumerate(ranges, start=1): + patch_path = WORK_DIR / f"var_patch_{idx:03d}.bin" + src.seek(start_off * sector_size) + payload = src.read(count * sector_size) + patch_path.write_bytes(payload) + if not emmc_write_file(patch_path, base_start_sector + start_off): + return False + return True + + # ============================================================================ # eMMC Operations # ============================================================================ @@ -1107,6 +1337,88 @@ def run_write_only(args) -> bool: return write_partition_to_emmc(partition_path, args.start_sector) +def run_mode_json_only(args) -> bool: + """Fast path: dump only GPT + /var, modify /var/jibo/mode.json, and write back minimal changes.""" + print_banner() + print_info("Running in mode-json-only mode (GPT + /var only)") + + WORK_DIR.mkdir(parents=True, exist_ok=True) + + # Build Shofel + if not build_shofel(force_rebuild=args.rebuild_shofel): + return False + + # Wait for Jibo + if not args.skip_detection: + if not wait_for_jibo_rcm(timeout=120): + return False + + # Dump GPT / partition table (small read) + gpt_path = WORK_DIR / "gpt_dump.bin" + gpt_sectors = 4096 # 2MB; safely covers typical GPT entry area + print_info(f"Dumping GPT header/table ({gpt_sectors} sectors)...") + if not emmc_read_to_file(gpt_path, 0, gpt_sectors): + return False + + partitions = parse_gpt_partitions(gpt_path) + if not partitions: + print_error("No partitions found in GPT dump") + return False + + var_partition = find_var_partition(partitions) + if not var_partition: + print_error("Could not identify /var partition from GPT") + return False + + print_success( + f"Identified /var partition: {var_partition.number} " + f"(start=0x{var_partition.start_sector:x}, sectors={var_partition.size_sectors})" + ) + + # Dump /var partition only + original_var_path = WORK_DIR / "var_partition_original.bin" + var_partition_path = WORK_DIR / "var_partition.bin" + backup_var_path = WORK_DIR / "var_partition_backup.bin" + + print_info("Dumping /var partition only (this is much smaller than a full eMMC dump)...") + if not emmc_read_to_file(original_var_path, var_partition.start_sector, var_partition.size_sectors): + return False + + shutil.copy(original_var_path, var_partition_path) + shutil.copy(original_var_path, backup_var_path) + print_info(f"Backup created: {backup_var_path}") + + # Modify mode.json inside /var + if not modify_var_partition(var_partition_path): + return False + + # Re-check connectivity (optional) + if not args.skip_detection: + print_info("Please ensure Jibo is still in RCM mode") + if not wait_for_jibo_rcm(timeout=60): + print_warning("Continuing anyway...") + + # Write back: patch by default, full write if requested + if args.full_var_write: + print_info("Writing full /var partition back to device...") + if not write_partition_to_emmc(var_partition_path, var_partition.start_sector): + return False + else: + print_info("Writing only changed sectors back to device (patch write)...") + if not write_partition_patch_to_emmc(original_var_path, var_partition_path, var_partition.start_sector): + return False + + # Verify (reads back full /var; optional) + if args.verify: + if not verify_write(var_partition_path, var_partition.start_sector, var_partition.size_sectors): + print_warning("Verification failed, but write may still be successful") + + print(f"\n{Colors.GREEN}{Colors.BOLD}Mode.json update complete!{Colors.RESET}") + print_info(f"Saved originals in: {WORK_DIR}") + print_info("If Jibo boots to a checkmark, SSH should work.") + return True + + # ============================================================================ # CLI # ============================================================================ @@ -1130,6 +1442,8 @@ Examples: help="Only dump the eMMC without modifying") mode_group.add_argument("--write-partition", metavar="FILE", help="Write a partition file to Jibo (requires --start-sector)") + mode_group.add_argument("--mode-json-only", action="store_true", + help="Fast mode: dump GPT + /var only, patch /var/jibo/mode.json, write back minimal changes") # Options parser.add_argument("--dump-path", metavar="FILE", @@ -1148,6 +1462,8 @@ Examples: help="Verify write by reading back (default: True)") parser.add_argument("--no-verify", action="store_false", dest="verify", help="Skip write verification") + parser.add_argument("--full-var-write", action="store_true", default=False, + help="With --mode-json-only: write entire /var partition instead of patch-writing changed sectors") args = parser.parse_args() @@ -1162,6 +1478,8 @@ Examples: elif args.write_partition: args.partition = args.write_partition success = run_write_only(args) + elif args.mode_json_only: + success = run_mode_json_only(args) else: success = run_full_mod(args) diff --git a/jibo_gui.bat b/jibo_gui.bat new file mode 100644 index 0000000..4f01b7f --- /dev/null +++ b/jibo_gui.bat @@ -0,0 +1,21 @@ +@echo off +setlocal + +REM Main GUI launcher (Main Panel) + +set "SCRIPT_DIR=%~dp0" +set "VENV_PY=%SCRIPT_DIR%.venv\Scripts\python.exe" + +if exist "%VENV_PY%" ( + "%VENV_PY%" -m gui.main_panel %* + exit /b %ERRORLEVEL% +) + +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + py -3 -m gui.main_panel %* + exit /b %ERRORLEVEL% +) + +python -m gui.main_panel %* +exit /b %ERRORLEVEL% diff --git a/jibo_gui.sh b/jibo_gui.sh new file mode 100755 index 0000000..4bb6e70 --- /dev/null +++ b/jibo_gui.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Main GUI launcher (Main Panel) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PY="$SCRIPT_DIR/.venv/bin/python" +if [[ -x "$PY" ]]; then + exec "$PY" -m gui.main_panel "$@" +fi + +if command -v python3 >/dev/null 2>&1; then + exec python3 -m gui.main_panel "$@" +fi + +exec python -m gui.main_panel "$@" diff --git a/jibo_updater.bat b/jibo_updater.bat new file mode 100644 index 0000000..6268abf --- /dev/null +++ b/jibo_updater.bat @@ -0,0 +1,23 @@ +@echo off +setlocal + +REM Wrapper for jibo_updater.py +REM Prefers the repo-local venv if it exists. + +set "SCRIPT_DIR=%~dp0" +set "VENV_PY=%SCRIPT_DIR%.venv\Scripts\python.exe" + +if exist "%VENV_PY%" ( + "%VENV_PY%" "%SCRIPT_DIR%jibo_updater.py" %* + exit /b %ERRORLEVEL% +) + +REM Prefer the Python launcher if available +where py >nul 2>nul +if %ERRORLEVEL%==0 ( + py -3 "%SCRIPT_DIR%jibo_updater.py" %* + exit /b %ERRORLEVEL% +) + +python "%SCRIPT_DIR%jibo_updater.py" %* +exit /b %ERRORLEVEL% diff --git a/jibo_updater.py b/jibo_updater.py new file mode 100644 index 0000000..1b67520 --- /dev/null +++ b/jibo_updater.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""Jibo OS Updater + +Downloads the latest JiboOs release from the configured Gitea instance, +extracts it, then uploads the contents of the release "build" folder into +Jibo's root filesystem over SFTP. + +High-level flow: +1) Check latest release +2) Download + extract archive +3) SSH into Jibo (root / password) +4) Remount / as read-write +5) SFTP upload build/ contents into / +6) Optionally switch /var/jibo/mode.json back to "normal" + +This tool assumes your Jibo is already modded and reachable via SSH. +""" + +from __future__ import annotations + +import argparse +import json +import os +import posixpath +import re +import shutil +import sys +import tarfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +import paramiko + + +SCRIPT_DIR = Path(__file__).parent.resolve() +WORK_DIR = SCRIPT_DIR / "jibo_work" +UPDATES_DIR = WORK_DIR / "updates" +STATE_FILE_DEFAULT = WORK_DIR / "update_state.json" + +DEFAULT_RELEASES_API = "https://kevinblog.sytes.net/Code/api/v1/repos/Kevin/JiboOs/releases" + + +class Colors: + RED = "\033[91m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + BLUE = "\033[94m" + CYAN = "\033[96m" + RESET = "\033[0m" + BOLD = "\033[1m" + + +def _no_color_if_not_tty() -> None: + if not sys.stdout.isatty(): + for attr in dir(Colors): + if attr.startswith("_"): + continue + setattr(Colors, attr, "") + + +def print_info(msg: str) -> None: + print(f"{Colors.CYAN}ℹ {msg}{Colors.RESET}") + + +def print_success(msg: str) -> None: + print(f"{Colors.GREEN}✓ {msg}{Colors.RESET}") + + +def print_warning(msg: str) -> None: + print(f"{Colors.YELLOW}⚠ {msg}{Colors.RESET}") + + +def print_error(msg: str) -> None: + print(f"{Colors.RED}✗ {msg}{Colors.RESET}") + + +def prompt_yes_no(question: str, default: bool = False) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + while True: + ans = input(f"{question} {suffix} ").strip().lower() + if not ans: + return default + if ans in {"y", "yes"}: + return True + if ans in {"n", "no"}: + return False + print("Please answer y or n.") + + +@dataclass(frozen=True) +class Release: + tag_name: str + name: str + prerelease: bool + tarball_url: str + zipball_url: str + + +def http_get_json(url: str, timeout: int = 20) -> object: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "JiboUpdater/1.0", + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = resp.read() + return json.loads(data.decode("utf-8", errors="replace")) + + +_VERSION_RE = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?") + + +def _version_tuple(tag: str) -> tuple[int, int, int]: + m = _VERSION_RE.match(tag.strip()) + if not m: + return (0, 0, 0) + major = int(m.group(1) or 0) + minor = int(m.group(2) or 0) + patch = int(m.group(3) or 0) + return (major, minor, patch) + + +def get_latest_release(releases_api: str, allow_prerelease: bool) -> Release: + raw = http_get_json(releases_api) + if not isinstance(raw, list) or not raw: + raise RuntimeError(f"Unexpected releases API response from {releases_api}") + + releases: list[Release] = [] + for item in raw: + if not isinstance(item, dict): + continue + prerelease = bool(item.get("prerelease", False)) + if prerelease and not allow_prerelease: + continue + releases.append( + Release( + tag_name=str(item.get("tag_name", "")), + name=str(item.get("name", "")), + prerelease=prerelease, + tarball_url=str(item.get("tarball_url", "")), + zipball_url=str(item.get("zipball_url", "")), + ) + ) + + if not releases: + raise RuntimeError("No releases found (after prerelease filtering)") + + # Gitea usually returns newest first, but sort by semver-ish tag to be safe. + releases.sort(key=lambda r: _version_tuple(r.tag_name), reverse=True) + return releases[0] + + +def normalize_download_url(download_url: str, base_url: str) -> str: + """Force downloads to use the same scheme/host as the API base. + + Some Gitea instances can be configured with a different ROOT_URL than the + externally reachable hostname, which can leak into tarball_url/zipball_url. + """ + + if not download_url: + return download_url + + base = urllib.parse.urlparse(base_url) + dl = urllib.parse.urlparse(download_url) + + # If already matches, keep as-is. + if dl.scheme == base.scheme and dl.netloc == base.netloc: + return download_url + + # If download URL is missing components or has a different host, rewrite it. + return urllib.parse.urlunparse( + (base.scheme, base.netloc, dl.path, dl.params, dl.query, dl.fragment) + ) + + +def _ensure_dirs() -> None: + WORK_DIR.mkdir(parents=True, exist_ok=True) + UPDATES_DIR.mkdir(parents=True, exist_ok=True) + + +def _download(url: str, dest: Path, *, force: bool = False) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists() and not force: + print_info(f"Using cached download: {dest}") + return + + print_info(f"Downloading: {url}") + tmp = dest.with_suffix(dest.suffix + ".part") + + last_err: Optional[BaseException] = None + for attempt in range(1, 4): + try: + if tmp.exists(): + tmp.unlink(missing_ok=True) + + with urllib.request.urlopen(url, timeout=180) as resp: + total = resp.headers.get("Content-Length") + total_int = int(total) if total and total.isdigit() else None + downloaded = 0 + chunk_size = 1024 * 256 + with open(tmp, "wb") as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total_int: + pct = downloaded * 100.0 / total_int + sys.stdout.write( + f"\r {downloaded/1e6:.1f}MB / {total_int/1e6:.1f}MB ({pct:.1f}%)" + ) + sys.stdout.flush() + + if total_int: + sys.stdout.write("\n") + tmp.replace(dest) + print_success(f"Downloaded to {dest}") + return + + except Exception as e: + last_err = e + wait = 2**attempt + print_warning(f"Download attempt {attempt}/3 failed: {e}. Retrying in {wait}s...") + time.sleep(wait) + + if tmp.exists(): + tmp.unlink(missing_ok=True) + raise RuntimeError(f"Download failed after 3 attempts: {last_err}") + + +def _extract(archive: Path, extract_dir: Path, *, force: bool = False) -> Path: + if extract_dir.exists() and force: + shutil.rmtree(extract_dir) + + if extract_dir.exists(): + print_info(f"Using cached extraction: {extract_dir}") + return extract_dir + + extract_dir.mkdir(parents=True, exist_ok=True) + print_info(f"Extracting {archive.name} ...") + + def _is_within(base: Path, target: Path) -> bool: + try: + target.resolve().relative_to(base.resolve()) + return True + except Exception: + return False + + if archive.suffixes[-2:] == [".tar", ".gz"] or archive.suffix == ".tgz": + with tarfile.open(archive, "r:gz") as tf: + for member in tf.getmembers(): + member_path = extract_dir / member.name + if not _is_within(extract_dir, member_path): + raise RuntimeError(f"Unsafe path in tar archive: {member.name}") + # Python 3.14 changes tar default filtering behavior; be explicit. + try: + tf.extractall(extract_dir, filter="data") + except TypeError: + tf.extractall(extract_dir) + elif archive.suffix == ".zip": + with zipfile.ZipFile(archive) as zf: + for member in zf.infolist(): + member_path = extract_dir / member.filename + if not _is_within(extract_dir, member_path): + raise RuntimeError(f"Unsafe path in zip archive: {member.filename}") + zf.extractall(extract_dir) + else: + raise RuntimeError(f"Unsupported archive type: {archive}") + + print_success(f"Extracted to {extract_dir}") + return extract_dir + + +def _iter_build_candidates(root: Path) -> Iterable[Path]: + for path in root.rglob("build"): + if path.is_dir(): + yield path + + +def _score_build_dir(path: Path) -> int: + score = 0 + for name, weight in (("etc", 5), ("opt", 5), ("var", 2), ("usr", 2), ("lib", 1), ("bin", 1)): + if (path / name).exists(): + score += weight + # Prefer build dirs that are under a version folder like V3.1/build + parts = {p.lower() for p in path.parts} + if any(re.fullmatch(r"v\d+(?:\.\d+)*", p, flags=re.IGNORECASE) for p in parts): + score += 2 + return score + + +def find_build_dir(extract_root: Path, explicit: Optional[str]) -> Path: + if explicit: + p = (extract_root / explicit).resolve() + if not p.exists() or not p.is_dir(): + raise RuntimeError(f"--build-path not found: {p}") + return p + + candidates = list(_iter_build_candidates(extract_root)) + if not candidates: + raise RuntimeError( + "Could not find a 'build' folder in the extracted archive. " + "Use --build-path to point to it (relative to the extracted root)." + ) + + candidates.sort(key=_score_build_dir, reverse=True) + best = candidates[0] + + if _score_build_dir(best) == 0 and len(candidates) > 1: + print_warning("Found build folders, but none look like a rootfs overlay (no etc/opt).") + + print_info(f"Using build folder: {best}") + return best + + +def load_state(path: Path) -> dict: + if not path.exists(): + return {} + try: + return json.loads(path.read_text("utf-8")) + except Exception: + return {} + + +def save_state(path: Path, state: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def ssh_connect(host: str, user: str, password: str, timeout: int) -> paramiko.SSHClient: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + hostname=host, + username=user, + password=password, + look_for_keys=False, + allow_agent=False, + timeout=timeout, + banner_timeout=timeout, + auth_timeout=timeout, + ) + return client + + +def ssh_exec(client: paramiko.SSHClient, command: str, timeout: int = 60) -> tuple[int, str, str]: + stdin, stdout, stderr = client.exec_command(command, timeout=timeout) + _ = stdin + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + return code, out, err + + +def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None: + # Create each path component if missing. + parts = [p for p in remote_dir.split("/") if p] + cur = "/" + for part in parts: + cur = posixpath.join(cur, part) + try: + sftp.stat(cur) + except IOError: + sftp.mkdir(cur) + + +def upload_tree( + sftp: paramiko.SFTPClient, + local_root: Path, + remote_root: str = "/", + *, + dry_run: bool = False, +) -> None: + local_root = local_root.resolve() + + paths = sorted(local_root.rglob("*")) + total = len(paths) + sent = 0 + + for p in paths: + rel = p.relative_to(local_root).as_posix() + remote_path = posixpath.join(remote_root, rel) + + if p.is_dir(): + if dry_run: + continue + ensure_remote_dir(sftp, remote_path) + continue + + if p.is_symlink(): + target = os.readlink(p) + if dry_run: + sent += 1 + continue + # Ensure parent exists + ensure_remote_dir(sftp, posixpath.dirname(remote_path)) + try: + # Remove if exists + try: + sftp.remove(remote_path) + except IOError: + pass + sftp.symlink(target, remote_path) + except Exception: + # Fallback: dereference and upload file content + real_path = p.resolve() + sftp.put(str(real_path), remote_path) + sent += 1 + if sent % 200 == 0: + print_info(f"Uploaded {sent}/{total} entries...") + continue + + if p.is_file(): + if dry_run: + sent += 1 + continue + + ensure_remote_dir(sftp, posixpath.dirname(remote_path)) + sftp.put(str(p), remote_path) + try: + mode = p.stat().st_mode & 0o777 + sftp.chmod(remote_path, mode) + except Exception: + pass + + sent += 1 + if sent % 200 == 0: + print_info(f"Uploaded {sent}/{total} entries...") + + print_success(f"Upload complete ({sent} files/links)") + + +def set_mode_json_to_normal(sftp: paramiko.SFTPClient) -> None: + remote = "/var/jibo/mode.json" + try: + with sftp.open(remote, "r") as f: + content = f.read().decode("utf-8", errors="replace") + except IOError as e: + raise RuntimeError(f"Failed to read {remote}: {e}") + + new_content: str + try: + data = json.loads(content) + if not isinstance(data, dict): + raise ValueError("mode.json is not a JSON object") + data["mode"] = "normal" + new_content = json.dumps(data, separators=(",", ": ")) + "\n" + except Exception: + # Fallback for non-standard formatting + new_content = re.sub(r'("mode"\s*:\s*")([^"]+)(")', r'\1normal\3', content) + if new_content == content: + # As a last resort, overwrite with a minimal JSON. + new_content = '{"mode": "normal"}\n' + + with sftp.open(remote, "w") as f: + f.write(new_content.encode("utf-8")) + + +def main() -> int: + _no_color_if_not_tty() + + parser = argparse.ArgumentParser(description="Update a modded Jibo with the latest JiboOs release") + parser.add_argument("--ip", "--host", dest="host", required=True, help="Jibo IP/hostname") + parser.add_argument("--user", default="root", help="SSH username (default: root)") + parser.add_argument("--password", default="jibo", help="SSH password (default: jibo)") + parser.add_argument("--releases-api", default=DEFAULT_RELEASES_API, help="Gitea releases API URL") + + parser.add_argument("--stable", action="store_true", help="Ignore prereleases") + parser.add_argument("--tag", help="Install a specific tag (e.g. v3.3.0) instead of latest") + + parser.add_argument("--build-path", help="Path to build folder inside extracted tree (relative)") + + parser.add_argument("--state-file", type=Path, default=STATE_FILE_DEFAULT, help="Where to store last applied version") + parser.add_argument("--force", action="store_true", help="Re-download and re-install even if version matches") + parser.add_argument("--yes", action="store_true", help="Don’t prompt for confirmation") + parser.add_argument("--dry-run", action="store_true", help="Download/extract + connect, but don’t write files") + + parser.add_argument( + "--return-normal", + action="store_true", + help="After update, set /var/jibo/mode.json mode back to normal (no prompt)", + ) + parser.add_argument( + "--no-return-normal", + action="store_true", + help="After update, do not ask to return to normal mode", + ) + + parser.add_argument("--ssh-timeout", type=int, default=15, help="SSH connect timeout seconds") + + args = parser.parse_args() + + _ensure_dirs() + + allow_prerelease = not args.stable + + print_info("Checking latest release...") + if args.tag: + # Fetch all releases and pick the one matching tag + raw = http_get_json(args.releases_api) + if not isinstance(raw, list): + raise RuntimeError("Unexpected releases API response") + chosen: Optional[Release] = None + for item in raw: + if not isinstance(item, dict): + continue + if str(item.get("tag_name", "")) == args.tag: + chosen = Release( + tag_name=str(item.get("tag_name", "")), + name=str(item.get("name", "")), + prerelease=bool(item.get("prerelease", False)), + tarball_url=str(item.get("tarball_url", "")), + zipball_url=str(item.get("zipball_url", "")), + ) + break + if not chosen: + raise RuntimeError(f"Tag not found in releases: {args.tag}") + release = chosen + else: + release = get_latest_release(args.releases_api, allow_prerelease=allow_prerelease) + + if not release.tag_name or not release.tarball_url: + raise RuntimeError("Release JSON missing tag_name or tarball_url") + + state = load_state(args.state_file) + last = str(state.get(args.host, "")) if isinstance(state, dict) else "" + + print_info(f"Latest: {release.tag_name} ({'prerelease' if release.prerelease else 'stable'})") + if last: + print_info(f"Last applied (from state): {last}") + + if (not args.force) and last and last == release.tag_name: + print_success("Already at latest version (per local state). Use --force to reinstall.") + return 0 + + if not args.yes: + if not prompt_yes_no( + f"This will upload the release build overlay into / on {args.host} and overwrite files. Continue?", + default=False, + ): + print_info("Aborted.") + return 2 + + # Download + extract + archive_name = f"{release.tag_name}.tar.gz" + archive_path = UPDATES_DIR / "downloads" / archive_name + extract_dir = UPDATES_DIR / "extracted" / release.tag_name + + tarball_url = normalize_download_url(release.tarball_url, args.releases_api) + + try: + _download(tarball_url, archive_path, force=args.force) + except urllib.error.URLError as e: + raise RuntimeError(f"Download failed: {e}") + + _extract(archive_path, extract_dir, force=args.force) + + # Gitea archives usually create a single top-level folder. Prefer that as the search root. + children = [p for p in extract_dir.iterdir() if p.is_dir()] + search_root = children[0] if len(children) == 1 else extract_dir + + build_dir = find_build_dir(search_root, args.build_path) + + # Connect and update + print_info(f"Connecting to {args.user}@{args.host} ...") + client = ssh_connect(args.host, args.user, args.password, timeout=args.ssh_timeout) + try: + code, out, err = ssh_exec(client, "sh -c 'touch /.jibo_rw_test 2>/dev/null && rm /.jibo_rw_test 2>/dev/null && echo WRITABLE || echo READONLY'") + if "WRITABLE" in out: + print_info("Root FS already writable") + else: + print_info("Remounting / as read-write...") + code, out, err = ssh_exec(client, "sh -c 'mount -o remount,rw /'", timeout=60) + if code != 0: + print_warning(f"Remount command returned {code}. stderr: {err.strip()}") + code, out, err = ssh_exec(client, "sh -c 'touch /.jibo_rw_test 2>/dev/null && rm /.jibo_rw_test 2>/dev/null && echo WRITABLE || echo READONLY'") + if "WRITABLE" not in out: + raise RuntimeError("Failed to remount / as writable (still READONLY)") + print_success("/ remounted writable") + + if args.dry_run: + print_success("Dry-run: skipping upload") + else: + print_info("Starting SFTP upload (this can take a while)...") + sftp = client.open_sftp() + try: + upload_tree(sftp, build_dir, remote_root="/", dry_run=False) + finally: + sftp.close() + + do_return = False + if args.return_normal: + do_return = True + elif args.no_return_normal: + do_return = False + elif args.yes: + do_return = False + else: + do_return = prompt_yes_no("Return Jibo to normal mode (mode.json: int-developer -> normal)?", default=False) + + if do_return: + if args.dry_run: + print_info("Dry-run: skipping mode.json change") + else: + sftp = client.open_sftp() + try: + set_mode_json_to_normal(sftp) + print_success("Updated /var/jibo/mode.json to normal") + finally: + sftp.close() + + if not args.dry_run: + # Update local state + if isinstance(state, dict): + state[args.host] = release.tag_name + save_state(args.state_file, state) + + print_success(f"Update finished ({release.tag_name})") + return 0 + + finally: + client.close() + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print("\nInterrupted.") + raise SystemExit(130) + except Exception as e: + print_error(str(e)) + raise SystemExit(1) diff --git a/jibo_updater.sh b/jibo_updater.sh new file mode 100755 index 0000000..11f49a0 --- /dev/null +++ b/jibo_updater.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wrapper for jibo_updater.py +# Prefers the repo-local venv if it exists. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PY="$SCRIPT_DIR/.venv/bin/python" +if [[ -x "$PY" ]]; then + exec "$PY" "$SCRIPT_DIR/jibo_updater.py" "$@" +fi + +if command -v python3 >/dev/null 2>&1; then + exec python3 "$SCRIPT_DIR/jibo_updater.py" "$@" +fi + +exec python "$SCRIPT_DIR/jibo_updater.py" "$@" diff --git a/jibo_work/_t1.bin b/jibo_work/_t1.bin new file mode 100644 index 0000000..38dd1b3 --- /dev/null +++ b/jibo_work/_t1.bin @@ -0,0 +1 @@ +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ No newline at end of file diff --git a/jibo_work/_t2.bin b/jibo_work/_t2.bin new file mode 100644 index 0000000..e2e5607 --- /dev/null +++ b/jibo_work/_t2.bin @@ -0,0 +1 @@ +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \ No newline at end of file diff --git a/jibo_work/update_state.json b/jibo_work/update_state.json new file mode 100644 index 0000000..71d8223 --- /dev/null +++ b/jibo_work/update_state.json @@ -0,0 +1,3 @@ +{ + "192.168.1.15": "v3.3.0" +} diff --git a/requirements-gui.txt b/requirements-gui.txt new file mode 100644 index 0000000..0bb08f4 --- /dev/null +++ b/requirements-gui.txt @@ -0,0 +1 @@ +PySide6>=6.7.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8fad050 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +paramiko>=3.4.0