Skip to content

Offline Magisk-to-system: full root, modules, and Zygisk — with a Magisk tab#54

Merged
RobThePCGuy merged 15 commits into
masterfrom
feature/magisk-system
Jul 20, 2026
Merged

Offline Magisk-to-system: full root, modules, and Zygisk — with a Magisk tab#54
RobThePCGuy merged 15 commits into
masterfrom
feature/magisk-system

Conversation

@RobThePCGuy

Copy link
Copy Markdown
Owner

Adds a fully offline Magisk-to-system installer for BlueStacks 5 patch-mode instances (5.22.150+), plus a Magisk tab in the GUI to run it. Everything writes while the instance is shut down — no R/W toggle, no temp-root, no in-guest taps — and it's reversible.

What it does

  • Writes Magisk's /system footprint into Root.vhd and /data/adb/magisk into Data.vhdx offline via the bundled debugfs. Boots straight to a running magiskd with su = uid 0.
  • Replays Magisk's own captured output (bootanim.rc + config) and derives every binary/script from a pinned, SHA-256-verified Kitsune Delta 27.001 APK fetched at runtime. Nothing is vendored (GPLv3-clean).
  • Stages the complete DATABIN (util_functions.sh, the chromeos signing keys, the scripts), so magisk --install-module works and modules land.
  • Installs the manager as a normal user app over ADB after first boot. The offline shortcuts don't hold up: the stub self-downloads its UI from a dead URL, and a /system/app manager trips Magisk's "Abnormal State."
  • Pins ReZygisk v1.0.0 (standalone ptrace Zygisk) — built-in Zygisk doesn't work on the emulator; ReZygisk injects libzygisk into the zygotes.
  • New Magisk tab: pick an instance, see its install state, Install/Uninstall Magisk (offline, gated on a patched engine), and Install the manager app. The tab shows only the actions that fit the current state.

Proven live

On a fresh clean Android 13 instance: offline install → magiskd + su, the manager installs clean (no Abnormal State), magisk --install-module lands ReZygisk, and after a reboot libzygisk is mapped into both the 64-bit and 32-bit zygotes.

The Play Integrity note in the UI is honest: Basic and Device are within reach with the right modules; STRONG relies on a hardware-backed keystore emulators don't have.

Tests

141 tests green — payload, offline system staging, ADB manager, and the GUI tab. Payloads download at runtime and are hash-pinned; the only binaries added are three tiny captured boot assets (~1.4 KB).

Download-on-demand (nothing vendored; Magisk is GPLv3) of the hash-pinned
Kitsune Delta 27.001 APK from the project's own release, SHA-256 verified
and cached, plus extraction of the x86_64 native tools (busybox/magisk64/
magiskinit/magiskpolicy/magiskboot) + x86 magisk32, renamed to the plain
names Magisk's daemon expects in /data/adb/magisk. Version bump = one line.

Network-free unit tests (synthetic APK + monkeypatched download); live smoke
verified against the real release earlier.
Reuses ext4_symlink's diskpart-attach + debugfs recipe against Data.vhdx:
mkdir /data/adb/magisk, write the six Kitsune tools root-owned + 0755,
e2fsck-verify, detach. busybox lands executable so Magisk's daemon clears
its access(busybox, X_OK) gate.

Two debugfs traps handled + regression-tested: the Cygwin debugfs needs a
Cygwin source path (_cygpath), and 'write' links its dest as a bare filename
in the CWD (so cd into the dir first, write bare names). Live-validated on a
copy of a real Tiramisu64 Data.vhdx: all six tools 0755 root:root, e2fsck
clean, write persists across detach.
magisk_system.py:
- quote the debugfs write source path so a Windows profile with a space
  (C:\Users\John Doe\...) no longer silently fails to stage (live-verified)
- verify ALL six tools (mode + root ownership, anchored to the Mode: field),
  not just busybox -- a partial stage can no longer report success
- roll back the partial DATABIN on any failure (no one-way door)
- cleanup enumerates the dir's real contents, so a prior/foreign Magisk
  install is fully replaced instead of leaving orphans
- write a .magisk_system.json provenance manifest (payload hash + tools)
- unstage now checks e2fsck and uses the same enumerate-based removal

ext4_symlink.py (shared machinery, pre-existing gap):
- _detach now retries and verifies the disk is actually gone; _Attached
  surfaces a detach failure instead of leaving Data.vhdx mounted as a raw
  disk (which could block the next boot). Raises only when no exception is
  already in flight.
