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.
+ReZygisk first, then LSPosed, then Restart once to +activate them. Any other module .zip goes through the Modules page.
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.
This exists because BlueStacks' own file picker hands Magisk a path it cannot open. If the tool cannot reach the instance, it leaves the .zip in the -instance's Download folder so you can flash it from the Magisk app.
+instance's Download folder so you can flash it from the Kyubi app. """ _PRIVACY = """ diff --git a/views/instances_page.py b/views/instances_page.py index 1410b89..bd3f6f7 100644 --- a/views/instances_page.py +++ b/views/instances_page.py @@ -132,12 +132,12 @@ def __init__(self, parent=None): self.root_toggle_button.setToolTip(self._native_tip) self.root_toggle_button.clicked.connect(self.toggle_root_requested.emit) self.install_button = QPushButton("Manager Root") - self.install_button.setToolTip( + self._manager_tip = ( "Magisk-managed root: adds modules, Zygisk and LSPosed. Installed " "into the system image offline; switches off Native Root for you.") self.install_button.clicked.connect(self.install_requested.emit) self.uninstall_button = QPushButton("Remove Manager Root") - self.uninstall_button.setToolTip( + self._remove_manager_tip = ( "Removes Magisk and restores the stock system image.") self.uninstall_button.clicked.connect(self.uninstall_requested.emit) for _b in (self.root_toggle_button, self.install_button, self.uninstall_button): @@ -155,12 +155,12 @@ def __init__(self, parent=None): self.update_button.clicked.connect(self.update_requested.emit) self.manager_button = QPushButton("Manager app") self.manager_button.setToolTip( - "Installs the Magisk app over ADB. Normally handled for you when " + "Installs the Kyubi app over ADB. Normally handled for you when " "Manager Root is installed; this is the retry if that did not run.") self.manager_button.clicked.connect(self.install_manager_requested.emit) self.remove_manager_button = QPushButton("Remove app") self.remove_manager_button.setToolTip( - "Uninstalls the Magisk app. Leaves the root itself in place.") + "Uninstalls the Kyubi app. Leaves the root itself in place.") self.remove_manager_button.clicked.connect(self.uninstall_manager_requested.emit) self.rezygisk_button = QPushButton("ReZygisk") self.rezygisk_button.setToolTip( @@ -262,7 +262,7 @@ def _build_rows(self, selected: set) -> None: checkbox.setToolTip(unique_id) checkbox.toggled.connect(self._update) - root_label = QLabel(("On" if app_root else "Off") if air + root_label = QLabel(self._air_root_text(app_root, magisk) if air else self._root_text(app_root, magisk)) # Styled by object name in the theme's QSS instead of a hard-coded # colour, so it follows the light/dark palette like everything else. @@ -293,10 +293,11 @@ def _build_rows(self, selected: set) -> None: "Root": ("Off, Native (BlueStacks' own su), Manager (Magisk), or " "Native + Manager, which conflict."), "R/W": "On means the system disk is writable.", - "Manager app": "Whether the Magisk app is installed in the instance.", + "Manager app": "Whether the Kyubi app is installed in the instance.", } - _AIR_ROOT_TIP = "On means su is installed. On Air it covers every instance." + _AIR_ROOT_TIP = ("On: plain su. Manager: Kyubi, with modules. On Air either " + "one covers every instance.") @staticmethod def _row_label(unique_id: str, data: dict) -> str: @@ -314,6 +315,12 @@ def _root_text(app_root: bool, magisk: dict | None) -> str: return "Manager" return "Native" if app_root else "Off" + @staticmethod + def _air_root_text(app_root: bool, magisk: dict | None) -> str: + if magisk: + return "Manager" + return "On" if app_root else "Off" + @staticmethod def _magisk_text(magisk: dict | None) -> str: if not magisk: @@ -395,6 +402,10 @@ def _update(self, *_args) -> None: # Air root is install-wide, so the label follows the install, not # the tick: otherwise an unticked, rooted install reads "Root". app_root = any(d.get("root_enabled") for d in self._instance_data.values()) + installed = any(self._magisk.get(k) for k in self._instance_data) + st = next((self._magisk[k] for k in self._instance_data + if self._magisk.get(k)), None) + manager = installed and "manager" in (st.get("components") or []) # "Native Root" would understate it: on Air this installs su into # the shared system image, so it roots every instance at once. self.root_toggle_button.setText( @@ -412,10 +423,22 @@ def _update(self, *_args) -> None: self.root_toggle_button.setToolTip(self._native_tip) self.rw_toggle_button.setVisible(not air) + # On Air the two roots are install-wide and mutually exclusive: with + # Kyubi in, the plain-su button would only ever be refused. + self.root_toggle_button.setVisible(not (air and installed)) + if air: + self.install_button.setToolTip( + "Kyubi (Magisk-managed root): adds modules, ReZygisk and LSPosed. " + "Covers every Air instance and replaces the plain su.") + self.uninstall_button.setToolTip( + "Takes Kyubi out of the system image every Air instance shares.") + else: + self.install_button.setToolTip(self._manager_tip) + self.uninstall_button.setToolTip(self._remove_manager_tip) # Show only the actions that apply, in flow order. A present but disabled # button reads as "you could do this" when you can't. - one = uid is not None and not air + one = uid is not None show_install = one and not installed self.install_button.setVisible(show_install) self.uninstall_button.setVisible(one and installed) diff --git a/views/magisk_controller.py b/views/magisk_controller.py index ceff22a..26f261c 100644 --- a/views/magisk_controller.py +++ b/views/magisk_controller.py @@ -23,6 +23,8 @@ import constants import instance_handler import lsposed_payload +import macos_kyubi +import macos_locator import magisk_payload import magisk_system import rezygisk_payload @@ -48,10 +50,58 @@ def __init__(self, window): def refresh_statuses(self) -> None: """Fill the Magisk tab with each instance's current install state.""" w = self._window - statuses = {uid: magisk_system.magisk_status(data["data_path"]) - for uid, data in w.instance_data.items()} + statuses = {uid: self._status(data) for uid, data in w.instance_data.items()} w.instances_page.set_magisk_statuses(statuses) + # --- BlueStacks Air ---------------------------------------------------- + # Kyubi on Air lives in the one system image every instance shares, so its + # status and its components are recorded per installation, not per instance + # (macos_kyubi). These helpers let every handler below stay one code path. + + @staticmethod + def _air_args(instance) -> tuple[str, str]: + return (instance.get("app_path"), + instance.get("user_path") or macos_locator.DATA_DIR) + + def _status(self, instance): + if instance.get("air_mode"): + return macos_kyubi.status(*self._air_args(instance)) + return magisk_system.magisk_status(instance["data_path"]) + + def _add_component(self, instance, component) -> None: + if instance.get("air_mode"): + app_path, data_dir = self._air_args(instance) + macos_kyubi.add_component(app_path, component, data_dir) + else: + magisk_system.add_component(instance["data_path"], component) + + def _remove_component(self, instance, component) -> None: + if instance.get("air_mode"): + app_path, data_dir = self._air_args(instance) + macos_kyubi.remove_component(app_path, component, data_dir) + else: + magisk_system.remove_component(instance["data_path"], component) + + def _clean_air_data(self, instance, report) -> None: + """Best effort, before shutdown: clear Kyubi's files from /data. + + The system image is what makes Kyubi run, so removing it there is the + uninstall. The copy in /data/adb/magisk is inert without it, but a + running instance lets us leave /data as tidy as the Windows uninstall + does. Instances that are not running keep an inert copy. + """ + w = self._window + adb_exe = adb_handler.find_adb([i.get("install_path") for i in w.installations]) + port = adb_handler.instance_adb_port(instance["config_path"], + instance["original_name"]) + if not adb_exe or not port: + return + try: + report("Removing Kyubi's files from the running instance...") + adb_handler.remove_databin(adb_exe, port) + except Exception: # noqa: BLE001 - the uninstall itself does not depend on it + logger.info("could not clean /data/adb over ADB", exc_info=True) + def _selected_instance(self): w = self._window uid = w.instances_page.selected_instance_id() @@ -75,6 +125,9 @@ def handle_install(self) -> None: "try again.") return native_on = bool(instance.get("root_enabled")) + if instance.get("air_mode"): + self._install_air(uid, instance, native_on) + return swap_note = ("Native Root is on. Both provide su and would "
"fight, so it is switched off first.
Root is on. Kyubi brings its own su, so the "
+ "plain one is removed in the same pass.
Writes Kyubi into the Android system image while BlueStacks " + "is closed, then starts %s and installs the Kyubi app for you.
" + % uid + swap_note + + "Every Air instance shares that one image, so this covers all " + "of them.
"): + return + app_path, data_dir = self._air_args(instance) + install_dir = instance.get("install_path") + instance_name = instance["original_name"] + config_path = instance["config_path"] + adb_exe = adb_handler.find_adb([i.get("install_path") for i in w.installations]) + port = (adb_handler.instance_adb_port(config_path, instance_name) + if adb_exe else None) + + def job(progress): + steps = StepReporter(progress, _STEPS_INSTALL) + progress("Closing BlueStacks...", 0) + instance_handler.terminate_bluestacks() + QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) + macos_kyubi.install(app_path, progress=steps, data_dir=data_dir) + return self._finish_with_manager( + instance, install_dir, instance_name, config_path, + adb_exe, port, steps) + + w._run_async(job, "Installing Kyubi into BlueStacks Air...") + + def _finish_with_manager(self, instance, install_dir, instance_name, config_path, adb_exe, port, report) -> str: """Start the instance and install the manager app over ADB. @@ -138,7 +224,7 @@ def _finish_with_manager(self, data_path, install_dir, instance_name, """ if not adb_exe or not install_dir: return ("Manager Root installed. Start the instance, then use " - "\"Manager app\" to add the Magisk app.") + "\"Manager app\" to add the Kyubi app.") try: # ADB is off by default on a fresh instance, and the manager install # needs it; enabling it here is what makes this step reliable. @@ -151,12 +237,12 @@ def _finish_with_manager(self, data_path, install_dir, instance_name, "booting in time. Once it is up, use \"Manager app\".") apk = magisk_payload.fetch_apk(self._cache_dir(), progress=report) adb_handler.install_manager(adb_exe, port, apk, progress=report) - magisk_system.add_component(data_path, "manager") + self._add_component(instance, "manager") except Exception as exc: # noqa: BLE001 - the root install already succeeded logger.warning("automatic manager install failed", exc_info=True) - return ("Manager Root installed. The Magisk app could not be added " + return ("Manager Root installed. The Kyubi app could not be added " "automatically (%s); use \"Manager app\" to retry." % exc) - return ("Manager Root and the Magisk app are installed. Restart the " + return ("Manager Root and the Kyubi app are installed. Restart the " "instance when you want to add modules.") def handle_uninstall(self) -> None: @@ -164,6 +250,9 @@ def handle_uninstall(self) -> None: uid, instance = self._selected_instance() if instance is None: return + if instance.get("air_mode"): + self._uninstall_air(instance) + return if not w._confirm( "Uninstall Magisk", "Remove Magisk from %s?" % uid, @@ -186,6 +275,28 @@ def job(progress): w._run_async(job, "Removing Magisk from %s..." % uid) + def _uninstall_air(self, instance) -> None: + w = self._window + if not w._confirm( + "Remove Manager Root", + "Remove Kyubi from BlueStacks Air?", + "Takes Kyubi out of the Android system image every Air " + "instance shares. BlueStacks closes first. If Kyubi was the only " + "change, the original image is put back exactly.
"): + return + app_path, data_dir = self._air_args(instance) + + def job(progress): + steps = StepReporter(progress, _STEPS_UNINSTALL) + self._clean_air_data(instance, steps) + progress("Closing BlueStacks...", 0) + instance_handler.terminate_bluestacks() + QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) + results = macos_kyubi.uninstall(app_path, progress=steps, data_dir=data_dir) + return results[-1] if results else "Kyubi removed." + + w._run_async(job, "Removing Kyubi from BlueStacks Air...") + def handle_update(self) -> None: w = self._window uid, instance = self._selected_instance() @@ -196,6 +307,9 @@ def handle_update(self) -> None: QMessageBox.information(w, "Magisk not installed", "There is no Magisk install on %s to update." % uid) return + if instance.get("air_mode"): + self._update_air(instance, st) + return # An update rewrites the guest system image, same as a fresh install, so # it needs the same patched engine to boot afterwards. if instance.get("patch_mode") and w._engine_state() != "patched": @@ -252,6 +366,41 @@ def job(progress): w._run_async(job, "Updating Magisk on %s..." % uid) + def _update_air(self, instance, st) -> None: + w = self._window + if not w._confirm( + "Update Kyubi", + "Check for a newer Kyubi and update BlueStacks Air?", + "Looks up the latest build and, if it differs from the one " + "installed, rewrites Kyubi in the system image (BlueStacks closes " + "first). Your modules and the Kyubi app stay. Nothing happens if " + "you are already up to date.
"): + return + app_path, data_dir = self._air_args(instance) + installed_sha = (st.get("payload_sha256") or "").lower() + had_manager = "manager" in (st.get("components") or []) + + def job(progress): + progress("Checking the latest Kyubi...", 0) + try: + latest_ver, latest_sha = magisk_payload.latest_identity( + progress=lambda m: progress(m, 3)) + except RuntimeError as exc: + return "Could not check for an update: %s" % exc + if installed_sha and latest_sha == installed_sha: + return "Kyubi is already up to date." + progress("Updating to %s; closing BlueStacks..." % latest_ver, 5) + instance_handler.terminate_bluestacks() + QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) + macos_kyubi.install(app_path, progress=StepReporter(progress, _STEPS_UPDATE), + data_dir=data_dir) + if had_manager: + macos_kyubi.add_component(app_path, "manager", data_dir) + return ("Kyubi updated to %s. Start BlueStacks; if the Kyubi app " + "asks, update it too." % latest_ver) + + w._run_async(job, "Updating Kyubi...") + def _adb_and_port(self, instance): """(adb_exe, port) for an over-ADB action on a running instance, or (None, None) after warning if ADB isn't present.""" @@ -278,18 +427,17 @@ def handle_install_manager(self) -> None: adb_exe, port = self._adb_and_port(instance) if not adb_exe: return - data_path = instance["data_path"] def job(progress): relay = StepReporter(progress, _STEPS_MANAGER) - relay("Fetching the Magisk manager...") + relay("Fetching the Kyubi app...") apk = magisk_payload.fetch_apk(self._cache_dir(), progress=relay) msg = adb_handler.install_manager(adb_exe, port, apk, progress=relay) - magisk_system.add_component(data_path, "manager") # reflect it in the status + self._add_component(instance, "manager") # reflect it in the status w.show_notice.emit("Manager installed", msg) return msg - w._run_async(job, "Installing the Magisk manager into %s..." % uid) + w._run_async(job, "Installing the Kyubi app into %s..." % uid) def handle_uninstall_manager(self) -> None: w = self._window @@ -299,16 +447,15 @@ def handle_uninstall_manager(self) -> None: adb_exe, port = self._adb_and_port(instance) if not adb_exe: return - data_path = instance["data_path"] def job(progress): msg = adb_handler.uninstall_manager( adb_exe, port, progress=StepReporter(progress, _STEPS_MANAGER)) - magisk_system.remove_component(data_path, "manager") + self._remove_component(instance, "manager") w.show_notice.emit("Manager removed", msg) return msg - w._run_async(job, "Removing the Magisk manager from %s..." % uid) + w._run_async(job, "Removing the Kyubi app from %s..." % uid) def handle_install_rezygisk(self) -> None: w = self._window @@ -327,7 +474,7 @@ def job(progress): 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 + return msg w._run_async(job, "Installing ReZygisk into %s..." % uid) @@ -346,7 +493,7 @@ def job(progress): zip_path = lsposed_payload.fetch_module(self._cache_dir(), progress=relay) msg = adb_handler.install_module(adb_exe, port, zip_path, progress=relay) w.show_notice.emit("LSPosed installed", msg) - return ("%s Reboot the instance to activate it (needs ReZygisk); then " - "manage modules from the LSPosed app." % msg) + return ("%s LSPosed needs ReZygisk; manage its modules from the " + "LSPosed app." % msg) w._run_async(job, "Installing LSPosed into %s..." % uid) diff --git a/views/main_window.py b/views/main_window.py index 01fd669..31a24ea 100644 --- a/views/main_window.py +++ b/views/main_window.py @@ -29,6 +29,7 @@ # the Air code paths below ever call into them. The UI branches on the # installation's `air_mode` flag rather than on the host platform, so a # platform check here would be the wrong question to ask. +import macos_kyubi import macos_locator import macos_root @@ -268,8 +269,6 @@ def initialize_paths_and_instances(self) -> None: # inapplicable button is ever shown, even briefly. self._air_mode = any(i.get("air_mode") for i in self.installations) self.instances_page.set_air_mode(self._air_mode) - if self._air_mode: - self.nav_rail.set_destination_visible(NAV_MODULES, False) # Populate instance_data BEFORE refreshing the patch UI: the Dashboard # "N / M instances rooted" stat is derived from instance_data, so @@ -308,7 +307,8 @@ def _refresh_patch_ui(self, state: str | None = None) -> None: # button disabled, so a banner pointing there would be a dead end. self.instances_page.set_engine_locked_banner(state in ("unpatched", "partial")) - rooted = sum(1 for d in self.instance_data.values() if d.get("root_enabled")) + rooted = sum(1 for d in self.instance_data.values() + if d.get("root_enabled") or d.get("manager_root")) self.dashboard_page.set_rooted_count(rooted, len(self.instance_data)) def _engine_state(self) -> str: @@ -374,6 +374,12 @@ def update_instance_data(self) -> None: # per refresh rather than once per instance. air_rooted = (macos_root.image_root_state(inst["app_path"], inst["user_path"]) if air_mode else False) + air_kyubi = (bool(macos_kyubi.status(inst["app_path"], inst["user_path"])) + if air_mode else False) + if air_mode: + # Modules install through Kyubi, so the page only means + # something on Air once Kyubi is in. + self.nav_rail.set_destination_visible(NAV_MODULES, air_kyubi) disk_instances = set() if air_mode: @@ -439,6 +445,7 @@ def update_instance_data(self) -> None: "display_name": display_names.get(name, name), "patch_mode": patch_mode, "air_mode": air_mode, + "manager_root": air_kyubi, "app_path": inst.get("app_path"), "user_path": inst.get("user_path"), } From 2c9ad9e875993e48e56658f67eaaef8bfb9fd0e1 Mon Sep 17 00:00:00 2001 From: Rob Adams Jr <10876982+RobThePCGuy@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:48:02 -0500 Subject: [PATCH 2/2] Track the Kyubi app per Air instance Kyubi itself lives in the one system image every Air instance shares, but the Kyubi app is installed into each instance's own data disk. The app was recorded install-wide, so every row claimed it once one instance had it. It is now recorded per instance, and an update keeps that list. --- macos_kyubi.py | 62 +++++++++++++++++++++++++++----------- views/magisk_controller.py | 12 ++++---- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/macos_kyubi.py b/macos_kyubi.py index 6e3e45e..503b643 100644 --- a/macos_kyubi.py +++ b/macos_kyubi.py @@ -120,9 +120,23 @@ ) -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 status(app_path: str, data_dir: str = macos_locator.DATA_DIR, + instance: str | None = None) -> dict | None: + """Recorded Kyubi install, or None. Shaped like ``magisk_system.magisk_status``. + + Kyubi itself is install-wide (one shared system image), but the Kyubi app + is installed into each instance's own data disk, so ``manager`` appears in + ``components`` only for the instances it was installed into. + """ + kyubi = macos_root.read_modstate(app_path, data_dir).get("kyubi") + if not kyubi: + return None + result = dict(kyubi) + comps = ["system"] + if instance and instance in (kyubi.get("manager_instances") or []): + comps.append("manager") + result["components"] = comps + return result def _cache_dir() -> str: @@ -242,10 +256,12 @@ def _step(msg: str) -> None: BOOTANIM, mode=0o100664, uid=1000, gid=1000) results.append("installed Kyubi into %s" % GUEST_MAGISK_DIR) + # An update rewrites the image but leaves each instance's Kyubi app alone. + previous = (state.get("kyubi") or {}).get("manager_instances") or [] state["root"] = False state["kyubi"] = {"version": magisk_payload.PAYLOAD_VERSION, "payload_sha256": payload_sha, - "components": ["system"]} + "manager_instances": previous} 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.") @@ -292,24 +308,36 @@ def _step(msg: str) -> None: 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) + data_dir: str = macos_locator.DATA_DIR, + instance: str | None = None) -> None: + """Record the Kyubi app as installed in ``instance``. + + ``component`` mirrors ``magisk_system.add_component``; only "manager" is + tracked on Air, since the system part is implied by Kyubi being recorded. + """ + _set_manager(app_path, data_dir, instance, component == "manager", True) def remove_component(app_path: str, component: str, - data_dir: str = macos_locator.DATA_DIR) -> None: + data_dir: str = macos_locator.DATA_DIR, + instance: str | None = None) -> None: + _set_manager(app_path, data_dir, instance, component == "manager", False) + + +def _set_manager(app_path, data_dir, instance, is_manager, present) -> None: + if not (is_manager and instance): + return 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}) + names = set(kyubi.get("manager_instances") or []) + if present: + names.add(instance) + else: + names.discard(instance) + kyubi["manager_instances"] = sorted(names) + kyubi.pop("components", None) + # Re-stamping is safe: the image has not changed since read_modstate + # validated it. macos_root.write_modstate(app_path, data_dir, state) diff --git a/views/magisk_controller.py b/views/magisk_controller.py index 26f261c..9e0f133 100644 --- a/views/magisk_controller.py +++ b/views/magisk_controller.py @@ -65,20 +65,23 @@ def _air_args(instance) -> tuple[str, str]: def _status(self, instance): if instance.get("air_mode"): - return macos_kyubi.status(*self._air_args(instance)) + return macos_kyubi.status(*self._air_args(instance), + instance=instance["original_name"]) return magisk_system.magisk_status(instance["data_path"]) def _add_component(self, instance, component) -> None: if instance.get("air_mode"): app_path, data_dir = self._air_args(instance) - macos_kyubi.add_component(app_path, component, data_dir) + macos_kyubi.add_component(app_path, component, data_dir, + instance=instance["original_name"]) else: magisk_system.add_component(instance["data_path"], component) def _remove_component(self, instance, component) -> None: if instance.get("air_mode"): app_path, data_dir = self._air_args(instance) - macos_kyubi.remove_component(app_path, component, data_dir) + macos_kyubi.remove_component(app_path, component, data_dir, + instance=instance["original_name"]) else: magisk_system.remove_component(instance["data_path"], component) @@ -378,7 +381,6 @@ def _update_air(self, instance, st) -> None: return app_path, data_dir = self._air_args(instance) installed_sha = (st.get("payload_sha256") or "").lower() - had_manager = "manager" in (st.get("components") or []) def job(progress): progress("Checking the latest Kyubi...", 0) @@ -394,8 +396,6 @@ def job(progress): QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) macos_kyubi.install(app_path, progress=StepReporter(progress, _STEPS_UPDATE), data_dir=data_dir) - if had_manager: - macos_kyubi.add_component(app_path, "manager", data_dir) return ("Kyubi updated to %s. Start BlueStacks; if the Kyubi app " "asks, update it too." % latest_ver)