From 134da6b58bf16ad627070762c3989e9309d30f30 Mon Sep 17 00:00:00 2001 From: Rob Adams Jr <10876982+RobThePCGuy@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:35:39 -0500 Subject: [PATCH 1/2] Add Kyubi (Manager Root) for BlueStacks Air BlueStacks Air only had a plain su: no modules, no ReZygisk, no LSPosed. Manager Root now installs Kyubi there too, from the new arm64-v8a Kyubi build, with the same buttons as Windows: Manager Root, Update, the Kyubi app, ReZygisk, LSPosed, and the Modules page. macos_kyubi writes the system-mode footprint into Air's shared Root.qcow2 through the existing image session. Two things differ from Windows because Air differs: the magisk tmpfs lives at /debug_ramdisk, since Air's ramdisk has no /sbin, and /data/adb/magisk is filled by a script the guest runs at boot rather than by editing each instance's multi-GB data.qcow2 offline. Kyubi replaces the plain su in the same pass, and the plain root refuses while Kyubi is in. Undo restores the original image byte for byte when Kyubi was the only change. Also fixed while here: - Manager/app copy says "Kyubi app" instead of "Magisk manager". - The ReZygisk and LSPosed results no longer repeat the restart advice. - The modification record dropped stale plain-su fields once root is off. Verified live on BlueStacks Air 5.21.790 through the buttons: Manager Root installed Kyubi, started the instance and installed the app; su gives uid 0; ReZygisk and LSPosed flashed and both run after Restart; Remove Manager Root took Kyubi out and left the tracker block in place. --- README.md | 11 +- adb_handler.py | 27 ++- macos_kyubi.py | 315 +++++++++++++++++++++++++++++++++++ macos_root.py | 9 +- magisk_payload.py | 22 ++- tests/test_adb_manager.py | 2 +- tests/test_auto_manager.py | 12 +- tests/test_instances_page.py | 6 +- views/help.py | 22 ++- views/instances_page.py | 39 ++++- views/magisk_controller.py | 183 ++++++++++++++++++-- views/main_window.py | 13 +- 12 files changed, 605 insertions(+), 56 deletions(-) create mode 100644 macos_kyubi.py diff --git a/README.md b/README.md index dd832c8..953e1ab 100644 --- a/README.md +++ b/README.md @@ -195,11 +195,17 @@ uid=0(root) gid=0(root) groups=0(root),... To undo, click the same button (now **"Remove Root (all instances)"**). It restores the pristine image from the backup the first root made. +#### Kyubi (Manager Root) on Air + +For modules, **ReZygisk** and **LSPosed**, click **Manager Root** instead. It installs [Kyubi](https://github.com/RobThePCGuy/Kyubi), the emulator Magisk build this tool uses on Windows too, into the same shared system image. BlueStacks restarts, and the Kyubi app is installed for you. Then **ReZygisk**, **LSPosed** and **Restart** work as they do on Windows, and the **Modules** page appears for any other module `.zip`. + +Kyubi replaces the plain `su` (both provide root and would conflict). **Remove Manager Root** takes it out again; if nothing else was changed, the original image is put back byte for byte. + > [!NOTE] > On Air the button says *"all instances"* because it means it: every Air instance boots one shared system image, so there is no per-instance root. This also means a BlueStacks update wipes root — it replaces that image. The tool notices and stops reporting the instance as rooted; just click the button again. > [!TIP] -> The Modules tab, the R/W toggle and the Magisk buttons are hidden on Air. They are not missing features to work around: Air has no `.bstk` files to flip, and the Magisk installer drives Windows VHDs through bundled `.exe` tools. +> The R/W toggle is hidden on Air: Air has no `.bstk` files to flip. The Modules tab appears once Kyubi is installed. > [!IMPORTANT] > **While rooted, `BlueStacks.app`'s code signature no longer validates.** `Root.qcow2` is a sealed resource of the bundle, so changing it invalidates the seal — `codesign --verify` and `spctl` both start failing. This is unavoidable for any change to the guest system, not something the tool works around. In practice BlueStacks still launches and runs normally, because macOS does not re-run Gatekeeper on an app that is already installed and has been opened. @@ -252,7 +258,7 @@ Everything the tool put on disk lives in two places, both outside the bundle and | BlueStacks | Root Working? | Method | |-----------|---------------|--------| -| **Air** (Apple Silicon, arm64) | Yes | Inject `su` into the shared `Root.qcow2` — Air ships none | +| **Air** (Apple Silicon, arm64) | Yes | Inject `su`, or Kyubi (Manager Root), into the shared `Root.qcow2` — Air ships no `su` | | **Intel / x86 macOS build** | **Not yet** | Different product: VirtualBox + VHDX + x86 guest. Detected and skipped, never modified. | **Verified rooted**: every instance reports `uid=0` after toggling root: @@ -376,6 +382,7 @@ Undo restores the pristine backup, so nothing about the change is one-way. - `macos_locator.py`: Finds a BlueStacks Air install (app bundle, shared data dir, instances) and reports it in `registry_handler`'s shape. Reads the player's Mach-O architecture and fails closed on anything that isn't Apple Silicon, so an Intel install is never mistaken for Air - `macos_root.py`: Roots BlueStacks Air by injecting `su` into the shared `Root.qcow2` via bundled `qemu-img` + Homebrew `debugfs`, with a backup outside the bundle and an image fingerprint so a BlueStacks update can't leave a stale "rooted" claim - `macos_su.py`: Builds the 223-byte statically-linked aarch64 `su` that gets injected; full assembly listing in the module docstring +- `macos_kyubi.py`: Installs Kyubi (Manager Root) into the Air system image: the arm64 Magisk binaries, a boot hook that uses `/debug_ramdisk` (Air has no `/sbin`), and a script the guest runs at boot to copy the binaries into `/data/adb/magisk`, so no per-instance disk is edited - `adb_handler.py`: Pushes/flashes a module `.zip`, and installs/removes the Magisk manager app, over BlueStacks' bundled ADB - `integrity_patch.py` / `root_persistence.py`: Engine patches (5.22+ integrity bypass, keep root enabled) with `.prepatch.bak` backups - `su_patch.py` / `su_patch_offline.py`: Patch-mode app root; flips the guest `su` `isDeveloperMode` gate inside `Data.vhdx` (bundled VHD/VHDX + ext4 reader, no ADB required) diff --git a/adb_handler.py b/adb_handler.py index dedda04..83b2096 100644 --- a/adb_handler.py +++ b/adb_handler.py @@ -312,7 +312,7 @@ def _p(msg): _p("Connecting to the instance...") serial = _resolve_serial(adb_exe, port, runner) - _p("Installing the Magisk manager (%s)..." % os.path.basename(apk_path)) + _p("Installing the Kyubi app (%s)..." % os.path.basename(apk_path)) cp = runner([adb_exe, "-s", serial, "install", "-r", apk_path]) out = ((cp.stdout or "") + (cp.stderr or "")).strip() if cp.returncode == 0 and "Success" in out: @@ -323,12 +323,12 @@ def _p(msg): # modules survive the restart and the manager did not.) Verified live: # without this the manager is gone after restart; with it, it persists. runner([adb_exe, "-s", serial, "shell", "sync"]) - return "Installed the Magisk manager. Open it from the app drawer." + return "Installed the Kyubi app. Open it from the app drawer." low = out.lower() if "signatures do not match" in low or "update_incompatible" in low: raise RuntimeError( - "A different-signed Magisk manager is already installed. Remove it " + "A different-signed Kyubi app is already installed. Remove it " "first -- \"Remove manager\" on the Magisk tab if this app installed " "it, otherwise `adb uninstall %s` or Android Settings -> Apps -- then " "retry. Details: %s" % (MANAGER_PACKAGE, out)) @@ -347,16 +347,31 @@ def _p(msg): _p("Connecting to the instance...") serial = _resolve_serial(adb_exe, port, runner) - _p("Removing the Magisk manager...") + _p("Removing the Kyubi app...") cp = runner([adb_exe, "-s", serial, "uninstall", MANAGER_PACKAGE]) out = ((cp.stdout or "") + (cp.stderr or "")).strip() if cp.returncode == 0 and "Success" in out: - return "Removed the Magisk manager." + return "Removed the Kyubi app." if "not installed" in out.lower() or "unknown package" in out.lower(): - return "Magisk manager was not installed." + return "The Kyubi app was not installed." raise RuntimeError("Manager uninstall failed: %s" % (out or "unknown error")) +def remove_databin(adb_exe: str, port: Optional[int], runner: Runner = _run) -> None: + """Delete Kyubi's binaries and the ADB auto-grant from a running instance. + + Used before a BlueStacks Air uninstall, where /data lives in a per-instance + disk image the tool does not edit offline. Modules are left in place, as the + Windows uninstall leaves them. + """ + serial = _resolve_serial(adb_exe, port, runner) + cp = runner([adb_exe, "-s", serial, "shell", "su", "-c", + "rm -rf /data/adb/magisk /data/adb/service.d/00-bsrgui-adbgrant.sh"]) + if cp.returncode != 0: + raise RuntimeError(((cp.stdout or "") + (cp.stderr or "")).strip() + or "could not remove /data/adb/magisk") + + def list_running_instances(adb_exe: str, instances, runner: Runner = _run) -> dict: """Which of ``instances`` are currently reachable over ADB. diff --git a/macos_kyubi.py b/macos_kyubi.py new file mode 100644 index 0000000..6e3e45e --- /dev/null +++ b/macos_kyubi.py @@ -0,0 +1,315 @@ +"""Kyubi (Magisk-managed root) for BlueStacks Air -- the macOS ``magisk_system``. + +What it installs +---------------- +The same system-mode footprint the Windows installer writes, in Air's one shared +system image (``Root.qcow2``, edited through ``macos_root.open_image``): + +* ``/system/etc/init/magisk/`` -- ``magisk64``, ``magiskinit``, ``magiskpolicy``, + ``busybox``, ``stub.apk``, ``util_functions.sh``, ``config``, plus the two + small scripts below. Root-owned, 0700. +* ``/system/etc/init/bootanim.rc`` -- the stock file with Magisk's boot hooks + appended; the stock file is kept gzipped beside it as ``bootanim.rc.gz``. + +Two deliberate differences from Windows, both forced by Air: + +* **arm64 only, no magisk32.** Air's guest is 64-bit ARM with no 32-bit ABI. +* **``/debug_ramdisk`` instead of ``/sbin``.** Air's ramdisk has no ``/sbin``; + ``/debug_ramdisk`` exists and is the first place Magisk looks for its tmpfs. + +And one simplification: **the DATABIN is staged by the guest itself.** On +Windows ``/data/adb/magisk`` is written offline into ``Data.vhdx``. On Air every +instance has its own multi-GB ``data.qcow2``, and converting that on each +install would be slow and would touch user data. Instead the boot hook runs a +small script before Magisk starts that copies the binaries from the system +image into ``/data/adb/magisk`` whenever they differ (compared by a stamp), so +every instance picks Kyubi up on its next boot, and an update to the system +image refreshes them automatically. + +Root is install-wide on Air, so this sits beside ``macos_root``'s plain ``su`` +in the shared modification record, as the ``kyubi`` entry. The two conflict (both +provide ``su``): installing Kyubi removes the plain ``su`` in the same pass. +""" +from __future__ import annotations + +import gzip +import hashlib +import logging +import os +import tempfile + +import macos_locator +import macos_root +import magisk_payload + +logger = logging.getLogger(__name__) + +INIT_DIR = "/android/system/etc/init" +MAGISK_DIR = INIT_DIR + "/magisk" +BOOTANIM = INIT_DIR + "/bootanim.rc" +BOOTANIM_GZ = INIT_DIR + "/bootanim.rc.gz" +GUEST_MAGISK_DIR = "/system/etc/init/magisk" + +STAGE_SCRIPT = "kyubi-stage.sh" +ADB_GRANT_SCRIPT = "00-bsrgui-adbgrant.sh" +STAMP = "stamp" + +CONFIG = "SYSTEMMODE=true\nRECOVERYMODE=false\n" + +# Appended to the stock bootanim.rc. Same sequence Magisk's own install-to-system +# writes (see magisk_assets/*/bootanim.rc), with the tmpfs at /debug_ramdisk and +# the DATABIN staging step in front of --post-fs-data. +BOOT_HOOK = """on post-fs-data + start logd + mkdir /data/adb 0700 root root + exec u:r:su:s0 root root -- {d}/busybox sh {d}/{stage} + exec u:r:su:s0 root root -- {d}/magiskpolicy --live --magisk + exec u:r:magisk:s0 root root -- {d}/magiskpolicy --live --magisk + exec u:r:update_engine:s0 root root -- {d}/magiskpolicy --live --magisk + exec u:r:su:s0 root root -- {d}/magisk64 --auto-selinux --setup-sbin {d} /debug_ramdisk + exec u:r:su:s0 root root -- /debug_ramdisk/magisk --auto-selinux --post-fs-data +on nonencrypted + exec u:r:su:s0 root root -- /debug_ramdisk/magisk --auto-selinux --service +on property:vold.decrypt=trigger_restart_framework + exec u:r:su:s0 root root -- /debug_ramdisk/magisk --auto-selinux --service +on property:sys.boot_completed=1 + mkdir /data/adb/magisk 755 + exec u:r:su:s0 root root -- /debug_ramdisk/magisk --auto-selinux --boot-complete +on property:init.svc.zygote=restarting + exec u:r:su:s0 root root -- /debug_ramdisk/magisk --auto-selinux --zygote-restart +on property:init.svc.zygote=stopped + exec u:r:su:s0 root root -- /debug_ramdisk/magisk --auto-selinux --zygote-restart +""".format(d=GUEST_MAGISK_DIR, stage=STAGE_SCRIPT) + +# Files the stage script copies into /data/adb/magisk. util_functions.sh is the +# module-install gate ("Incomplete Magisk install" without it). +DATABIN_FILES = ("busybox", "magisk64", "magiskinit", "magiskpolicy", + "stub.apk", "util_functions.sh") + +STAGE_BODY = """#!/system/bin/sh +# Kyubi on BlueStacks Air: copy the binaries from the system image into the +# DATABIN before Magisk starts, whenever they differ. Run by init as root. +S={s} +D=/data/adb/magisk +BB=$S/busybox +if ! $BB cmp -s $S/{stamp} $D/.kyubi_stamp; then + $BB rm -rf $D + $BB mkdir -p $D + for f in {files}; do $BB cp -f $S/$f $D/$f; done + $BB chmod 0755 $D/* + $BB chmod 0644 $D/stub.apk + $BB cp -f $S/{stamp} $D/.kyubi_stamp +fi +$BB mkdir -p /data/adb/service.d +$BB cp -f $S/{grant} /data/adb/service.d/{grant} +$BB chmod 0755 /data/adb/service.d/{grant} +""".format(s=GUEST_MAGISK_DIR, stamp=STAMP, grant=ADB_GRANT_SCRIPT, + files=" ".join(DATABIN_FILES)) + +# Same auto-grant as the Windows install (magisk_system._ADB_GRANT_BODY): lets +# the ADB shell use su so module flashes over ADB are never silently denied. +ADB_GRANT_BODY = ( + "#!/system/bin/sh\n" + "# BlueStacks-Root-GUI: force-allow the ADB shell (uid 2000) at boot so\n" + "# module flashes over ADB are never auto-denied. Runs as root via Magisk\n" + "# service.d; scoped to the shell uid only.\n" + "for i in 1 2 3 4 5 6 7 8 9 10; do\n" + ' magisk --sqlite "REPLACE INTO policies (uid,policy,until,logging,notification) VALUES(2000,2,0,0,0)" && break\n' + " sleep 2\n" + "done\n" +) + + +def status(app_path: str, data_dir: str = macos_locator.DATA_DIR) -> dict | None: + """Recorded Kyubi install, or None. Shaped like ``magisk_system.magisk_status``.""" + return macos_root.read_modstate(app_path, data_dir).get("kyubi") + + +def _cache_dir() -> str: + return os.path.join(tempfile.gettempdir(), "BlueStacksRootGUI-magisk", "cache") + + +def _sha256(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def _hook_bootanim(stock: str) -> str: + return stock.rstrip("\n") + "\n" + BOOT_HOOK + + +def _write_host_file(workdir: str, name: str, data: bytes) -> str: + path = os.path.join(workdir, name) + with open(path, "wb") as fh: + fh.write(data) + return path + + +def _put(img: macos_root.ImageSession, host: str, guest: str, *, + mode: int, uid: int = 0, gid: int = 0) -> None: + parent, name = guest.rsplit("/", 1) + img.run("rm {g}\ncd {p}\nwrite {h} {n}\nsif {n} mode 0{m:o}\n" + "sif {n} uid {u}\nsif {n} gid {gi}\nquit\n" + .format(g=guest, p=parent, h=host, n=name, m=mode, u=uid, gi=gid)) + if "Inode:" not in img.run("stat %s\nquit\n" % guest): + raise macos_root.RootError("Could not write %s into the system image." % guest) + + +def _remove_magisk_dir(img: macos_root.ImageSession) -> None: + listing = img.run("ls -p %s\nquit\n" % MAGISK_DIR) + names = [p.split("/")[5] for p in listing.split() if p.count("/") >= 6] + names = [n for n in names if n not in ("", ".", "..")] + cmds = "".join("rm %s/%s\n" % (MAGISK_DIR, n) for n in names) + img.run(cmds + "rmdir %s\nquit\n" % MAGISK_DIR) + + +def _read_stock_backup(img: macos_root.ImageSession) -> str | None: + gz = os.path.join(img.workdir, "bootanim.rc.gz") + if os.path.exists(gz): + os.unlink(gz) + img.run("dump %s %s\nquit\n" % (BOOTANIM_GZ, gz)) + if not os.path.isfile(gz): + return None + with gzip.open(gz, "rb") as fh: + return fh.read().decode("utf-8") + + +def install(app_path: str, progress=None, + data_dir: str = macos_locator.DATA_DIR) -> list[str]: + """Install Kyubi into the Air system image. BlueStacks must be closed.""" + def _step(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + _step("Fetching Kyubi...") + apk = magisk_payload.fetch_apk(_cache_dir(), progress=progress) + work = tempfile.mkdtemp(prefix="kyubi-air-") + tools = magisk_payload.extract_tools(apk, os.path.join(work, "tools"), + progress=progress, arm64=True) + extras = magisk_payload.extract_databin_extras(apk, os.path.join(work, "extras"), + progress=progress) + payload_sha = _sha256(apk) + + image = macos_locator.root_image_path(app_path) + state = macos_root.read_modstate(app_path, data_dir) + active = macos_root.applied_modifications(state) + macos_root.ensure_backup(image, data_dir, image_is_pristine=not active, step=_step) + + results: list[str] = [] + with macos_root.open_image(app_path, progress, results=results) as img: + if state.get("root"): + _step("Removing the plain su (Kyubi provides its own)...") + img.run("rm %s\nquit\n" % macos_root.SU_IMAGE_PATH) + results.append("removed %s" % macos_root.SU_GUEST_PATH) + + _step("Writing Kyubi into the system image...") + current = img.read_file(BOOTANIM) + if current is None: + raise macos_root.RootError("The system image has no %s to hook." % BOOTANIM) + stock = current + if "magisk" in current: + # Reinstall/update over an existing install: the stock file is the + # gzipped copy the first install kept, not the hooked one in place. + stock = _read_stock_backup(img) + if stock is None: + raise macos_root.RootError( + "Kyubi's boot hook is present but the original bootanim.rc " + "backup is missing, so it cannot be reinstalled safely.") + if "Inode:" in img.run("stat %s\nquit\n" % MAGISK_DIR): + _remove_magisk_dir(img) + img.run("mkdir %s\nsif %s mode 040700\nsif %s uid 0\nsif %s gid 0\nquit\n" + % (MAGISK_DIR, MAGISK_DIR, MAGISK_DIR, MAGISK_DIR)) + + files = dict(tools) + files.update(extras) + files["config"] = _write_host_file(work, "config", CONFIG.encode()) + files[STAGE_SCRIPT] = _write_host_file(work, STAGE_SCRIPT, STAGE_BODY.encode()) + files[ADB_GRANT_SCRIPT] = _write_host_file(work, ADB_GRANT_SCRIPT, + ADB_GRANT_BODY.encode()) + files[STAMP] = _write_host_file(work, STAMP, (payload_sha + "\n").encode()) + for name, host in sorted(files.items()): + _put(img, host, "%s/%s" % (MAGISK_DIR, name), mode=0o100700) + + if "magisk" not in current: + _put(img, _write_host_file(work, "bootanim.rc.gz", + gzip.compress(stock.encode())), + BOOTANIM_GZ, mode=0o100600) + _put(img, _write_host_file(work, "bootanim.rc", _hook_bootanim(stock).encode()), + BOOTANIM, mode=0o100664, uid=1000, gid=1000) + results.append("installed Kyubi into %s" % GUEST_MAGISK_DIR) + + state["root"] = False + state["kyubi"] = {"version": magisk_payload.PAYLOAD_VERSION, + "payload_sha256": payload_sha, + "components": ["system"]} + macos_root.write_modstate(app_path, data_dir, state) + _step("Kyubi installed. Start BlueStacks to finish setup.") + results.append("Kyubi installed. Start BlueStacks to finish setup.") + return results + + +def uninstall(app_path: str, progress=None, + data_dir: str = macos_locator.DATA_DIR) -> list[str]: + """Remove Kyubi from the Air system image. BlueStacks must be closed.""" + def _step(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + image = macos_locator.root_image_path(app_path) + state = macos_root.read_modstate(app_path, data_dir) + active = macos_root.applied_modifications(state) + results: list[str] = [] + + # Last edit standing: put the untouched image back, byte for byte, which + # also repairs BlueStacks.app's signature. + if not (active - {"kyubi"}) and os.path.isfile(macos_root.backup_path(data_dir)): + results += macos_root.restore_pristine(image, data_dir, _step) + macos_root.write_modstate(app_path, data_dir, {}) + _step("Kyubi removed. Restart BlueStacks for the change to take effect.") + return results + + with macos_root.open_image(app_path, progress, results=results) as img: + _step("Removing Kyubi from the system image...") + stock = _read_stock_backup(img) + if stock is not None: + _put(img, _write_host_file(img.workdir, "bootanim.rc", stock.encode()), + BOOTANIM, mode=0o100664, uid=1000, gid=1000) + img.run("rm %s\nquit\n" % BOOTANIM_GZ) + if "Inode:" in img.run("stat %s\nquit\n" % MAGISK_DIR): + _remove_magisk_dir(img) + results.append("removed Kyubi from %s" % GUEST_MAGISK_DIR) + + state["kyubi"] = None + macos_root.write_modstate(app_path, data_dir, state) + _step("Kyubi removed. Restart BlueStacks for the change to take effect.") + results.append("Kyubi removed. Start BlueStacks to use it without root.") + return results + + +def add_component(app_path: str, component: str, + data_dir: str = macos_locator.DATA_DIR) -> None: + state = macos_root.read_modstate(app_path, data_dir) + kyubi = state.get("kyubi") + if not kyubi: + return + comps = set(kyubi.get("components") or []) + comps.add(component) + kyubi["components"] = sorted(comps) + # Re-stamping is safe here: the image has not changed since read_modstate + # validated it. + macos_root.write_modstate(app_path, data_dir, state) + + +def remove_component(app_path: str, component: str, + data_dir: str = macos_locator.DATA_DIR) -> None: + state = macos_root.read_modstate(app_path, data_dir) + kyubi = state.get("kyubi") + if not kyubi: + return + kyubi["components"] = sorted(set(kyubi.get("components") or []) - {component}) + macos_root.write_modstate(app_path, data_dir, state) diff --git a/macos_root.py b/macos_root.py index 018f203..a53643a 100644 --- a/macos_root.py +++ b/macos_root.py @@ -271,6 +271,8 @@ def write_modstate(app_path: str, data_dir: str, state: dict) -> None: _clear_state(data_dir) return payload["image"] = _fingerprint(image) + payload.pop("su_sha256", None) + payload.pop("su_path", None) if payload.get("root"): # Fingerprint of the exact binary that was injected, so a future # version can tell its own su from one somebody else put there. @@ -429,7 +431,7 @@ def _install_image(source: str, image: str, *, label: str) -> None: # block share one image, so neither can decide on its own whether undoing means # "restore the pristine backup" or "edit the current image" -- that depends on # whether the *other* one is still applied. -MODIFICATIONS = ("root", "hosts") +MODIFICATIONS = ("root", "hosts", "kyubi") def applied_modifications(state: dict) -> set[str]: @@ -628,6 +630,11 @@ def _step(msg: str) -> None: _step("Root removed. Restart BlueStacks for the change to take effect.") return results + if enabled and "kyubi" in active: + # Both provide su and would fight over it. + raise RootError("Kyubi is installed and already provides root. Remove " + "Kyubi first if you want the plain su instead.") + if enabled: ensure_backup(image, data_dir, image_is_pristine=not active, step=_step) results.append("backed up to %s" % backup) diff --git a/magisk_payload.py b/magisk_payload.py index e1c45e2..cf378c3 100644 --- a/magisk_payload.py +++ b/magisk_payload.py @@ -75,6 +75,16 @@ "magisk32": (_X86, "libmagisk32.so"), # 32-bit app root } +# BlueStacks Air (Apple Silicon) runs a 64-bit-only arm64 guest: no 32-bit ABI, +# so no magisk32. Needs a Kyubi release built with arm64-v8a. +_ARM64 = "arm64-v8a" +_TOOLS_ARM64 = { + "busybox": (_ARM64, "libbusybox.so"), + "magisk64": (_ARM64, "libmagisk64.so"), + "magiskinit": (_ARM64, "libmagiskinit.so"), + "magiskpolicy": (_ARM64, "libmagiskpolicy.so"), +} + # Non-lib member the system-mode install needs (the Magisk manager stub the app # would otherwise write to /system/etc/init/magisk/stub.apk). STUB_APK_MEMBER = "assets/stub.apk" @@ -246,12 +256,14 @@ def _p(msg: str) -> None: return "%s (Kyubi)" % tag, expected.lower() -def extract_tools(apk_path: str, dest_dir: str, progress=None) -> dict[str, str]: +def extract_tools(apk_path: str, dest_dir: str, progress=None, *, + arm64: bool = False) -> dict[str, str]: """Extract the Magisk native tools from the APK into ``dest_dir``. Returns ``{tool_name: path}``. Files are written under their plain names (busybox, magisk64, ...) -- the form Magisk's daemon expects in - ``/data/adb/magisk`` -- not the ``lib*.so`` wrapper. + ``/data/adb/magisk`` -- not the ``lib*.so`` wrapper. ``arm64`` selects the + BlueStacks Air set instead of the x86 one. """ def _p(msg: str) -> None: logger.info(msg) @@ -263,9 +275,13 @@ def _p(msg: str) -> None: try: with zipfile.ZipFile(apk_path) as z: members = set(z.namelist()) - for tool, (abi, soname) in _TOOLS.items(): + for tool, (abi, soname) in (_TOOLS_ARM64 if arm64 else _TOOLS).items(): member = "lib/%s/%s" % (abi, soname) if member not in members: + if arm64: + raise RuntimeError( + "This Kyubi build has no Apple Silicon (arm64) files " + "yet, so it cannot be installed on BlueStacks Air.") raise RuntimeError( "payload is missing %s (expected %s in the APK)" % (tool, member)) target = os.path.join(dest_dir, tool) diff --git a/tests/test_adb_manager.py b/tests/test_adb_manager.py index 6ed11c6..6604713 100644 --- a/tests/test_adb_manager.py +++ b/tests/test_adb_manager.py @@ -48,7 +48,7 @@ def handle(cmd): runner = _runner(handle) msg = install_manager("adb", 5555, apk, runner=runner) - assert "Installed the Magisk manager" in msg + assert "Installed the Kyubi app" in msg inst = next(c for c in runner.calls if "install" in c) assert inst[:3] == ["adb", "-s", "127.0.0.1:5555"] assert inst[-3:] == ["install", "-r", apk] diff --git a/tests/test_auto_manager.py b/tests/test_auto_manager.py index a2aa9d0..3cedb06 100644 --- a/tests/test_auto_manager.py +++ b/tests/test_auto_manager.py @@ -78,7 +78,7 @@ def controller(qtbot): def test_manager_step_is_skipped_cleanly_without_adb(controller): msg = controller._finish_with_manager( - "data", "install", "Tiramisu64", "conf", adb_exe=None, port=None, + {"data_path": "data"}, "install", "Tiramisu64", "conf", adb_exe=None, port=None, report=lambda _m: None) assert "installed" in msg.lower() assert "manager app" in msg.lower() # tells the user the retry @@ -90,7 +90,7 @@ def test_a_failed_manager_step_does_not_report_a_failed_install(controller, monk monkeypatch.setattr("instance_handler.launch_instance", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no player"))) msg = controller._finish_with_manager( - "data", "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, + {"data_path": "data"}, "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, report=lambda _m: None) assert "Manager Root installed" in msg assert "retry" in msg.lower() @@ -101,7 +101,7 @@ def test_a_boot_timeout_is_reported_as_a_follow_up(controller, monkeypatch): monkeypatch.setattr("instance_handler.launch_instance", lambda *a, **k: None) monkeypatch.setattr("adb_handler.wait_until_ready", lambda *a, **k: None) msg = controller._finish_with_manager( - "data", "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, + {"data_path": "data"}, "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, report=lambda _m: None) assert "did not finish booting" in msg @@ -117,12 +117,12 @@ def test_the_happy_path_installs_the_app_and_records_it(controller, monkeypatch) monkeypatch.setattr("magisk_system.add_component", recorded) msg = controller._finish_with_manager( - "data", "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, + {"data_path": "data"}, "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, report=lambda _m: None) installed.assert_called_once() recorded.assert_called_once_with("data", "manager") # status stays honest - assert "Magisk app are installed" in msg + assert "app are installed" in msg def test_adb_is_enabled_before_launching(controller, monkeypatch): @@ -134,6 +134,6 @@ def test_adb_is_enabled_before_launching(controller, monkeypatch): lambda path, key, val: wrote.append((key, val))) monkeypatch.setattr("instance_handler.launch_instance", lambda *a, **k: None) monkeypatch.setattr("adb_handler.wait_until_ready", lambda *a, **k: None) - controller._finish_with_manager("data", "install", "Tiramisu64", "conf", + controller._finish_with_manager({"data_path": "data"}, "install", "Tiramisu64", "conf", adb_exe="adb", port=5555, report=lambda _m: None) assert (constants.ENABLE_ADB_KEY, "1") in wrote diff --git a/tests/test_instances_page.py b/tests/test_instances_page.py index 740bd00..a35e718 100644 --- a/tests/test_instances_page.py +++ b/tests/test_instances_page.py @@ -312,13 +312,13 @@ def test_windows_grid_keeps_all_columns(qtbot): assert _headers(page) == ["Instance", "Root", "R/W", "Manager app"] -def test_air_hides_rw_and_magisk_buttons(qtbot): +def test_air_hides_rw_but_offers_manager_root(qtbot): + """Air has no R/W state, but Kyubi (Manager Root) installs there.""" page = _air_page(qtbot) page.checkboxes["Tiramisu64 (AIR)"].setChecked(True) assert page.rw_toggle_button.isVisible() is False - assert page.install_button.isVisible() is False + assert page.install_button.isVisible() is True assert page.uninstall_button.isVisible() is False - # ...but the things Air *can* do stay available. assert page.root_toggle_button.isEnabled() is True assert page.launch_button.isEnabled() is True diff --git a/views/help.py b/views/help.py index 4625d56..937a2fd 100644 --- a/views/help.py +++ b/views/help.py @@ -43,7 +43,8 @@ _DASHBOARD_AIR = """