- extracted shared _run_script/_stat_path helpers; de-duped the classic
  su-symlink path onto them (behavior-identical)

magisk_payload.py:
- extract_tools names the missing tool and cleans partial output
- fetch_apk re-downloads when a cached file is unreadable instead of throwing

102 tests pass (14 magisk). Space-path staging live-validated on a copy of a
real Tiramisu64 Data.vhdx.
install_to_system() writes Magisk's genuine system-mode footprint offline into
Root.vhd (/android/system/etc/init): the magisk/ dir (config + magisk32/64/
init/policy + stub.apk, 0700 root) plus the hijacked bootanim.rc (0664 system)
and bootanim.rc.gz backup. Reuses the Phase-1 debugfs recipe (quoted src, cd +
bare-name write, enumerate-clean, verify, e2fsck, rollback). uninstall_from_
system() restores the stock bootanim.rc from the pinned original.

The footprint is captured from Magisk's OWN Install-to-System (once, per pinned
version) rather than reverse-engineered: binaries + stub.apk come from the
pinned APK (byte-identical, verified); config + bootanim.rc + bootanim.rc.gz are
tiny captured assets in magisk_assets/kitsune-27.001/. magisk_payload gains
extract_stub_apk(); cleanup helpers generalized to any dir.

103 tests pass. Write-validated on a copy of a real clean Root.vhd: full
footprint 0700 root + bootanim.rc 0664 system, e2fsck clean.
The captured system-install assets (bootanim.rc init script, config, and the
gzip backup) must stay byte-exact; CRLF conversion would break them on the
guest. Mark magisk_assets as -text (+ .gz binary).
Magisk's Install-to-System only writes a stub manager that self-downloads
the full UI from an abandoned HuskyDG URL (404), so it comes up greyed and
non-functional offline. install_to_system() now also writes the pinned full
Kitsune APK to /system/app/KitsuneMask/KitsuneMask.apk, so PackageManager
installs a working manager at boot before magiskd's stub check ever fires.
uninstall_from_system() removes it too.

Boot-validated on a clean Tiramisu64: offline install -> boot -> colored,
working Kitsune manager (pm path = /system/app), Magisk 27.001, su = uid 0.
No app install, no taps during the offline write.
…ase 3)

- install()/uninstall(): one call does the full offline flow (fetch payload ->
  /system + manager + DATABIN -> manifest) and its reverse. Engine patch stays
  the caller's job.
- Unified install manifest (.magisk_system.json): payload hash + version +
  components + timestamp; magisk_status() reads it so the GUI can show state.
- Clone/shared-image handling: _resolve_root_vhd() finds the instance's own
  Root.vhd or, for a clone/fresh instance that shares the master's system image,
  follows the .bstk's VHD HardDisk location -- with a clear error if none, since
  installing to a shared /system affects every instance of that Android version.

108 tests pass (clone resolution + manifest round-trip covered).
…stub fix)

The Phase-2 /system/app manager preinstall was wrong: Magisk throws
"Abnormal State: system app not supported" for a manager in /system/app,
and writing the full APK as stub.apk yields a broken, unselectable app. All
three offline manager tricks fail (stub self-download 404s -> greyed;
/system/app -> Abnormal; full-as-stub -> broken).

install_to_system now writes only the genuine stub footprint; the working
manager must be pm-installed as a USER app after first boot (documented in
the docstring). Offline root needs no manager -- proven: magiskd + su work
without it; the app is just a normal app.

108 tests pass.
`magisk --install-module` aborts "Incomplete Magisk install" unless
/data/adb/magisk holds util_functions.sh (it defines install_module and
every helper it calls). Stage the full DATABIN, not just the 6 binaries:
add the APK-derived support files a real install carries alongside them --
util_functions.sh, boot_patch.sh, addon.d.sh, stub.apk, and the chromeos/
signing keys (futility + kernel.keyblock + kernel_data_key.vbprivk).

All static APK content (version baked into util_functions.sh), so like the
binaries they're fetched from the pinned APK -- nothing new captured.

- magisk_payload: extract_databin_extras() pulls the 7 support files,
  preserving the chromeos/ subdir; partial-extract cleanup on failure.
