Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion adb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +206 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject failed version queries before parsing output

When the version query fails but its diagnostic contains a 4–6 digit number, this ignores cp.returncode and parses that number as the Magisk version. For example, a transient ADB disconnect can report device '127.0.0.1:5555' not found, causing the ReZygisk install to claim that Magisk 5555 is installed and incorrectly instruct the user to update the root payload, rather than treating the version as unreadable as intended. Return None on a nonzero status and only parse successful version output.

Useful? React with 👍 / 👎.



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)
Expand All @@ -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])
Expand Down
10 changes: 5 additions & 5 deletions lsposed_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -25,16 +25,15 @@
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 = (
"https://github.com/LSPosed/LSPosed/releases/download/v1.9.2/"
"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:
Expand All @@ -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)
5 changes: 0 additions & 5 deletions magisk_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the taskbar identity stable across releases

For users who pin this release and later replace it with an updated executable, constants.APP_ID includes APP_VERSION, so applying it here gives every release a different Windows taskbar identity. The pinned shortcut retains the old identity and the updated process therefore opens under a separate taskbar button—the behavior this change is intended to prevent. Use a version-independent AppUserModelID for this call.

Useful? React with 👍 / 👎.

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__`
Expand Down
18 changes: 10 additions & 8 deletions rezygisk_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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


Expand All @@ -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)
23 changes: 8 additions & 15 deletions tests/test_magisk_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,29 +61,22 @@ 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"))
assert ms._clean_dir_commands("dev", "/x/y", {}) == [
"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", {}) == [
Expand Down
88 changes: 88 additions & 0 deletions tests/test_module_version_gate.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion views/magisk_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down