diff --git a/adb_handler.py b/adb_handler.py index f5d1a67..b2fc5a7 100644 --- a/adb_handler.py +++ b/adb_handler.py @@ -189,15 +189,42 @@ def _shell_single_quote(s: str) -> str: return s.replace("'", "'\\''") +def magisk_version_code(adb_exe: str, serial: str, runner: Runner = _run) -> Optional[int]: + """The running daemon's MAGISK_VER_CODE, or None if it can't be read. + + ``magisk -V`` prints the numeric version code and nothing else. None means + "couldn't tell" (no root shell, Magisk absent, unexpected output) and must + never be treated as "too old" -- a version gate that fires on an unreadable + version would block flashing on any instance whose shell is momentarily + unavailable. + """ + try: + cp = runner([adb_exe, "-s", serial, "shell", "su", "-c", "magisk -V"]) + except Exception: # noqa: BLE001 - unreadable version is not a failure + logger.debug("magisk -V failed", exc_info=True) + return None + out = ((cp.stdout or "") + (cp.stderr or "")).strip() + m = re.search(r"\b(\d{4,6})\b", out) + return int(m.group(1)) if m else None + + def install_module(adb_exe: str, port: Optional[int], local_zip: str, progress: Optional[Callable[[str], None]] = None, - runner: Runner = _run) -> str: + runner: Runner = _run, + min_magisk_ver_code: Optional[int] = None) -> str: """Push ``local_zip`` to a running instance and flash it via Magisk directly. Runs ``magisk --install-module`` over an ADB root shell (the same command we flash by hand). On success the module is installed and only needs a reboot. If the root shell / Magisk isn't reachable, the zip is left in the guest's Download folder and a RuntimeError explains how to flash it manually. + + ``min_magisk_ver_code`` is the module's own MAGISK_VER_CODE requirement (its + ``customize.sh`` enforces one and aborts mid-flash otherwise). Checking it + here turns that into a clear refusal before anything is pushed. Callers that + have no requirement pass nothing and no check runs; an *unreadable* version + also proceeds, since a gate that fires on "couldn't tell" would block flashes + on a healthy instance. """ def _p(msg): logger.info(msg) @@ -217,6 +244,15 @@ def _p(msg): _p("Confirming ADB root access...") _ensure_su_policy(adb_exe, serial, runner) + if min_magisk_ver_code is not None: + have = magisk_version_code(adb_exe, serial, runner) + if have is not None and have < min_magisk_ver_code: + raise RuntimeError( + "%s needs Magisk %d or newer, but this instance is running %d. " + "Update the root payload first -- flashing now would fail partway " + "through the module's own install script." + % (name, min_magisk_ver_code, have)) + tmp = "/data/local/tmp/" + name _p("Pushing %s..." % name) cp = runner([adb_exe, "-s", serial, "push", local_zip, tmp]) diff --git a/lsposed_payload.py b/lsposed_payload.py index 5cac2a0..1d32e2a 100644 --- a/lsposed_payload.py +++ b/lsposed_payload.py @@ -14,7 +14,7 @@ fetched from LSPosed's own GitHub release, SHA-256-verified, and cached. We pin the **zygisk** variant (the riru variant is for the older Riru loader we don't use). Bumping the version is a one-line change: update ``MODULE_URL`` + -``MODULE_SHA256`` (+ ``MODULE_SIZE``) together. +``MODULE_SHA256`` + ``MODULE_VERSION`` together. Credit: LSPosed (c) LSPosed Developers, GPLv3. """ @@ -25,7 +25,7 @@ import payload_fetch # --- Pinned module (official release, hash-locked) ------------------------- -# One-line version bump: change URL + SHA256 (+ SIZE) together. Use the *zygisk* +# One-line version bump: change URL + SHA256 + VERSION together. Use the *zygisk* # asset, not the riru one. MODULE_NAME = "LSPosed-v1.9.2-7024-zygisk-release.zip" MODULE_URL = ( @@ -33,8 +33,7 @@ "LSPosed-v1.9.2-7024-zygisk-release.zip" ) MODULE_SHA256 = "0ebc6bcb465d1c4b44b7220ab5f0252e6b4eb7fe43da74650476d2798bb29622" -MODULE_SIZE = 2462055 -MODULE_VERSION = "v1.9.2 (7024)" # human-readable; move in lockstep with the pin +MODULE_VERSION = "v1.9.2 (7024)" # shown in progress; move in lockstep with the pin def fetch_module(cache_dir: str, progress=None) -> str: @@ -48,4 +47,5 @@ def fetch_module(cache_dir: str, progress=None) -> str: os.makedirs(cache_dir, exist_ok=True) dest = os.path.join(cache_dir, MODULE_NAME) return payload_fetch.fetch_verified( - MODULE_URL, dest, MODULE_SHA256, label="LSPosed module", progress=progress) + MODULE_URL, dest, MODULE_SHA256, + label="LSPosed %s" % MODULE_VERSION, progress=progress) diff --git a/magisk_system.py b/magisk_system.py index 987a1e5..a82e32c 100644 --- a/magisk_system.py +++ b/magisk_system.py @@ -208,11 +208,6 @@ def _list_dir_typed(device: str, ext4_dir: str, env: dict) -> list[tuple[str, bo return entries -def _list_dir(device: str, ext4_dir: str, env: dict) -> list[str]: - """Names currently inside ``ext4_dir`` (empty if it's absent).""" - return [name for name, _ in _list_dir_typed(device, ext4_dir, env)] - - def _clean_dir_commands(device: str, ext4_dir: str, env: dict) -> list[str]: """debugfs commands to remove whatever is *actually* in ``ext4_dir`` and then the dir itself -- covers a prior/foreign install, not just our own names. diff --git a/main.py b/main.py index 50e982e..be5dbdd 100644 --- a/main.py +++ b/main.py @@ -54,6 +54,19 @@ try: logger.info("Starting %s (admin=%s, log=%s)", constants.APP_NAME, admin.is_admin(), LOG_PATH) + + # Declare our own taskbar identity before any window exists. Without + # this Windows groups the window under whatever host process it sees + # (python.exe when run from source), so the taskbar shows the wrong + # icon and a pinned shortcut opens a second, separate button. Must + # happen before the first window is created to take effect. + try: + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + constants.APP_ID) + except Exception: # noqa: BLE001 - cosmetic only, never block startup + logger.debug("could not set AppUserModelID", exc_info=True) + app = QApplication(sys.argv) # Held for the lifetime of the process (module-level `if __name__` diff --git a/rezygisk_payload.py b/rezygisk_payload.py index e86c36b..8979ea3 100644 --- a/rezygisk_payload.py +++ b/rezygisk_payload.py @@ -10,7 +10,7 @@ Like the Magisk payload, the module is **downloaded on demand, not vendored**: this project fetches a specific, hash-pinned release from ReZygisk's own GitHub, verifies it by SHA-256, and caches it. Bumping the version is a one-line change: -update ``MODULE_URL`` + ``MODULE_SHA256`` (+ ``MODULE_SIZE``) together. +update ``MODULE_URL`` + ``MODULE_SHA256`` + ``MODULE_VERSION`` together. Credit: ReZygisk (c) The PerformanC Organization, GPLv3. """ @@ -21,19 +21,20 @@ import payload_fetch # --- Pinned module (official release, hash-locked) ------------------------- -# One-line version bump: change URL + SHA256 (+ SIZE) together. +# One-line version bump: change URL + SHA256 + VERSION together. MODULE_NAME = "ReZygisk-v1.0.0-release.zip" MODULE_URL = ( "https://github.com/PerformanC/ReZygisk/releases/download/v1.0.0/" "ReZygisk-v1.0.0-release.zip" ) MODULE_SHA256 = "7904649b8dcaf2b060c3432df4fee302aeb1258da199d2b425335e82d49510e6" -MODULE_SIZE = 506334 -MODULE_VERSION = "v1.0.0 (515)" # human-readable; move in lockstep with the pin +MODULE_VERSION = "v1.0.0 (515)" # shown in progress; move in lockstep with the pin -# ReZygisk's customize.sh requires Magisk >= this (MAGISK_VER_CODE). The pinned -# Kitsune payload is 27001, comfortably above it; surfaced here so a Magisk -# version bump that dips below is caught rather than failing mid-flash. +# ReZygisk's customize.sh requires Magisk >= this (MAGISK_VER_CODE) and aborts +# partway through its own install script otherwise. Enforced before anything is +# pushed -- adb_handler.install_module takes this as min_magisk_ver_code, so a +# payload that ever dips below produces a clear refusal instead of a failed +# flash. The pinned Kyubi payload is 31000, comfortably above it. MIN_MAGISK_VER_CODE = 26402 @@ -48,4 +49,5 @@ def fetch_module(cache_dir: str, progress=None) -> str: os.makedirs(cache_dir, exist_ok=True) dest = os.path.join(cache_dir, MODULE_NAME) return payload_fetch.fetch_verified( - MODULE_URL, dest, MODULE_SHA256, label="ReZygisk module", progress=progress) + MODULE_URL, dest, MODULE_SHA256, + label="ReZygisk %s" % MODULE_VERSION, progress=progress) diff --git a/tests/test_magisk_system.py b/tests/test_magisk_system.py index e68825a..3eb3032 100644 --- a/tests/test_magisk_system.py +++ b/tests/test_magisk_system.py @@ -61,17 +61,6 @@ def run(cmd, env=None, **k): return run -def test_list_databin_parses_names_skipping_dots_and_symlink_targets(monkeypatch): - sample = "\n".join([ - " 3801090 40700 (2) 0 0 4096 19-Jul-2026 12:38 .", - " 3801089 40700 (2) 0 0 4096 19-Jul-2026 12:06 ..", - " 3801091 100755 (1) 0 0 2260144 19-Jul-2026 12:38 busybox", - " 3801092 120777 (7) 0 0 9 19-Jul-2026 12:38 sulink -> busybox", - ]) - monkeypatch.setattr(ms._es, "_run", _fake_run(sample)) - assert ms._list_dir("dev", ms._DATABIN, {}) == ["busybox", "sulink"] - - def test_clean_dir_commands_removes_actual_contents_then_rmdir(monkeypatch): monkeypatch.setattr(ms._es, "_run", _fake_run( " 1 100755 (1) 0 0 5 d t foo\n 2 100755 (1) 0 0 5 d t bar\n")) @@ -79,11 +68,15 @@ def test_clean_dir_commands_removes_actual_contents_then_rmdir(monkeypatch): "rm /x/y/foo", "rm /x/y/bar", "rmdir /x/y"] -def test_list_dir_typed_flags_directories_not_files_or_symlinks(monkeypatch): +def test_list_dir_typed_flags_dirs_skips_dots_and_strips_symlink_targets(monkeypatch): + # "." / ".." must be skipped or a clean-out would try to rm the directory + # from inside itself; a symlink must yield its own name, not its target. sample = "\n".join([ - " 1 40755 (2) 0 0 4096 19-Jul-2026 12:38 chromeos", # dir - " 2 100755 (1) 0 0 5 19-Jul-2026 12:38 busybox", # file - " 3 120777 (7) 0 0 9 19-Jul-2026 12:38 s -> busybox", # symlink + " 1 40700 (2) 0 0 4096 19-Jul-2026 12:38 .", + " 2 40700 (2) 0 0 4096 19-Jul-2026 12:06 ..", + " 3 40755 (2) 0 0 4096 19-Jul-2026 12:38 chromeos", # dir + " 4 100755 (1) 0 0 5 19-Jul-2026 12:38 busybox", # file + " 5 120777 (7) 0 0 9 19-Jul-2026 12:38 s -> busybox", # symlink ]) monkeypatch.setattr(ms._es, "_run", _fake_run(sample)) assert ms._list_dir_typed("dev", "/x", {}) == [ diff --git a/tests/test_module_version_gate.py b/tests/test_module_version_gate.py new file mode 100644 index 0000000..62d0ee2 --- /dev/null +++ b/tests/test_module_version_gate.py @@ -0,0 +1,88 @@ +"""The Magisk version gate on install_module. + +A module's own customize.sh enforces a MAGISK_VER_CODE minimum and aborts partway +through if it isn't met, leaving a half-written module directory. Checking before +anything is pushed turns that into a clean refusal. + +The subtle requirement is the failure direction: an *unreadable* version must let +the flash proceed. A gate that fired on "couldn't tell" would block flashing on a +healthy instance whose shell was momentarily unavailable -- worse than the problem +it prevents. These pin that direction, which is easy to invert in a refactor. +""" +from types import SimpleNamespace + +import pytest + +from adb_handler import install_module, magisk_version_code + + +def _cp(stdout="", stderr="", rc=0): + return SimpleNamespace(stdout=stdout, stderr=stderr, returncode=rc) + + +def _runner(version_out, version_rc=0): + """Fake adb: resolves a serial, answers `magisk -V`, succeeds otherwise.""" + calls = [] + + def runner(cmd): + calls.append(cmd) + joined = " ".join(str(c) for c in cmd) + if "magisk -V" in joined: + return _cp(stdout=version_out, rc=version_rc) + if "connect" in joined: + return _cp(stdout="connected to 127.0.0.1:5555") + if "devices" in joined: + return _cp(stdout="List of devices attached\n127.0.0.1:5555\tdevice\n") + return _cp() + + runner.calls = calls + return runner + + +@pytest.fixture() +def zip_path(tmp_path): + p = tmp_path / "ReZygisk-v1.0.0-release.zip" + p.write_bytes(b"PK\x03\x04stub") + return str(p) + + +def test_reads_numeric_version_code(): + assert magisk_version_code("adb", "s", _runner("31000\n")) == 31000 + + +def test_unreadable_version_reports_none_not_zero(): + # None means "couldn't tell"; 0 would compare as hopelessly old and block. + assert magisk_version_code("adb", "s", _runner("su: not found", 127)) is None + + +def test_refuses_when_daemon_is_older_than_the_module_requires(zip_path): + runner = _runner("26000\n") + with pytest.raises(RuntimeError) as exc: + install_module("adb", 5555, zip_path, runner=runner, + min_magisk_ver_code=26402) + msg = str(exc.value) + assert "26402" in msg and "26000" in msg + # Refusal must come BEFORE anything is pushed to the guest. + assert not any("push" in " ".join(str(c) for c in cmd) for cmd in runner.calls) + + +def test_proceeds_when_daemon_meets_the_minimum(zip_path): + runner = _runner("31000\n") + install_module("adb", 5555, zip_path, runner=runner, min_magisk_ver_code=26402) + assert any("--install-module" in " ".join(str(c) for c in cmd) + for cmd in runner.calls) + + +def test_proceeds_when_version_is_unreadable(zip_path): + # The important direction: fail open, not closed. + runner = _runner("su: not found", 127) + install_module("adb", 5555, zip_path, runner=runner, min_magisk_ver_code=26402) + assert any("--install-module" in " ".join(str(c) for c in cmd) + for cmd in runner.calls) + + +def test_no_minimum_means_no_version_query_at_all(zip_path): + runner = _runner("31000\n") + install_module("adb", 5555, zip_path, runner=runner) + assert not any("magisk -V" in " ".join(str(c) for c in cmd) + for cmd in runner.calls) diff --git a/views/magisk_controller.py b/views/magisk_controller.py index 1c1a19c..ceff22a 100644 --- a/views/magisk_controller.py +++ b/views/magisk_controller.py @@ -323,7 +323,9 @@ def job(progress): relay = StepReporter(progress, _STEPS_MODULE) relay("Fetching ReZygisk...") zip_path = rezygisk_payload.fetch_module(self._cache_dir(), progress=relay) - msg = adb_handler.install_module(adb_exe, port, zip_path, progress=relay) + msg = adb_handler.install_module( + adb_exe, port, zip_path, progress=relay, + min_magisk_ver_code=rezygisk_payload.MIN_MAGISK_VER_CODE) w.show_notice.emit("ReZygisk installed", msg) return "%s Reboot the instance to activate Zygisk." % msg