- magisk_system: _databin_extra_commands() writes them (chromeos/ as a real
  subdir); _clean_dir_commands() now recurses so the subdir is emptied and
  rmdir'd before its parent (rm won't remove a dir) -- fixes rollback and
  uninstall for the nested layout; _verify_staged() checks the extras'
  mode/owner; stage_databin() takes extras and writes the complete DATABIN.

Scripts/binaries 0755, data blobs (stub.apk, chromeos keys) 0644, all
root:root. 114 tests green; debugfs script verified against the real
27.001 APK offline. Live disk write still needs a clean instance + admin.
The full Magisk/Kitsune manager can't be placed offline (its stub
self-downloads from a dead URL; a /system/app manager trips Magisk's
"Abnormal State"), so it's installed as a normal user app after first boot.

- install_manager(): `adb install -r` the pinned APK against the running
  instance's serial; a signature mismatch (a different-signed manager
  already present) gets an actionable message; longer 180s timeout since
  the install streams ~13 MB then runs dexopt.
- uninstall_manager(): removes io.github.huskydg.magisk; "not installed"
  is treated as success (idempotent), for a clean reinstall.
- MANAGER_PACKAGE pulled from the APK manifest, not guessed.

120 tests green.
…ase 4)

BlueStacks' guest kernel doesn't support Magisk's built-in Zygisk, so the
Zygisk API (needed by Play Integrity Fix etc.) comes from ReZygisk, a
ptrace-based standalone implementation. It's a normal Magisk module: with the
DATABIN now complete (util_functions.sh present), it flashes via the existing
`magisk --install-module` path (adb_handler.install_module).

rezygisk_payload mirrors magisk_payload: download-at-runtime, nothing vendored,
SHA-256-verified, cached. Pinned to the official PerformanC/ReZygisk v1.0.0
release (confirmed to serve the pinned bytes). Its customize.sh needs Magisk
>= 26402; our Kitsune is 27001, and MIN_MAGISK_VER_CODE surfaces that so a
future Magisk downgrade is caught rather than failing mid-flash.

123 tests green. Live flash still needs a clean instance.
Adds a "Magisk" nav-rail page that drives the proven offline pipeline from
the app, retiring the scratchpad harnesses:

- Pick an instance; the page shows its live Magisk state from
  magisk_system.magisk_status() (version + components, or "not installed").
- Install Magisk (system root): offline install() on a shut-down instance,
  gated on a patched engine for patch-mode builds (the modified /system only
  boots patched); warns if app-root is on (competing su).
- Uninstall Magisk: offline uninstall(), restoring the stock boot sequence.
- Install manager app: adb install of the pinned APK against the running
  instance (fetch_apk + install_manager), with an ADB-not-found guard.

All long ops run through the existing _run_async worker (the app is already
elevated, so disk writes run in-process); the page's buttons join the
busy-disable set and re-derive enabled state on selection like the Modules
page. Honest integrity copy on the page: Basic/Device reachable, STRONG
impossible on any emulator.

141 tests green (+18: page logic, main-window gating/wiring, nav rail).
Per feedback: hide the buttons that don't fit the instance's state instead
of grey-disabling them -- no Install button once Magisk is installed (only
Uninstall + Install manager), and only Install before it is. A present-but-
disabled button reads as "you could do this" when you can't.

Also reword the integrity note so it doesn't call STRONG "impossible" --
it's that STRONG relies on a hardware-backed keystore emulators don't have,
so the honest target here is Basic/Device.