Where BlueStacks Air was found, and whether it is rooted.

-

Air has no engine patch to apply. Go to Instances to root it.

+

Air has no engine patch to apply. Go to Instances to root it, with a +plain su or with Kyubi.

""" _INSTANCES_WINDOWS = """ @@ -75,10 +76,17 @@ _INSTANCES_AIR = """

BlueStacks Air ships no su, so rooting adds one to the Android system image. Every Air instance shares that one image, so root covers all of them.

+

Two ways to root, pick one

+

To root

    -
  1. Tick your instance and click Root (all instances). BlueStacks is -closed for you.
  2. +
  3. Tick your instance and click the one you want. BlueStacks is closed for +you. Manager Root then starts it again and installs the Kyubi app.
  4. First time only: macOS blocks the change and this tool opens System Settings > Privacy & Security > App Management. Switch BlueStacksRootGUI on, choose Quit & Reopen, and click Root @@ -86,8 +94,12 @@
  5. Wait about a minute, then start BlueStacks. Root apps and root checkers now see su.
+

Adding modules (Manager Root)

+

ReZygisk first, then LSPosed, then Restart once to +activate them. Any other module .zip goes through the Modules page.

To undo

-

Click Remove Root. The original image is put back exactly as it was.

+

Click Remove Root or Remove Manager Root. If that was the only +change, the original image is put back exactly as it was.

Good to know