141 tests green.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces offline system-root installation and uninstallation of Magisk and ReZygisk for BlueStacks instances, including UI integration and comprehensive unit tests. The review feedback highlights several improvement opportunities: filtering out non-numeric lines in _list_dir_typed to prevent parsing debugfs error messages as bogus files, tracking extracted files before copying to ensure partial writes are cleaned up on failure, and replacing urlretrieve with urlopen using explicit timeouts to avoid indefinite hangs during payload downloads.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread magisk_system.py
Comment on lines +176 to +185
for line in out.splitlines():
parts = line.split()
if not parts:
continue
# symlink lines read "... name -> target"; take the name, not the target
name = parts[parts.index("->") - 1] if "->" in parts else parts[-1]
if name in (".", ".."):
continue
mode = parts[1] if len(parts) > 1 else ""
entries.append((name, mode.startswith("40")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The _list_dir_typed function parses every line of the debugfs ls -l output, including error messages (e.g., ls: File not found... when the directory does not exist). This causes it to parse the error message as a bogus file entry (e.g., named 'filename'), which subsequently leads to executing unnecessary or failing rm commands.

Since a valid ls -l listing line always starts with a numeric inode, we can robustly filter out error messages and headers by checking if the first column consists of digits.

Suggested change
for line in out.splitlines():
parts = line.split()
if not parts:
continue
# symlink lines read "... name -> target"; take the name, not the target
name = parts[parts.index("->") - 1] if "->" in parts else parts[-1]
if name in (".", ".."):
continue
mode = parts[1] if len(parts) > 1 else ""
entries.append((name, mode.startswith("40")))
for line in out.splitlines():
parts = line.split()
if not parts or not parts[0].isdigit():
continue
# symlink lines read "... name -> target"; take the name, not the target
name = parts[parts.index("->") - 1] if "->" in parts else parts[-1]
if name in (".", ".."):
continue
mode = parts[1] if len(parts) > 1 else ""
entries.append((name, mode.startswith("40")))

Comment thread magisk_payload.py
Comment on lines +145 to +148
target = os.path.join(dest_dir, tool)
with z.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
out[tool] = target

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If open(target, "wb") or shutil.copyfileobj fails during extraction, the partially written file is left on disk because out[tool] = target is only executed after the copy completes. This means the except Exception block won't clean up the failed file since it only iterates over out.values().

Assigning out[tool] = target before opening the file ensures that any partially written file is tracked and properly cleaned up on failure.

Suggested change
target = os.path.join(dest_dir, tool)
with z.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
out[tool] = target
target = os.path.join(dest_dir, tool)
out[tool] = target
with z.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)

Comment thread magisk_payload.py
Comment on lines +197 to +201
target = os.path.join(dest_dir, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(target), exist_ok=True)
with z.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
out[rel] = target

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Similar to extract_tools, if the file copy fails during extraction of DATABIN extras, the partially written file is left on disk because out[rel] = target is only executed after the copy completes.

Assigning out[rel] = target before opening the file ensures that any partially written file is tracked and properly cleaned up on failure.

Suggested change
target = os.path.join(dest_dir, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(target), exist_ok=True)
with z.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
out[rel] = target
target = os.path.join(dest_dir, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(target), exist_ok=True)
out[rel] = target
with z.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)

Comment thread magisk_payload.py
_p("Downloading Kitsune payload (%s)..." % PAYLOAD_NAME)
tmp = dest + ".part"
try:
urllib.request.urlretrieve(PAYLOAD_URL, tmp) # pinned URL, hash-checked below

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Using urllib.request.urlretrieve without a timeout can cause the download to hang indefinitely if the network connection is lost or the server stops responding.

Using urllib.request.urlopen with an explicit timeout parameter prevents indefinite hangs.

Suggested change
urllib.request.urlretrieve(PAYLOAD_URL, tmp) # pinned URL, hash-checked below
with urllib.request.urlopen(PAYLOAD_URL, timeout=30) as response, open(tmp, "wb") as out_file:
shutil.copyfileobj(response, out_file)

Comment thread rezygisk_payload.py
_p("Downloading ReZygisk module (%s)..." % MODULE_NAME)
tmp = dest + ".part"
try:
urllib.request.urlretrieve(MODULE_URL, tmp) # pinned URL, hash-checked below

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Using urllib.request.urlretrieve without a timeout can cause the download to hang indefinitely if the network connection is lost or the server stops responding.

Using urllib.request.urlopen with an explicit timeout parameter prevents indefinite hangs.

Suggested change
urllib.request.urlretrieve(MODULE_URL, tmp) # pinned URL, hash-checked below
with urllib.request.urlopen(MODULE_URL, timeout=30) as response, open(tmp, "wb") as out_file:
while chunk := response.read(1 << 16):
out_file.write(chunk)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6aaeb0cc65

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread magisk_system.py
Comment on lines +414 to +418
if getattr(sys, "frozen", False): # PyInstaller onefile
base = sys._MEIPASS # type: ignore[attr-defined]
else:
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, "magisk_assets", _ASSET_SUBDIR)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bundle the Magisk assets in the frozen executable

When running the release-built one-file executable, this resolves the assets under sys._MEIPASS, but the Build executable step in .github/workflows/release.yml and the documented PyInstaller command only add favicon.ico and tools/e2fsprogs; PyInstaller does not automatically collect this data directory. Consequently every Magisk install from the published executable reaches the source checks in install_to_system() without config or either bootanim.rc asset and fails before modifying the image.

Useful? React with 👍 / 👎.

Comment thread magisk_system.py
Comment on lines +128 to +134
for bstk in glob.glob(os.path.join(instance_dir, "*.bstk")):
loc = _bstk_vhd_location(bstk)
if not loc:
continue
resolved = loc if os.path.isabs(loc) else os.path.normpath(os.path.join(instance_dir, loc))
if os.path.isfile(resolved):
return resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid modifying a shared Root.vhd as instance-local state

For a clone or fresh instance without its own Root.vhd, this returns the master's shared system image even though the DATABIN and manifest are written only to the selected instance's Data.vhdx. Other instances referencing that image therefore boot the Magisk bootanim.rc without their required DATABIN, and uninstalling any one installed sibling removes the shared system footprint while the other siblings remain marked as installed. Shared roots must either be rejected or have installation and manifest state coordinated across every dependent instance.

Useful? React with 👍 / 👎.

Comment thread magisk_system.py
Comment on lines +517 to +520
except Exception:
_p("System install failed -- rolling back...")
try:
_es._run_script(dev, _clean_dir_commands(dev, magiskdir, env), env)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore bootanim.rc when system installation rolls back

If post-write verification or e2fsck fails, the preceding script has already replaced bootanim.rc, but this rollback removes only the magisk directory. The remaining init file invokes binaries from that now-removed directory, while the failed operation never stages the DATABIN or writes a manifest, leaving the instance in a broken partial state that the GUI reports as not installed. The failure path needs to restore the original boot sequence as well.

Useful? React with 👍 / 👎.

Comment thread magisk_system.py
Comment on lines +453 to +457
cmds += ["cd %s" % initdir, "rm bootanim.rc",
"write %s bootanim.rc" % _dq(_cygpath(srcs["bootanim.rc"])),
"sif %s mode 0100664" % bo, "sif %s uid 1000" % bo, "sif %s gid 1000" % bo,
"write %s bootanim.rc.gz" % _dq(_cygpath(srcs["bootanim.rc.gz"])),
"sif %s mode 0100600" % boz, "sif %s uid 0" % boz, "sif %s gid 0" % boz]

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 Preserve the image's actual stock bootanim.rc

On any supported BlueStacks build whose stock bootanim.rc differs from this captured asset, installation deletes the actual file and writes a version-pinned generic gzip as its purported backup. uninstall_from_system() later decompresses that same bundled asset rather than restoring what was read from the image, so build-specific init changes or user modifications are permanently lost and the advertised reversal is not exact. Capture the current file before replacing it and restore that per-image backup.

Useful? React with 👍 / 👎.

Robustness + honesty fixes plus completing the ReZygisk/manager wiring so
the tested payload/adb code is actually reachable:

- install(): if stage_databin fails after install_to_system, roll back the
  /system footprint (uninstall_from_system) so the instance boots stock
  instead of into a Magisk init with no DATABIN (was: orphaned /system, no
  manifest, GUI hides Uninstall). [bug_001]
- _system_write_commands: rm bootanim.rc.gz before rewriting it -- debugfs
  write won't overwrite, so a reinstall/retry over a leftover .gz silently
  no-op'd; also add the .gz to the post-write verify list. [bug_002]
- Manifest no longer claims "manager" on the offline install (it's just
  system+databin); add_component()/remove_component() record "manager" only
  after the adb install/uninstall succeeds, so the status label is truthful. [bug_010]
- install_manager signature-mismatch message points at real remedies (the
  new Remove manager button, adb uninstall, or Settings), not a phantom. [bug_012]
- Wire the pinned ReZygisk payload: new "Install ReZygisk (Zygisk)" button
  (fetch_module -> install_module) and "Remove manager" button
  (uninstall_manager), gated on the manager being installed. Turns the
  previously-unreachable rezygisk_payload/uninstall_manager into real
  actions. [bug_013]

150 tests green.
@RobThePCGuy
RobThePCGuy merged commit 8cefb17 into master Jul 20, 2026
4 checks passed
@RobThePCGuy
RobThePCGuy deleted the feature/magisk-system branch July 20, 2026 04:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant