diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f0abc9..d04ac3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,10 +40,16 @@ jobs: run: python -m compileall -q . test: - # conftest.py's autouse fixture monkeypatches registry_handler at import - # time, and admin.py imports ctypes.wintypes -- both Windows-only, so the - # suite needs a real Windows runner and can't join the `verify` job above. - runs-on: windows-latest + # The suite covers both platforms' code paths (tests pin the branch they + # exercise rather than inheriting the host's), but a few things can only be + # checked natively: the pywin32 mutex and ctypes.wintypes on Windows, and + # the Air locator / su builder on macOS. macos-14 is Apple Silicon, which + # is the only architecture BlueStacks Air runs on. + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-14] + runs-on: ${{ matrix.os }} timeout-minutes: 15 steps: - name: Checkout @@ -57,5 +63,9 @@ jobs: - name: Install dependencies run: pip install -r requirements.txt -r requirements-dev.txt + # PyQt5 needs a display; the runners have none. Windows is fine headless, + # macOS needs Qt told so explicitly or every widget test aborts. - name: Run tests run: pytest + env: + QT_QPA_PLATFORM: offscreen diff --git a/README.md b/README.md index 25ce78f..f92b033 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ **A one-click tool to root BlueStacks 5.** It turns root access on and off from a simple window: no command line, no reverse-engineering, no hunting for an old version. Point it at your BlueStacks, click a couple of buttons, done. +Windows is the primary platform. **BlueStacks Air on Apple Silicon Macs is also supported** — see [Option 3](#option-3-macos--bluestacks-air-apple-silicon) and [Rooting BlueStacks Air](#rooting-bluestacks-air-macos). Air needs a different method entirely, because unlike Windows BlueStacks it ships no `su` for the usual config flag to unlock. The Intel (x86) macOS build is **not supported yet**. + > [!TIP] > **The latest BlueStacks now roots, no downgrade required.** BlueStacks 5.22 added a security check that shut rooted instances down with *"Android system doesn't meet security requirements."* This tool patches that check out, so you can root the current build. Confirmed working on **5.22.232.1002 / Android 13**: the latest official build as of July 2026. If someone told you to downgrade to 5.21, you don't have to anymore. @@ -24,6 +26,7 @@ - [Rooting the Current BlueStacks (Patch Mode)](#rooting-the-current-bluestacks-patch-mode) - [Magisk Modules, Kitsune Mask & Older Builds](#magisk-modules-kitsune-mask--older-builds) - [Keep Root After Updates](#keep-root-after-updates) + - [Rooting BlueStacks Air (macOS)](#rooting-bluestacks-air-macos) - [Troubleshooting](#troubleshooting) - [How It Works](#how-it-works) - [Features](#features) @@ -97,6 +100,30 @@ Output lands in the `dist/` folder. > [!NOTE] > You normally don't need to build by hand: pushing a version tag (`v*`) triggers the `release.yml` workflow, which builds this exact executable on a Windows runner and publishes it to **[Releases](https://github.com/RobThePCGuy/BlueStacks-Root-GUI/releases)** automatically. +### Option 3: macOS — BlueStacks Air (Apple Silicon) + +> [!IMPORTANT] +> **Apple Silicon only.** This supports **BlueStacks Air** on M-series Macs. The older **Intel (x86) macOS build is not supported yet** — it is a different product inside: VirtualBox-based, with VHDX disks and an x86 Android guest, none of which the Air method fits. The tool detects the player's architecture and refuses an Intel install rather than touching it, so there is no risk in trying. + +Run from source; there is no packaged build yet. + +```bash +git clone https://github.com/RobThePCGuy/BlueStacks-Root-GUI.git +cd BlueStacks-Root-GUI +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +brew install e2fsprogs # required: rooting edits the guest ext4 +python main.py +``` + +Two things are different from Windows, and both are one-time: + +1. **`brew install e2fsprogs`.** Rooting edits the Android system image with `debugfs`, which macOS does not ship. The Windows build bundles its own copy; the macOS one uses Homebrew's. The app tells you if it is missing. +2. **Grant App Management.** Rooting writes back into `BlueStacks.app`, and since macOS Ventura that needs **System Settings → Privacy & Security → App Management** for whatever runs the tool (your terminal when running from source). You may need to quit and reopen it afterwards. + +You do **not** need `sudo`, and normally you will not see a password prompt at all. That is not a shortcut — it is the only thing that works. App Management is granted to an *application*, not to a user, so a root helper does **not** inherit your terminal's grant: running the copy as root fails where writing it directly succeeds. `Root.qcow2` is already mode `rw-rw-rw-`, so once App Management is granted the plain write goes through. (The tool still falls back to an authorization prompt for installs whose image genuinely is not user-writable.) + ## Usage Guide The [Quick Start](#quick-start) covers the common case. This section has the full detail, plus the paths for Magisk/Kitsune modules and older builds. Launch the GUI **as administrator**: it opens on the **Dashboard**, auto-detects your install, and only shows the engine-patch button when a modern build (5.22.150.1014+) is present. @@ -139,6 +166,35 @@ schtasks /Change /TN "BlueStacksHelper_nxt" /DISABLE > [!WARNING] > The scheduled task is the one that matters most. Some builds don't even install the `BstHdUpdaterSvc` service, so the `sc.exe` lines will report *"service does not exist,"* which is fine; they still ship the `BlueStacksHelper_nxt` scheduled task, which can update independently. Disable whichever exist. Setting `bst.auto_update="0"` in `bluestacks.conf` does **not** work; it is silently ignored. +### Rooting BlueStacks Air (macOS) + +Set up per [Option 3](#option-3-macos--bluestacks-air-apple-silicon) first — `brew install e2fsprogs` and the App Management permission are both required. + +1. **Close BlueStacks**, then start the tool (`python main.py`). The Dashboard shows `AIR v5.21.x` and your data directory. +2. Go to **Instances**, tick your instance, and click **"Root (all instances)"**. +3. Wait. The pass takes a couple of minutes — most of it is unpacking and repacking a 1.7 GB image. +4. **Start BlueStacks.** `su` is now at `/system/xbin/su`, on the guest `PATH`, so both `adb shell su` and root-checker apps see it: + +```console +$ hd-adb shell "su -c id" +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. + +> [!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. + +> [!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. +> +> Undoing restores the backup **byte for byte**, which repairs the seal exactly. That is why the backup is kept until the last change is undone, and why undo restores it rather than editing the modification back out. + +Everything the tool put on disk lives in two places, both outside the bundle and both removed when you undo: `Root.qcow2.prepatch.bak` (the pristine image) and `Root.qcow2.modstate.json` (what is currently applied), in `/Users/Shared/Library/Application Support/BlueStacks/`. + ## Troubleshooting ### Common Issues @@ -179,6 +235,13 @@ schtasks /Change /TN "BlueStacksHelper_nxt" /DISABLE | 5.22.x (pre-5.22.150.1014) | Yes | Classic rooting + engine integrity patch to clear the security popup | | 5.22.150.1014+ | Yes | Patch mode: engine patch + `Data.vhdx` guest-`su` patch | +**macOS:** + +| BlueStacks | Root Working? | Method | +|-----------|---------------|--------| +| **Air** (Apple Silicon, arm64) | Yes | Inject `su` into the shared `Root.qcow2` — Air ships none | +| **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: | Edition | Registry key | Version | Mode | Android versions verified | @@ -242,6 +305,23 @@ Both patches are located by byte signature rather than hard-coded offsets, so th > [!NOTE] > The patch-mode method, the `HD-Player.exe` / `HD-MultiInstanceManager.exe` engine patch **and** the offline `Data.vhdx` guest-`su` patch that root the latest BlueStacks, was contributed by **[@AndnixSH](https://github.com/AndnixSH)** in [PR #27](https://github.com/RobThePCGuy/BlueStacks-Root-GUI/pull/27). See [Credits](#credits). +**BlueStacks Air (macOS, Apple Silicon):** a third method, because neither of the above applies. Air is a different product — a QEMU/`libqvirt` VM running an **arm64** Android 13 guest, with its config at `/Users/Shared/Library/Application Support/BlueStacks/`. (The Intel/x86 macOS build is a *fourth* product — VirtualBox with VHDX disks — and is not supported; detection checks the player's Mach-O architecture and skips it.) + +The important difference is that **Air ships no `su` at all.** Windows BlueStacks includes a guest `su` that `enable_root_access` merely *unlocks*, which is why flipping that flag roots it. Air's image has no `su`, no SuperSU and no Magisk anywhere in `/system` or its ramdisk — its init even tries to `import /init.superuser.rc` and logs that the file does not exist. The `enable_root_access` key still exists in Air's `bluestacks.conf`, and the player still resets `bst.feature.rooting` to `0` on every launch, but nothing reads them. **Setting those flags on Air does nothing.** + +So the tool *adds* an `su`: + +1. Converts `BlueStacks.app/Contents/img/Root.qcow2` to raw with BlueStacks' own bundled `qemu-img`, after backing it up outside the bundle. +2. Writes a 223-byte statically-linked aarch64 `su` into the guest ext4 with `debugfs`, owned by root with mode `06755`. SELinux is **disabled** on Air, so a classic setuid binary is sufficient — no policy patching. The binary is generated, not downloaded or vendored (see `macos_su.py`, which includes its full assembly listing). +3. Runs `e2fsck`, repacks to qcow2, and writes it back with one authorization prompt. + +Two consequences worth knowing: + +- **Root is install-wide, not per-instance.** Every Air instance boots that one shared image; there is no per-instance system image, so rooting covers all of them at once. +- **A BlueStacks update removes root**, because it replaces that image. The tool fingerprints the image and reports "not rooted" once it changes, rather than claiming root you no longer have. + +Undo restores the pristine backup, so nothing about the change is one-way. + ## Features - **Nav-Rail Layout**: A left navigation rail splits the app into four pages: **Dashboard** (install paths, engine-patch state, rooted-instance count), **Instances** (every per-instance action: Launch/Restart, R/W, and both root methods), **Modules** (push and flash a Magisk module), and **Privacy** (turn BlueStacks' own ads/telemetry off, plus an in-guest tracker block). A light/dark theme toggle sits in the header @@ -276,9 +356,13 @@ Both patches are located by byte signature rather than hard-coded offsets, so th - `engine_rules.py`: Qt-free decision logic for patch-gating and update-revert detection (unit-testable without a `QApplication`) - `config_handler.py`: Reads/writes `bluestacks.conf` - `instance_handler.py`: Modifies `.bstk` files, handles processes -- `registry_handler.py`: Reads BlueStacks paths and versions from the Windows Registry +- `registry_handler.py`: The single detection entry point. Reads BlueStacks paths and versions from the Windows Registry, and delegates to `macos_locator.py` on macOS - `constants.py`: Shared constants (keys, filenames, modes, process list, patch-mode version cutoff, `APP_VERSION`) -- `admin.py`: UAC elevation helpers (relaunch as administrator, network-drive-safe) +- `admin.py`: UAC elevation helpers (relaunch as administrator, network-drive-safe); a no-op on macOS, which elevates per-operation instead +- `platform_support.py`: Platform flags, and the `osascript` administrator prompt macOS uses for the one privileged step +- `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 - `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) @@ -295,9 +379,11 @@ Both patches are located by byte signature rather than hard-coded offsets, so th See `requirements.txt`. Key dependencies: - PyQt5 -- pywin32 +- pywin32 (Windows only — marked with an environment marker so `pip install` works on macOS) - psutil +On macOS, rooting also needs **e2fsprogs** (`brew install e2fsprogs`) for `debugfs`/`e2fsck`; the Windows build bundles its own copy in `tools/e2fsprogs/`. `qemu-img` and `adb` come from the BlueStacks Air bundle itself, so there is nothing to install for those. + ### Running Tests The suite uses `pytest` with `pytest-qt` (for the Qt view tests): diff --git a/adb_handler.py b/adb_handler.py index b2fc5a7..dedda04 100644 --- a/adb_handler.py +++ b/adb_handler.py @@ -20,9 +20,12 @@ logger = logging.getLogger(__name__) -# BlueStacks ships its own adb as HD-Adb.exe next to HD-Player.exe. Plain adb.exe -# is a fallback for unusual layouts. -_ADB_NAMES = ("HD-Adb.exe", "adb.exe") +# BlueStacks ships its own adb next to the player: HD-Adb.exe on Windows, +# lower-case `hd-adb` in BlueStacks.app/Contents/MacOS on Air. Plain adb is a +# fallback for unusual layouts. All names are tried on both platforms -- the +# lookup is a file-exists check, so the extra candidates cost nothing and keep +# this list from needing a platform branch. +_ADB_NAMES = ("HD-Adb.exe", "adb.exe", "hd-adb", "adb") # bluestacks.conf: bst.instance..status.adb_port="5555" _ADB_PORT_KEY = ".status.adb_port" @@ -56,13 +59,16 @@ def _run_install(cmd: list) -> subprocess.CompletedProcess: def find_adb(install_dirs) -> Optional[str]: - """First HD-Adb.exe / adb.exe found in any of ``install_dirs``, else None.""" + """First bundled adb found in any of ``install_dirs``, else None.""" for d in install_dirs: if not d: continue for name in _ADB_NAMES: cand = os.path.join(d, name) - if os.path.isfile(cand): + # Executable, not merely present: on macOS the bundle also contains + # non-executable data files, and handing a non-runnable path to + # subprocess would fail later with a far less obvious error. + if os.path.isfile(cand) and os.access(cand, os.X_OK): return cand return None diff --git a/admin.py b/admin.py index e2334ad..b92cd15 100644 --- a/admin.py +++ b/admin.py @@ -13,15 +13,30 @@ fails because ``Y:`` doesn't exist for the elevated process, so it never starts. We therefore rewrite the relaunch path to its UNC form (``\\\\vmware-host\\Shared Folders\\...``), which the elevated session can reach. + +macOS note +---------- +None of this applies to BlueStacks Air. Air keeps its config, instance disks +and logs under ``/Users/Shared``, all writable by the logged-in user, so the app +runs unelevated and elevates only for the single operation that needs it -- see +``platform_support.run_elevated``. The functions here degrade to "already fine, +carry on" rather than being absent, so ``main.py`` can call ``ensure_admin()`` +unconditionally. """ from __future__ import annotations -import ctypes import logging import os import subprocess import sys -from ctypes import wintypes + +import platform_support + +# ctypes.wintypes raises ValueError on import on non-Windows, so both the +# Windows-only imports are conditional. +if platform_support.IS_WINDOWS: + import ctypes + from ctypes import wintypes logger = logging.getLogger(__name__) @@ -31,6 +46,10 @@ def is_admin() -> bool: """Return True if the current process is running with administrator rights.""" + if not platform_support.IS_WINDOWS: + # There is no session-wide elevation to detect on macOS; report the + # truth (are we root?) without implying the app ought to be. + return os.geteuid() == 0 try: return bool(ctypes.windll.shell32.IsUserAnAdmin()) except Exception: # noqa: BLE001 - any failure means "assume not admin" @@ -81,6 +100,9 @@ def relaunch_as_admin() -> bool: Returns True if an elevated instance was started (caller should then exit), or False if elevation was declined or failed. """ + if not platform_support.IS_WINDOWS: + # No session-wide elevation on macOS -- see the module docstring. + return False if is_admin(): return False @@ -127,7 +149,12 @@ def ensure_admin() -> None: (unelevated) process exits. If they decline, this process keeps running so the app still opens -- it will simply surface permission errors when it tries to patch or kill processes. + + On macOS this is a no-op: the app is meant to run as the logged-in user and + elevates per-operation instead (``platform_support.run_elevated``). """ + if not platform_support.IS_WINDOWS: + return if is_admin(): return if relaunch_as_admin(): diff --git a/conftest.py b/conftest.py index fc69b95..5ffade9 100644 --- a/conftest.py +++ b/conftest.py @@ -20,6 +20,10 @@ def _no_real_bluestacks_detection(monkeypatch): on the host. Stubbing detection to "nothing installed" makes the stray init harmless everywhere; tests that need installations set them on the window directly after construction. + + This is the single detection entry point on every platform -- on macOS it + delegates to ``macos_locator`` -- so patching it here also keeps the suite + off a real BlueStacks Air install. """ monkeypatch.setattr( "registry_handler.get_all_bluestacks_installations", diff --git a/constants.py b/constants.py index 117e2f6..1497a6c 100644 --- a/constants.py +++ b/constants.py @@ -41,11 +41,20 @@ def parse_version(s): APP_SOURCE_NXT = "NXT" APP_SOURCE_MSI = "MSI" APP_SOURCE_NXT_CN = "CN" +# BlueStacks Air (macOS / Apple Silicon). Detected by macos_locator rather than +# the registry, and rooted by injecting su into the shared system image -- Air +# ships no guest su for the conf keys to unlock. See macos_root. +APP_SOURCE_AIR = "AIR" MODE_READWRITE = "Normal" MODE_READONLY = "Readonly" MODE_UNKNOWN = "Unknown" +# BlueStacks Air has no R/W concept to report: it ships no .bstk files, and +# every instance boots one shared, read-only system image from the app bundle. +# Distinct from MODE_UNKNOWN, which means "we could not tell" and causes an +# instance to be hidden -- "N/A" is a definite answer, so Air instances list. +MODE_NOT_APPLICABLE = "N/A" FASTBOOT_VDI = "fastboot.vdi" @@ -85,6 +94,18 @@ def parse_version(s): RIP_LEA_MODRM = frozenset({0x05, 0x0D, 0x15, 0x1D, 0x25, 0x2D, 0x35, 0x3D}) +# BlueStacks Air's processes. The player runs its VM in-process through +# libqvirt.dylib rather than spawning the bundled qemu-system-aarch64, but the +# uninstaller kills that name too, so both are covered. +MACOS_PROCESS_NAMES = [ + "BlueStacks", + "BlueStacks Air multi-instance manager", + "qemu-system-aarch64", + "hd-adb", + "HD-LogCollector", +] + + BLUESTACKS_PROCESS_NAMES = [ "HD-Player.exe", # Must be terminated before patching: the engine patch rewrites diff --git a/instance_handler.py b/instance_handler.py index 49c0717..e74bb04 100644 --- a/instance_handler.py +++ b/instance_handler.py @@ -10,15 +10,19 @@ import constants +import platform_support logger = logging.getLogger(__name__) def launch_instance(install_dir: str, instance_name: str) -> None: - """Start a specific BlueStacks instance (``HD-Player.exe --instance ``). + """Start a specific BlueStacks instance. - Raises with an actionable message if the player exe isn't found. + Raises with an actionable message if the player isn't found. """ + if platform_support.IS_MACOS: + _launch_instance_macos(install_dir, instance_name) + return exe = os.path.join(install_dir, "HD-Player.exe") if not os.path.isfile(exe): raise RuntimeError("HD-Player.exe not found in %s" % install_dir) @@ -26,6 +30,23 @@ def launch_instance(install_dir: str, instance_name: str) -> None: subprocess.Popen([exe, "--instance", instance_name], close_fds=True) +def _launch_instance_macos(install_dir: str, instance_name: str) -> None: + """Start an Air instance via the ``BlueStacks`` binary in the bundle. + + ``install_dir`` is ``BlueStacks.app/Contents/MacOS``. The binary is invoked + directly rather than through ``open -a`` so the ``--instance`` argument + actually reaches it: ``open`` would hand the app a launch *event* instead, + and a second instance name would be dropped in favour of re-activating the + already-running copy. + """ + exe = os.path.join(install_dir, "BlueStacks") + if not os.path.isfile(exe): + raise RuntimeError("BlueStacks not found in %s" % install_dir) + logger.info("Launching instance %s via %s", instance_name, exe) + subprocess.Popen([exe, "--instance", instance_name], + close_fds=True, start_new_session=True) + + def restart_instance(install_dir: str, instance_name: str, wait_ms: int = 2500) -> None: """Kill all BlueStacks processes, then relaunch ``instance_name``. @@ -264,6 +285,18 @@ def is_instance_readonly(instance_path: str) -> bool | None: ) return None +def bluestacks_process_names() -> list[str]: + """Process names to terminate, for whichever BlueStacks this platform runs. + + The two sets share no names at all: Air's player is plain ``BlueStacks`` + with no extension, and it has no service/agent processes because it runs + its VM in-process (``libqvirt.dylib``) rather than behind ``BstkSVC``. + """ + if platform_support.IS_MACOS: + return constants.MACOS_PROCESS_NAMES + return constants.BLUESTACKS_PROCESS_NAMES + + def terminate_bluestacks() -> bool: """ Attempts to terminate all known BlueStacks-related processes gracefully, @@ -276,11 +309,12 @@ def terminate_bluestacks() -> bool: terminated_any = False logger.info("Attempting to terminate BlueStacks processes...") processes_found: list[psutil.Process] = [] + target_names = bluestacks_process_names() for proc in psutil.process_iter(["pid", "name"]): try: proc_name = proc.info.get("name") - if proc_name in constants.BLUESTACKS_PROCESS_NAMES: + if proc_name in target_names: processes_found.append(proc) logger.debug(f"Found BlueStacks process: {proc_name} (PID: {proc.pid})") except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): diff --git a/macos_hosts.py b/macos_hosts.py new file mode 100644 index 0000000..4900b08 --- /dev/null +++ b/macos_hosts.py @@ -0,0 +1,139 @@ +"""In-guest tracker blocking for BlueStacks Air -- the macOS ``telemetry_block``. + +``telemetry_block`` does this on Windows by attaching ``Root.vhd`` as a disk and +driving the bundled Cygwin ``debugfs`` over it. None of that exists here: Air has +no VHD, no disk-attach step, and its system tree lives in a qcow2 inside the app +bundle. The *edit* is the same idea, though -- rewrite ``/system/etc/hosts`` in +the guest system image offline -- so this module reuses ``macos_root``'s image +session and keeps the block text itself shared with ``telemetry_block`` rather +than maintaining a second domain list that could drift. + +Scope, and why it differs from Windows +-------------------------------------- +This reaches apps running *inside* the emulator only. BlueStacks' own ads are +served by the host player, so ``ad_settings`` handles those; that part is +already cross-platform because it edits ``bluestacks.conf``. + +Like rooting on Air, the block is **install-wide**: every instance boots the one +shared system image, so there is no per-instance hosts file. (Windows has the +same property for a different reason -- ``Root.vhd`` is shared across all +instances of an Android version -- so this is a difference of degree, not kind.) + +Interaction with root +--------------------- +Both features edit the same image, and either can be applied first, so state is +kept in ``macos_root``'s single shared record rather than a file of its own. +That is not tidiness -- it is required. The record is validated by fingerprinting +the image, and *any* edit rewrites the image; two separately-fingerprinted files +would therefore invalidate each other, so blocking trackers on a rooted install +would make it read as un-rooted (and removing the block would then "restore" over +the root). One record, one fingerprint, re-stamped by whichever edit ran last. +""" +from __future__ import annotations + +import logging +import os + +import macos_locator +import macos_root +import telemetry_block + +logger = logging.getLogger(__name__) + +# Path inside the image; the guest bind-mounts /android/system as /system. +HOSTS_IMAGE_PATH = "/android/system/etc/hosts" +HOSTS_GUEST_PATH = "/system/etc/hosts" + +# Android ships this exact file; used when the guest has no hosts file at all so +# the result is still a valid hosts file rather than only the block. +DEFAULT_HOSTS = "127.0.0.1 localhost\n::1 ip6-localhost\n" + + +def blocked_hosts() -> tuple[str, ...]: + """The domain list, shared with the Windows implementation.""" + return telemetry_block.blocked_hosts() + + +def status(app_path: str, data_dir: str = macos_locator.DATA_DIR) -> dict | None: + """Recorded block state, or None when no block is in place. + + Shaped like ``telemetry_block.status`` so the Privacy page renders both + without caring which backend produced it. + """ + return macos_root.read_modstate(app_path, data_dir).get("hosts") + + +def apply(app_path: str, progress=None, + data_dir: str = macos_locator.DATA_DIR) -> list[str]: + """Null-route the tracker domains in the guest hosts file.""" + def _step(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + results: list[str] = [] + image = macos_locator.root_image_path(app_path) + state = macos_root.read_modstate(app_path, data_dir) + active = macos_root.applied_modifications(state) + # Shared with rooting: whichever edit lands first takes the pristine copy. + macos_root.ensure_backup(image, data_dir, image_is_pristine=not active, + step=_step) + + with macos_root.open_image(app_path, progress, results=results) as img: + _step("Editing the guest hosts file...") + current = img.read_file(HOSTS_IMAGE_PATH) + if current is None: + logger.info("Guest has no %s; creating one.", HOSTS_GUEST_PATH) + current = DEFAULT_HOSTS + # Strip any previous block first so re-applying updates the domain list + # instead of appending a second copy. + base = telemetry_block._strip_block(current) + img.write_file(HOSTS_IMAGE_PATH, base + telemetry_block._block_text()) + results.append("blocked %d hosts in %s" + % (len(blocked_hosts()), HOSTS_GUEST_PATH)) + + # Re-stamps root's entry against the rewritten image too, so blocking + # trackers cannot make an already-rooted install read as un-rooted. + state["hosts"] = {"applied": True, "hosts": len(blocked_hosts()), + "when": None, "path": HOSTS_GUEST_PATH} + macos_root.write_modstate(app_path, data_dir, state) + _step("Done. Restart BlueStacks for the change to take effect.") + return results + + +def remove(app_path: str, progress=None, + data_dir: str = macos_locator.DATA_DIR) -> list[str]: + """Remove the block, leaving any other hosts entries untouched.""" + def _step(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + results: list[str] = [] + image = macos_locator.root_image_path(app_path) + state = macos_root.read_modstate(app_path, data_dir) + active = macos_root.applied_modifications(state) + + # Last edit standing: restore the untouched image rather than editing it, + # which also repairs the app bundle's code-signature seal. If root is still + # applied this must NOT run -- restoring the pre-root image would silently + # un-root the user while claiming only to have removed a hosts block. + if not (active - {"hosts"}) 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("Block removed. Restart BlueStacks for the change to take effect.") + return results + + with macos_root.open_image(app_path, progress, results=results) as img: + _step("Restoring the guest hosts file...") + current = img.read_file(HOSTS_IMAGE_PATH) + if current is None: + results.append("no %s in the guest; nothing to undo" % HOSTS_GUEST_PATH) + else: + img.write_file(HOSTS_IMAGE_PATH, telemetry_block._strip_block(current)) + results.append("removed the block from %s" % HOSTS_GUEST_PATH) + + state["hosts"] = None + macos_root.write_modstate(app_path, data_dir, state) + _step("Done. Restart BlueStacks for the change to take effect.") + return results diff --git a/macos_locator.py b/macos_locator.py new file mode 100644 index 0000000..c863afe --- /dev/null +++ b/macos_locator.py @@ -0,0 +1,252 @@ +"""Locate a BlueStacks Air install -- the macOS counterpart to ``registry_handler``. + +macOS has no registry, and Air does not need one: every path this tool wants is +either fixed by the installer or recorded in a small plist. Detection therefore +reads the filesystem directly and returns the *same* installation dict shape +``registry_handler.get_all_bluestacks_installations()`` produces, so the UI and +``update_instance_data()`` do not care which backend found the install. + +Layout (BlueStacks Air 5.21.782, Apple Silicon) +----------------------------------------------- +``/Applications/BlueStacks.app/Contents/`` + ``MacOS/`` the player, plus the tools this project reuses: ``hd-adb`` + (guest ADB) and ``qemu-img`` (qcow2 conversion). + ``img/`` ``Root.qcow2`` (the shared, read-only Android system image), + ``data-org.qcow2`` (pristine userdata template), and the + guest ``kernel_hvf`` / ``initrd_hvf.img``. +``/Users/Shared/Library/Application Support/BlueStacks/`` + ``bluestacks.conf`` the same key/value file Windows uses, same keys. + ``Engine//`` one directory per instance, holding that instance's + ``data.qcow2``. Note there is **no** per-instance + ``Root.qcow2``: every instance boots the one image in + the app bundle, which is why rooting on Air is + install-wide rather than per-instance. + +Two Windows concepts have no analogue here and are reported as such: + +* **The engine patch.** That patches ``HD-Player.exe``'s disk-integrity check, + introduced in 5.22.150.1014. Air is 5.21.x, has no such check, and has no + ``.exe`` to patch -- so ``patch_mode`` is always False. +* **R/W mode.** On Windows each instance owns ``Root.vhd``/``fastboot.vdi`` and + a ``.bstk`` file whose ``Type="Readonly"`` this tool flips. Air ships no + ``.bstk`` files at all and mounts the shared image read-only regardless, so + there is nothing to toggle. +""" +from __future__ import annotations + +import logging +import os +import plistlib +import struct +from typing import Any + +import constants + +logger = logging.getLogger(__name__) + +Installation = dict[str, Any] + +# The installer hard-codes both of these; ``setting.plist`` records the app +# location too, and is preferred when present so a relocated bundle still works. +DEFAULT_APP_PATH = "/Applications/BlueStacks.app" +DATA_DIR = "/Users/Shared/Library/Application Support/BlueStacks" + +SETTING_PLIST = os.path.join(DATA_DIR, "setting.plist") +SETTING_PATH_KEY = "BlueStacks Air.path" # -> /Contents/MacOS + +ENGINE_DIRNAME = "Engine" +IMG_DIRNAME = "img" +ROOT_IMAGE_NAME = "Root.qcow2" + +PLAYER_NAME = "BlueStacks" # Contents/MacOS/BlueStacks + +# Mach-O header constants, used to tell the Apple Silicon build apart from an +# Intel one. Only the byte patterns are needed, so these are matched directly +# rather than unpacked into ints of ambiguous endianness. +_MACHO_64_LE = b"\xcf\xfa\xed\xfe" # MH_MAGIC_64 on a little-endian host +_MACHO_64_BE = b"\xfe\xed\xfa\xcf" +_FAT_MAGIC = b"\xca\xfe\xba\xbe" # universal binary (big-endian header) +_FAT_MAGIC_LE = b"\xbe\xba\xfe\xca" + +ARCH_ARM64 = "arm64" +ARCH_X86_64 = "x86_64" +_CPU_TYPES = {0x0100000C: ARCH_ARM64, 0x01000007: ARCH_X86_64, + 0x0000000C: "arm", 0x00000007: "i386"} + +# A universal binary with a silly arch count is corrupt, not something to walk. +_MAX_FAT_ARCHES = 16 + +# Bundled tools we reuse rather than requiring the user to install. +ADB_NAME = "hd-adb" +QEMU_IMG_NAME = "qemu-img" + +# Directories under Engine/ that are not instances. +_NON_INSTANCE_DIRS = frozenset({"UserData"}) + + +def _app_path_from_plist() -> str | None: + """Bundle path recorded in ``setting.plist``, or None if unusable.""" + try: + with open(SETTING_PLIST, "rb") as fh: + data = plistlib.load(fh) + except (OSError, plistlib.InvalidFileException): + logger.debug("could not read %s", SETTING_PLIST, exc_info=True) + return None + + macos_dir = data.get(SETTING_PATH_KEY) + if not macos_dir: + return None + # The key points at /Contents/MacOS; walk back up to the bundle. + bundle = os.path.dirname(os.path.dirname(str(macos_dir))) + return bundle if bundle.endswith(".app") else None + + +def find_app_path() -> str | None: + """Path to ``BlueStacks.app``, or None when Air is not installed.""" + for candidate in (_app_path_from_plist(), DEFAULT_APP_PATH): + if candidate and os.path.isdir(os.path.join(candidate, "Contents", "MacOS")): + return candidate + return None + + +def player_architectures(app_path: str) -> set[str]: + """CPU architectures the BlueStacks player binary is built for. + + Used to refuse an Intel install rather than silently mistreating it as + Air. The two macOS BlueStacks products are unrelated inside: the Apple + Silicon one (Air) runs an **arm64** Android guest from a qcow2, while the + Intel one is the older VirtualBox-based build with VHDX disks and an x86 + guest. Nothing here fits that -- and the failure would be quiet rather + than loud, since ``macos_root`` would happily write an *aarch64* ``su`` + into an x86 guest, leaving a modified system image and an ``su`` that + dies with an exec-format error. + + Returns an empty set if the binary is missing or unreadable. + """ + path = os.path.join(app_path, "Contents", "MacOS", PLAYER_NAME) + try: + with open(path, "rb") as fh: + head = fh.read(8) + if len(head) < 8: + return set() + magic, rest = head[:4], head[4:8] + + if magic in (_FAT_MAGIC, _FAT_MAGIC_LE): + # Universal binary: a big-endian count, then 20-byte entries + # each starting with the cputype. + count = struct.unpack(">I", rest)[0] + found = set() + for _ in range(min(count, _MAX_FAT_ARCHES)): + entry = fh.read(20) + if len(entry) < 20: + break + cpu = struct.unpack(">I", entry[:4])[0] + found.add(_CPU_TYPES.get(cpu, "cpu:0x%x" % cpu)) + return found + + if magic == _MACHO_64_LE: + cpu = struct.unpack("I", rest)[0] + else: + return set() + return {_CPU_TYPES.get(cpu, "cpu:0x%x" % cpu)} + except OSError: + logger.debug("could not read %s", path, exc_info=True) + return set() + + +def app_version(app_path: str) -> tuple[int, ...] | None: + """Version tuple from the bundle's ``Info.plist`` (e.g. (5, 21, 782)).""" + try: + with open(os.path.join(app_path, "Contents", "Info.plist"), "rb") as fh: + data = plistlib.load(fh) + except (OSError, plistlib.InvalidFileException): + logger.debug("could not read Info.plist under %s", app_path, exc_info=True) + return None + return constants.parse_version(data.get("CFBundleShortVersionString", "")) + + +def root_image_path(app_path: str) -> str: + """Path to the shared Android system image inside the bundle.""" + return os.path.join(app_path, "Contents", IMG_DIRNAME, ROOT_IMAGE_NAME) + + +def bundled_tool(app_path: str, name: str) -> str | None: + """Path to an executable BlueStacks ships in ``Contents/MacOS``.""" + path = os.path.join(app_path, "Contents", "MacOS", name) + return path if os.access(path, os.X_OK) else None + + +def engine_dir(data_dir: str = DATA_DIR) -> str: + """Directory holding one subdirectory per instance.""" + return os.path.join(data_dir, ENGINE_DIRNAME) + + +def list_instance_dirs(data_dir: str = DATA_DIR) -> list[str]: + """Instance directory names under ``Engine/`` (``UserData`` excluded).""" + base = engine_dir(data_dir) + try: + return sorted( + entry for entry in os.listdir(base) + if entry not in _NON_INSTANCE_DIRS + and os.path.isdir(os.path.join(base, entry)) + ) + except OSError: + logger.debug("could not list %s", base, exc_info=True) + return [] + + +def get_all_bluestacks_installations() -> list[Installation]: + """Detected Air installs, in ``registry_handler``'s dict shape. + + Returns at most one entry -- unlike Windows, where NXT / MSI / CN can be + installed side by side, macOS supports a single BlueStacks.app. + """ + app_path = find_app_path() + if not app_path: + logger.debug("No BlueStacks Air installation found.") + return [] + + # Fail closed on anything that is not the Apple Silicon build, including a + # binary we cannot read: reporting nothing costs an Intel user a confusing + # "not found", while proceeding would edit their system image with an + # aarch64 payload it cannot run. + arches = player_architectures(app_path) + if ARCH_ARM64 not in arches: + logger.warning( + "BlueStacks at %s is not the Apple Silicon (Air) build -- player " + "architecture %s. Only BlueStacks Air on Apple Silicon is " + "supported; the Intel/x86 macOS build is a different, " + "VirtualBox-based product this tool cannot root yet.", + app_path, ", ".join(sorted(arches)) or "unreadable") + return [] + + config_path = os.path.join(DATA_DIR, constants.BLUESTACKS_CONF_FILENAME) + if not os.path.isfile(config_path): + # The bundle exists but BlueStacks has never been run, so there are no + # instances and no conf. Report nothing rather than a half-install the + # UI would render as an empty, un-actionable row. + logger.info("BlueStacks Air found at %s but %s does not exist yet " + "(never launched?).", app_path, config_path) + return [] + + version = app_version(app_path) + logger.info("Found BlueStacks Air %s at %s", + ".".join(map(str, version)) if version else "?", app_path) + + return [{ + "source": constants.APP_SOURCE_AIR, + "user_path": DATA_DIR, + # Instance folders live under Engine/, not the data dir itself. + "data_path": engine_dir(DATA_DIR), + "install_path": os.path.join(app_path, "Contents", "MacOS"), + "config_path": config_path, + "version": version, + # Air has neither the 5.22 integrity check nor an .exe to patch. + "patch_mode": False, + # Rooting Air means injecting su into the shared system image; the + # conf-key mechanism that roots Windows BlueStacks does nothing here. + "air_mode": True, + "app_path": app_path, + }] diff --git a/macos_root.py b/macos_root.py new file mode 100644 index 0000000..0a952d9 --- /dev/null +++ b/macos_root.py @@ -0,0 +1,581 @@ +"""Root BlueStacks Air by injecting ``su`` into its Android system image. + +Why this exists instead of the conf toggle +------------------------------------------ +Windows BlueStacks ships a guest ``su`` that ``bst.instance.. +enable_root_access`` unlocks, which is why ``config_handler`` can root it by +editing one line. BlueStacks Air ships no ``su`` anywhere -- verified against +the live guest *and* offline against the image's ext4 (``/system/bin``, +``/system/xbin``, ``/system/app``, ``/system/priv-app`` and the ramdisk are all +clean). The conf keys still exist in Air's ``bluestacks.conf``, and the player +still resets ``bst.feature.rooting`` to ``"0"`` on every launch, but nothing +consumes them: there is no ``su`` for them to gate. Rooting Air means putting +one there. See ``macos_su`` for the binary and why it is built rather than +downloaded. + +What gets modified +------------------ +``BlueStacks.app/Contents/img/Root.qcow2`` -- one qcow2 holding a single ext4 +partition (MBR, type 0x83, starting at 1 MiB) whose ``/android/system`` tree the +guest bind-mounts as ``/system``. So ``/android/system/xbin/su`` in the image is +``/system/xbin/su`` in Android, which is already on the default ``PATH``. + +Two consequences follow from this file living in the app bundle, and both are +deliberate behaviour rather than limitations worth hiding: + +* **Rooting is install-wide, not per-instance.** Every Air instance boots this + same image; there is no per-instance ``Root.qcow2`` (one placed in the + instance directory is ignored -- tested). The UI reflects that. +* **Writing it needs the App Management privilege, not root.** The file's mode + is ``rw-rw-rw-``, so the user can already write it; what refuses the write is + macOS App Management, and that is granted to an *application*, so elevating + does not inherit it -- a root ``cp`` through ``osascript`` is denied where a + direct write succeeds. Hence the direct write first and elevation only as a + fallback (``_install_image``). New files cannot be created in + ``Contents/img`` at all (root-owned directory), so the image is overwritten + in place and the backup lives outside the bundle. +* **Any edit invalidates the bundle's code signature**, because ``Root.qcow2`` + is a sealed resource. BlueStacks still launches, but ``codesign --verify`` + fails until the change is undone -- which is why undo restores the backup + byte-for-byte rather than editing the modification back out. + +The pipeline +------------ +``qemu-img`` (BlueStacks' own copy) converts qcow2 to raw and back; ``debugfs`` +edits the ext4 in place through e2fsprogs' ``?offset=`` syntax, so the partition +never has to be sliced out and spliced back. ``e2fsck`` runs before the image is +handed back to BlueStacks: a corrupt system image would fail to boot, and it is +cheap insurance next to a 1.7 GB rewrite. +""" +from __future__ import annotations + +import contextlib +import json +import logging +import os +import shutil +import struct +import subprocess +import tempfile + +import macos_locator +import macos_su +import platform_support + +logger = logging.getLogger(__name__) + +# Path *inside the image*; the guest bind-mounts /android/system as /system. +SU_IMAGE_PATH = "/android/system/xbin/su" +SU_GUEST_PATH = "/system/xbin/su" +SU_MODE = 0o106755 # regular file | setuid | rwxr-xr-x + +# Kept beside bluestacks.conf, not in the bundle: Contents/img is root-owned, so +# a sibling backup cannot be created there even with the image world-writable. +BACKUP_NAME = "Root.qcow2.prepatch.bak" + +# ONE state file for every edit this tool makes to the system image, not one +# per feature. Both root and the hosts block live in the same qcow2 and are +# validated by fingerprinting that qcow2, so per-feature state files would +# invalidate each other: applying the hosts block rewrites the image, which +# would make root's separately-stored fingerprint stop matching and silently +# report the instance as un-rooted. A single record with a single fingerprint +# cannot drift from itself. +STATE_NAME = "Root.qcow2.modstate.json" + +# debugfs/e2fsck are not part of macOS. Homebrew keeps e2fsprogs keg-only, so +# its binaries are never on PATH even when installed -- look there explicitly +# before falling back to whatever PATH offers. +_E2FS_SEARCH_DIRS = ( + "/opt/homebrew/opt/e2fsprogs/sbin", # Apple Silicon Homebrew + "/opt/homebrew/sbin", + "/usr/local/opt/e2fsprogs/sbin", # Intel Homebrew + "/usr/local/sbin", + "/opt/local/sbin", # MacPorts +) + +INSTALL_HINT = ( + "This needs the e2fsprogs tools (debugfs, e2fsck) to edit the Android " + "system image.\n\nInstall them with:\n brew install e2fsprogs" +) + +# macOS App Management (TCC). Since Ventura, writing into another application's +# bundle is refused with EPERM unless the *responsible* app holds this +# privilege -- and, unlike a permissions problem, elevating does not help: +# running the copy as root through osascript is denied identically. The only +# fix is the user granting it, so say exactly that instead of surfacing a bare +# "Operation not permitted". +APP_MANAGEMENT_HINT = ( + "macOS blocked the write into BlueStacks.app.\n\n" + "Modifying another app's bundle needs the \"App Management\" privilege, " + "which administrator rights alone do not provide. Grant it here:\n\n" + " System Settings > Privacy & Security > App Management\n\n" + "...then enable this app (or your terminal, if you are running from " + "source) and try again." +) + +_EPERM_MARKERS = ("Operation not permitted", "not permitted") + + +class RootError(RuntimeError): + """Rooting could not be completed; the message is user-facing.""" + + +# -------------------------------------------------------------------------- +# Tool discovery +# -------------------------------------------------------------------------- + +def _find_tool(name: str) -> str | None: + for directory in _E2FS_SEARCH_DIRS: + candidate = os.path.join(directory, name) + if os.access(candidate, os.X_OK): + return candidate + return shutil.which(name) + + +def find_e2fs_tools() -> tuple[str, str]: + """Return ``(debugfs, e2fsck)`` paths, or raise with an install hint.""" + debugfs, e2fsck = _find_tool("debugfs"), _find_tool("e2fsck") + if not debugfs or not e2fsck: + raise RootError(INSTALL_HINT) + return debugfs, e2fsck + + +def find_qemu_img(app_path: str) -> str: + """BlueStacks' bundled ``qemu-img``; it is always present in the bundle.""" + tool = macos_locator.bundled_tool(app_path, macos_locator.QEMU_IMG_NAME) + if not tool: + raise RootError( + "Could not find qemu-img inside %s. Is this a complete BlueStacks " + "Air installation?" % app_path) + return tool + + +def _run(argv: list[str], *, label: str) -> subprocess.CompletedProcess: + logger.debug("running %s", argv) + proc = subprocess.run(argv, capture_output=True, text=True) + if proc.returncode != 0: + raise RootError("%s failed: %s" % (label, (proc.stderr or proc.stdout).strip())) + return proc + + +# -------------------------------------------------------------------------- +# Image geometry +# -------------------------------------------------------------------------- + +def partition_offset(raw_path: str) -> int: + """Byte offset of the first MBR partition in ``raw_path``. + + Read rather than hard-coded at 1 MiB: a BlueStacks update is free to lay the + image out differently, and silently editing the wrong offset would corrupt + the system image instead of failing. + """ + with open(raw_path, "rb") as fh: + mbr = fh.read(512) + if len(mbr) < 512 or mbr[510:512] != b"\x55\xaa": + raise RootError("Android system image has no MBR signature; refusing to edit it.") + for i in range(4): + entry = mbr[446 + i * 16: 446 + (i + 1) * 16] + part_type = entry[4] + start_lba = struct.unpack(" str: + """Drive debugfs over the ext4 at ``offset`` inside ``image``.""" + argv = [debugfs] + if writable: + argv.append("-w") + argv += ["-f", "/dev/stdin", "%s?offset=%d" % (image, offset)] + proc = subprocess.run(argv, input=commands, capture_output=True, text=True) + # debugfs reports per-command failures on stdout and still exits 0, so the + # caller checks the text; a non-zero exit is a hard failure. + if proc.returncode != 0: + raise RootError("debugfs failed: %s" % (proc.stderr or proc.stdout).strip()) + return proc.stdout + + +# -------------------------------------------------------------------------- +# Recorded state +# -------------------------------------------------------------------------- +# Reading the real answer means converting 1.7 GB of qcow2, which cannot run on +# the 5-second status refresh. Instead the injected state is recorded next to +# the backup, fingerprinted with the image's size and mtime. If BlueStacks +# updates and replaces the image, the fingerprint stops matching and the state +# reads "not rooted" -- which is exactly what happened to the guest. + +def _state_path(data_dir: str) -> str: + return os.path.join(data_dir, STATE_NAME) + + +def backup_path(data_dir: str) -> str: + return os.path.join(data_dir, BACKUP_NAME) + + +def _fingerprint(image: str) -> dict: + st = os.stat(image) + return {"size": st.st_size, "mtime": int(st.st_mtime)} + + +def read_modstate(app_path: str, data_dir: str = macos_locator.DATA_DIR) -> dict: + """Everything this tool has done to the current system image. + + ``{"root": bool, "hosts": {...}|None}``. Returns empty when the image no + longer matches what was recorded -- a BlueStacks update replaces the image + and takes every edit with it, so reporting the old contents would claim + modifications the guest no longer has. + """ + image = macos_locator.root_image_path(app_path) + if not os.path.isfile(image): + return {} + try: + with open(_state_path(data_dir), encoding="utf-8") as fh: + state = json.load(fh) + except (OSError, ValueError): + return {} + if state.get("image") != _fingerprint(image): + logger.info("Android system image changed since it was modified (a " + "BlueStacks update would do this); reporting it as stock.") + return {} + return state + + +def write_modstate(app_path: str, data_dir: str, state: dict) -> None: + """Record ``state``, stamped with the image as it is right now. + + Must be called *after* the image has been written back, so the fingerprint + describes the image the user actually has. + """ + image = macos_locator.root_image_path(app_path) + payload = {k: v for k, v in state.items() if v} + if not payload: + _clear_state(data_dir) + return + payload["image"] = _fingerprint(image) + 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. + payload["su_sha256"] = macos_su.su_sha256() + payload["su_path"] = SU_GUEST_PATH + with open(_state_path(data_dir), "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + + +def _clear_state(data_dir: str) -> None: + try: + os.unlink(_state_path(data_dir)) + except FileNotFoundError: + pass + + +def image_root_state(app_path: str, data_dir: str = macos_locator.DATA_DIR) -> bool: + """True when this tool's ``su`` is believed to be in the current image.""" + return bool(read_modstate(app_path, data_dir).get("root")) + + +def verify_image_rooted(app_path: str) -> bool: + """Authoritative -- and slow -- check that ``su`` really is in the image. + + Converts the whole image, so this is for explicit verification only; the UI + uses :func:`image_root_state`. + """ + debugfs, _ = find_e2fs_tools() + qemu_img = find_qemu_img(app_path) + image = macos_locator.root_image_path(app_path) + workdir = tempfile.mkdtemp(prefix="bsroot-verify-") + try: + raw = os.path.join(workdir, "Root.raw") + _run([qemu_img, "convert", "-O", "raw", image, raw], label="qemu-img convert") + out = _debugfs(debugfs, raw, partition_offset(raw), + "stat %s\nquit\n" % SU_IMAGE_PATH, writable=False) + return "Inode" in out and "File not found" not in out + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +# -------------------------------------------------------------------------- +# The operation +# -------------------------------------------------------------------------- + +def _copy_in_place(source: str, dest: str) -> None: + """Overwrite ``dest``'s contents, keeping its inode, owner and mode. + + Opened ``r+b`` and truncated rather than replaced: ``Contents/img`` is + root-owned, so no new file can be created there to rename over, and the + image itself is what must stay put. + """ + with open(source, "rb") as src, open(dest, "r+b") as dst: + shutil.copyfileobj(src, dst, length=8 * 1024 * 1024) + dst.truncate() + + +def _install_image(source: str, image: str, *, label: str) -> None: + """Write ``source`` over the bundle's image, unelevated where possible. + + Order matters, and not for the obvious reason. ``Root.qcow2`` ships mode + ``rw-rw-rw-``, so the logged-in user can already write it -- the only thing + standing in the way is macOS App Management. And that privilege is granted + to an *application*, not to a user: a root shell spawned through + ``osascript`` does **not** inherit the grant its parent holds. Escalating + therefore fails exactly where a plain write succeeds (observed: a direct + write permitted while ``cp`` under ``with administrator privileges`` was + refused on the same file, seconds apart). + + So try the direct write first -- which also means the common case needs no + password prompt at all -- and keep elevation as the fallback for an install + whose image is not user-writable. + """ + try: + _copy_in_place(source, image) + return + except OSError as direct_exc: + logger.info("Direct write to %s failed (%s); trying with administrator " + "rights.", image, direct_exc) + direct_blocked = isinstance(direct_exc, PermissionError) + + try: + platform_support.run_elevated( + "/bin/cp %s %s\n" % (platform_support.shell_quote(source), + platform_support.shell_quote(image)), + label=label) + except platform_support.ElevationError as exc: + # EPERM from *both* paths is the App Management signature: a plain + # permissions problem would have been fixed by running as root. + if direct_blocked or any(marker in str(exc) for marker in _EPERM_MARKERS): + raise RootError(APP_MANAGEMENT_HINT) from exc + raise RootError(str(exc)) from exc + + +# The edits this tool can have applied to the system image. Root and the hosts +# 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") + + +def applied_modifications(state: dict) -> set[str]: + """Which of :data:`MODIFICATIONS` a modstate record says are in place.""" + return {key for key in MODIFICATIONS if state.get(key)} + + +def ensure_backup(image: str, data_dir: str, *, image_is_pristine: bool, + step=None) -> str | None: + """Keep one pristine copy of the system image, shared by every edit. + + Refreshed only while the image is unmodified, which is what makes it + *pristine*: overwriting it from an already-rooted image would turn "undo" + into "restore the rooted image". Not refreshing at all would be worse in a + different way -- a BlueStacks update replaces the image, and a stale backup + would undo to the previous build's ``/system``. + """ + backup = backup_path(data_dir) + if os.path.isfile(backup) and not image_is_pristine: + return backup + if step: + step("Backing up the original system image (this is how it is undone)...") + _require_free_space(backup, os.path.getsize(image) + 2**30) + shutil.copy2(image, backup) + return backup + + +def restore_pristine(image: str, data_dir: str, step=None) -> list[str]: + """Put the untouched image back and drop the backup. + + Byte-for-byte, which also repairs the app bundle's code-signature seal -- + any edit to ``Root.qcow2`` invalidates it, so restoring identical bytes is + the only way to leave the install exactly as it was found. + """ + backup = backup_path(data_dir) + results = [] + if step: + step("Restoring the original Android system image...") + _install_image(backup, image, + label="restore the original BlueStacks system image") + results.append("restored %s from backup" % os.path.basename(image)) + try: + os.unlink(backup) + results.append("removed the backup copy") + except OSError: + logger.warning("Could not remove backup %s", backup, exc_info=True) + return results + + +def _require_free_space(path: str, needed: int) -> None: + free = shutil.disk_usage(os.path.dirname(path) or "/").free + if free < needed: + raise RootError( + "Not enough free disk space: this needs about %.1f GB free and " + "only %.1f GB is available." % (needed / 2**30, free / 2**30)) + + +class ImageSession: + """An unpacked system image, open for editing. + + Yielded by :func:`open_image`. ``run`` drives debugfs against the guest + filesystem; ``read_file`` and ``write_file`` are the file-level helpers the + hosts editor needs (``macos_hosts``). + """ + + def __init__(self, raw: str, offset: int, debugfs: str, workdir: str): + self.raw = raw + self.offset = offset + self.workdir = workdir + self._debugfs = debugfs + + def run(self, commands: str) -> str: + out = _debugfs(self._debugfs, self.raw, self.offset, commands, writable=True) + if "Bad magic" in out or "Filesystem not open" in out: + raise RootError("Could not open the guest filesystem: %s" % out.strip()) + return out + + def read_file(self, path: str) -> str | None: + """Contents of a file in the guest, or None when it does not exist.""" + dest = os.path.join(self.workdir, "dump.tmp") + if os.path.exists(dest): + os.unlink(dest) + self.run("dump %s %s\nquit\n" % (path, dest)) + if not os.path.isfile(dest): + return None + with open(dest, encoding="utf-8", errors="replace") as fh: + return fh.read() + + def write_file(self, path: str, content: str, *, mode: int = 0o100644) -> None: + """Replace a file in the guest, root-owned with ``mode``.""" + src = os.path.join(self.workdir, "write.tmp") + with open(src, "w", encoding="utf-8", newline="\n") as fh: + fh.write(content) + parent, name = path.rsplit("/", 1) + # debugfs `write` refuses to overwrite, and links the destination as a + # bare name in the current directory -- hence the rm, the cd, and the + # unqualified name. + self.run( + "rm {path}\ncd {parent}\nwrite {src} {name}\n" + "sif {name} mode 0{mode:o}\nsif {name} uid 0\nsif {name} gid 0\nquit\n" + .format(path=path, parent=parent, src=src, name=name, mode=mode)) + + +@contextlib.contextmanager +def open_image(app_path: str, progress=None, *, results: list[str] | None = None): + """Unpack the Air system image, yield an :class:`ImageSession`, repack it. + + The image is only written back if the body completes without raising, so a + failed edit leaves BlueStacks exactly as it was rather than installing a + half-modified system image. + """ + def _step(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + if not platform_support.IS_MACOS: + raise RootError("Editing the BlueStacks Air system image is macOS-only.") + + image = macos_locator.root_image_path(app_path) + if not os.path.isfile(image): + raise RootError("Android system image not found at %s" % image) + + debugfs, e2fsck = find_e2fs_tools() + qemu_img = find_qemu_img(app_path) + # A 10 GiB sparse raw plus the rebuilt qcow2; the raw only ever holds the + # ~1.7 GB that is actually allocated, but leave room for both. + _require_free_space(tempfile.gettempdir(), 6 * 2**30) + + workdir = tempfile.mkdtemp(prefix="bsimg-") + try: + raw = os.path.join(workdir, "Root.raw") + _step("Unpacking the Android system image...") + _run([qemu_img, "convert", "-O", "raw", image, raw], label="qemu-img convert") + + offset = partition_offset(raw) + logger.info("ext4 partition at offset %d", offset) + + yield ImageSession(raw, offset, debugfs, workdir) + + _step("Checking the guest filesystem...") + # e2fsck exits 1 when it fixed something, which is a success here; only + # 4+ (uncorrected errors) means the image is unusable. + check = subprocess.run([e2fsck, "-fy", "%s?offset=%d" % (raw, offset)], + capture_output=True, text=True) + if check.returncode >= 4: + raise RootError("The guest filesystem failed its check and was not " + "written back:\n%s" % (check.stdout or check.stderr)) + if check.returncode == 1 and results is not None: + results.append("e2fsck repaired the filesystem") + + _step("Repacking the system image...") + new_image = os.path.join(workdir, "Root.qcow2") + _run([qemu_img, "convert", "-O", "qcow2", raw, new_image], + label="qemu-img convert") + + _step("Writing the image back into BlueStacks...") + _install_image(new_image, image, label="update the BlueStacks system image") + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +def set_root(app_path: str, enabled: bool, progress=None, + data_dir: str = macos_locator.DATA_DIR) -> list[str]: + """Add (or remove) ``su`` in the Air system image. Returns log lines.""" + def _step(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + if not platform_support.IS_MACOS: + raise RootError("BlueStacks Air rooting is macOS-only.") + + image = macos_locator.root_image_path(app_path) + if not os.path.isfile(image): + raise RootError("Android system image not found at %s" % image) + + backup = backup_path(data_dir) + state = read_modstate(app_path, data_dir) + active = applied_modifications(state) + results: list[str] = [] + + # Undoing the *last* edit means putting the pristine image back, which is + # faster than editing, and is the only thing that repairs the bundle's + # code-signature seal. Deliberately ahead of the tool lookup below: this + # path is a plain file copy, and someone who has since removed e2fsprogs + # must never be trapped in a rooted state by a dependency undo does not use. + if not enabled and not (active - {"root"}) and os.path.isfile(backup): + results += restore_pristine(image, data_dir, _step) + _clear_state(data_dir) # nothing is applied any more, by definition + _step("Root removed. Restart BlueStacks for the change to take effect.") + return results + + if enabled: + ensure_backup(image, data_dir, image_is_pristine=not active, step=_step) + results.append("backed up to %s" % backup) + + with open_image(app_path, progress, results=results) as img: + if enabled: + _step("Installing su into the guest system...") + su_path = os.path.join(img.workdir, "su") + with open(su_path, "wb") as fh: + fh.write(macos_su.build_su()) + parent, name = SU_IMAGE_PATH.rsplit("/", 1) + # rm first so re-running is idempotent rather than failing on an + # existing inode; a missing file makes rm a harmless no-op. + img.run( + "rm {img}\ncd {parent}\nwrite {src} {name}\n" + "sif {name} mode 0{mode:o}\nsif {name} uid 0\nsif {name} gid 0\nquit\n" + .format(img=SU_IMAGE_PATH, parent=parent, src=su_path, + name=name, mode=SU_MODE)) + results.append("installed %s (mode %04o, uid 0)" + % (SU_GUEST_PATH, SU_MODE & 0o7777)) + else: + # No backup to fall back on -- remove the inode we added. + _step("Removing su from the guest system...") + img.run("rm %s\nquit\n" % SU_IMAGE_PATH) + results.append("removed %s" % SU_GUEST_PATH) + + # Rewrite the whole record: the image just changed, so every edit still + # applied has to be re-stamped against the new fingerprint, not just root's. + state["root"] = enabled + write_modstate(app_path, data_dir, state) + + _step("Done. Restart BlueStacks for the change to take effect.") + return results diff --git a/macos_su.py b/macos_su.py new file mode 100644 index 0000000..275908d --- /dev/null +++ b/macos_su.py @@ -0,0 +1,177 @@ +"""The minimal aarch64 ``su`` this tool injects into a BlueStacks Air image. + +Why a hand-assembled binary instead of a real one +------------------------------------------------- +BlueStacks Air ships **no** ``su`` at all. Unlike Windows BlueStacks -- whose +guest image contains a SuperSU-based ``su`` that ``bst.instance.. +enable_root_access`` merely *unlocks* -- the Air image has no ``su``, no +SuperSU, no Magisk anywhere in ``/system`` or the ramdisk. Its init even tries +to ``import /init.superuser.rc`` and logs that the file does not exist. So the +conf key that roots Windows BlueStacks is inert here: there is nothing for it +to switch on. Rooting Air means *adding* an ``su``. + +That leaves the question of where the binary comes from. Vendoring a prebuilt +one drags in a GPLv3 payload this project deliberately does not redistribute +(see ``magisk_payload``'s docstring on why Kyubi is downloaded, never shipped), +and downloading one at runtime means trusting a third party for the single most +security-sensitive byte sequence in the whole tool. Building it here avoids +both: it is 223 bytes of auditable, deterministic machine code with no +dependencies, and the assembly it corresponds to is written out in full below. + +Why this is enough +------------------ +SELinux is **disabled** on Air (``getenforce`` -> ``Disabled``), so no policy +patching is needed -- the kernel honours the setuid bit and nothing else gets a +vote. The classic setuid-root ``su`` therefore works exactly as it did on +pre-SELinux Android: the binary is owned by root with mode ``06755``, so the +kernel gives it euid 0 on exec, and it drops that to a *real* uid 0 before +handing control to the shell. + +Argument handling is free. ``execve``-ing ``/system/bin/sh`` with the original +``argv`` untouched gives ``su -c "cmd"`` -> ``sh -c "cmd"`` and bare ``su`` -> +an interactive ``sh``, because a shell parses its options from ``argv[1:]`` and +ignores what ``argv[0]`` happens to say. That is the whole of ``su``'s +interface as this tool and every root-checking app use it. + +The program +----------- +Entry is the raw ELF entry point: no libc, no relocations, no dynamic loader. +On entry the kernel has put the stack pointer at ``argc``, immediately followed +by ``argv[]``, a NULL, then ``envp[]``:: + + mov x9, sp // x9 = &argc + mov x0, #0 + mov x1, #0 + mov x2, #0 + mov x8, #149 // __NR_setresgid + svc #0 // setresgid(0, 0, 0) + mov x0, #0 + mov x1, #0 + mov x2, #0 + mov x8, #147 // __NR_setresuid + svc #0 // setresuid(0, 0, 0) + ldr x10, [x9] // argc + add x1, x9, #8 // argv = &argc + 1 + add x11, x10, #1 // argc + 1 (argv's NULL terminator) + lsl x11, x11, #3 // * sizeof(char *) + add x2, x1, x11 // envp = argv + argc + 1 + adr x0, path // "/system/bin/sh" + mov x8, #221 // __NR_execve + svc #0 // execve(path, argv, envp) + mov x0, #1 // only reached if execve failed + mov x8, #93 // __NR_exit + svc #0 + path: + .asciz "/system/bin/sh" + +``setresuid``/``setresgid`` (not plain ``setuid``) set the real, effective and +saved IDs in one call, so the shell cannot drop back to the calling user and +nothing downstream sees a mismatched real-vs-effective uid. Both run before +``execve`` so a failure to elevate can never yield an unprivileged shell that +merely *looks* rooted -- the syscalls cannot fail from a setuid-root binary, +and if they somehow did the exec still happens with euid 0 unchanged. + +Syscall numbers are from the asm-generic ABI that arm64 Linux uses; they are +stable kernel ABI and are the same on the 5.15 guest kernel Air runs. +""" +from __future__ import annotations + +import struct + +# --------------------------------------------------------------------------- +# Machine code. Each entry is (mnemonic, encoded word); the mnemonics are kept +# beside the bytes so a reader can check the encoding without an assembler, and +# ``tests/test_macos_su.py`` asserts the words still disassemble to these. +# --------------------------------------------------------------------------- +SHELL_PATH = b"/system/bin/sh\0" + +_INSNS: tuple[tuple[str, int], ...] = ( + ("mov x9, sp", 0x910003E9), + ("mov x0, #0", 0xD2800000), + ("mov x1, #0", 0xD2800001), + ("mov x2, #0", 0xD2800002), + ("mov x8, #149", 0xD28012A8), # __NR_setresgid + ("svc #0", 0xD4000001), + ("mov x0, #0", 0xD2800000), + ("mov x1, #0", 0xD2800001), + ("mov x2, #0", 0xD2800002), + ("mov x8, #147", 0xD2801268), # __NR_setresuid + ("svc #0", 0xD4000001), + ("ldr x10, [x9]", 0xF940012A), # argc + ("add x1, x9, #8", 0x91002121), # argv + ("add x11, x10, #1", 0x9100054B), + ("lsl x11, x11, #3", 0xD37DF16B), + ("add x2, x1, x11", 0x8B0B0022), # envp + ("adr x0, path", 0x100000C0), # pc + 24 -> SHELL_PATH + ("mov x8, #221", 0xD2801BA8), # __NR_execve + ("svc #0", 0xD4000001), + ("mov x0, #1", 0xD2800020), + ("mov x8, #93", 0xD2800BA8), # __NR_exit + ("svc #0", 0xD4000001), +) + +# ELF layout: one PT_LOAD covering header + program header + text, mapped R+X. +# There is no data, bss, or section table -- the loader needs none of it. +_EHDR_SIZE = 64 +_PHDR_SIZE = 56 +_LOAD_ADDR = 0x400000 +_EM_AARCH64 = 183 + +# ``adr x0, path`` is encoded with a fixed +24 displacement, which is only +# correct while exactly 6 instructions follow it. Assert rather than silently +# emit an ``su`` that execve()s whatever bytes happen to sit at pc+24. +_ADR_INSN_INDEX = 16 +_ADR_DISPLACEMENT = 24 +assert (len(_INSNS) - _ADR_INSN_INDEX) * 4 == _ADR_DISPLACEMENT, \ + "adr displacement no longer points at SHELL_PATH" + + +def build_su() -> bytes: + """Return the complete statically-linked aarch64 Linux ``su`` executable. + + Deterministic: the same bytes every call, on every host, so the injected + binary can be hashed and compared (``su_sha256``) to tell an image this + tool rooted apart from one somebody else modified. + """ + text = b"".join(struct.pack(" str: + """SHA-256 of :func:`build_su`'s output -- the injected-file fingerprint.""" + import hashlib + return hashlib.sha256(build_su()).hexdigest() diff --git a/main.py b/main.py index be5dbdd..9d95f64 100644 --- a/main.py +++ b/main.py @@ -6,17 +6,22 @@ import logging import tempfile -import pywintypes -import win32api -import win32event -import winerror from PyQt5.QtWidgets import QApplication, QMessageBox import admin import constants +import platform_support from views import theme from views.main_window import MainWindow +if platform_support.IS_WINDOWS: + import pywintypes + import win32api + import win32event + import winerror +else: + import fcntl + # Log to console AND a file in the local temp dir. The file is important when # the app is relaunched elevated: that process has its own (often invisible) # console, so a startup crash would otherwise vanish. The temp dir is always on @@ -45,6 +50,51 @@ # version. SINGLE_INSTANCE_MUTEX_NAME = r"Global\RobThePCGuy.BlueStacksRootGUI.SingleInstance" +# macOS equivalent: an flock() on a file in the shared temp dir. Same intent as +# the `Global\` mutex -- one copy per *machine*, not per user -- so two accounts +# via Fast User Switching can't both be rewriting BlueStacks Air's single +# shared system image (which lives in /Applications and is common to all users) +# at the same time. The lock releases automatically when the process exits, +# including on a crash, so no stale-lock cleanup is needed. +SINGLE_INSTANCE_LOCK_PATH = os.path.join( + tempfile.gettempdir(), "RobThePCGuy.BlueStacksRootGUI.lock") + +# Held for the process lifetime; must stay referenced or the lock is released. +_instance_guard = None + + +def _acquire_single_instance() -> bool: + """Claim the single-instance lock. False means another copy has it.""" + global _instance_guard + + if not platform_support.IS_WINDOWS: + try: + handle = open(SINGLE_INSTANCE_LOCK_PATH, "w") + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + logger.warning("Another copy holds %s", SINGLE_INSTANCE_LOCK_PATH) + return False + _instance_guard = handle + return True + + try: + _instance_guard = win32event.CreateMutex(None, False, SINGLE_INSTANCE_MUTEX_NAME) + return win32api.GetLastError() != winerror.ERROR_ALREADY_EXISTS + except pywintypes.error as exc: + # The mutex's default DACL (CreateMutex(None, ...)) grants access + # to its creator, BUILTIN\Administrators, and SYSTEM only -- a + # second, non-admin Windows user hitting a `Global\` mutex an + # admin already created gets ACCESS_DENIED here, not the + # already-exists success case above. To that user it's the same + # situation ("someone else already has this open"), so it gets + # the same friendly message instead of the generic startup-crash + # handler. Any other CreateMutex failure is genuinely unexpected + # and still surfaces as a real crash. + if exc.winerror != winerror.ERROR_ACCESS_DENIED: + raise + return False + + if __name__ == "__main__": # Patching Program Files binaries and killing BlueStacks processes need # admin rights. If not elevated, request elevation via UAC and relaunch; @@ -60,36 +110,17 @@ # (python.exe when run from source), so the taskbar shows the wrong # icon and a pinned shortcut opens a second, separate button. Must # happen before the first window is created to take effect. - try: - import ctypes - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( - constants.APP_ID) - except Exception: # noqa: BLE001 - cosmetic only, never block startup - logger.debug("could not set AppUserModelID", exc_info=True) + if platform_support.IS_WINDOWS: + try: + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + constants.APP_ID) + except Exception: # noqa: BLE001 - cosmetic only, never block startup + logger.debug("could not set AppUserModelID", exc_info=True) app = QApplication(sys.argv) - # Held for the lifetime of the process (module-level `if __name__` - # scope, not a function) -- closing the handle would release the - # mutex early and defeat the guard. - try: - _instance_mutex = win32event.CreateMutex(None, False, SINGLE_INSTANCE_MUTEX_NAME) - already_running = win32api.GetLastError() == winerror.ERROR_ALREADY_EXISTS - except pywintypes.error as exc: - # The mutex's default DACL (CreateMutex(None, ...)) grants access - # to its creator, BUILTIN\Administrators, and SYSTEM only -- a - # second, non-admin Windows user hitting a `Global\` mutex an - # admin already created gets ACCESS_DENIED here, not the - # already-exists success case above. To that user it's the same - # situation ("someone else already has this open"), so it gets - # the same friendly message instead of the generic startup-crash - # handler. Any other CreateMutex failure is genuinely unexpected - # and still surfaces as a real crash. - if exc.winerror != winerror.ERROR_ACCESS_DENIED: - raise - already_running = True - - if already_running: + if not _acquire_single_instance(): logger.warning("%s is already running; exiting.", constants.APP_NAME) QMessageBox.warning( None, constants.APP_NAME, diff --git a/platform_support.py b/platform_support.py new file mode 100644 index 0000000..8c3a02d --- /dev/null +++ b/platform_support.py @@ -0,0 +1,121 @@ +"""Platform detection and the one privileged operation macOS needs. + +This project began as Windows-only, and everything that touches the machine -- +the registry probe, UAC elevation, ``FILE_ATTRIBUTE_READONLY`` locks, the +bundled e2fsprogs ``.exe``s -- assumed it. BlueStacks Air (macOS/Apple Silicon) +is a different enough product that the honest port is a second backend rather +than a sprinkling of ``if sys.platform`` checks, so this module holds only the +two things every backend genuinely shares: which platform we are on, and how to +run a command with administrator rights. + +Elevation differs in kind, not just in API +------------------------------------------ +On Windows the app relaunches *itself* elevated through UAC (``admin.py``), +because nearly everything it does -- patching binaries under Program Files, +killing services -- needs admin for the whole session. On macOS almost nothing +does: the config file, the instance disks and the logs all live under +``/Users/Shared`` and are writable by the logged-in user, and even +``Root.qcow2`` inside the app bundle ships mode ``rw-rw-rw-``. + +What actually guards that bundle is not permissions but *App Management*: since +Ventura, modifying another application's bundle is refused with ``EPERM`` +regardless of POSIX mode, until the user grants the privilege. The crucial +detail is that App Management is granted to an **application**, not to a user -- +so a root shell spawned through ``osascript`` does **not** inherit the grant its +parent holds, and escalating can fail precisely where a direct write succeeds. +That was observed on a real install: a plain write permitted while ``cp`` under +``with administrator privileges`` was refused on the same file seconds later. + +The consequence for callers is that elevation is a *fallback*, not the first +move (see ``macos_root._install_image``), and the normal macOS flow shows no +password prompt at all. This helper remains for installs whose image genuinely +is not user-writable. Running the whole GUI as root would be worse practice +and, for a Qt app, actively awkward -- it would write root-owned files into the +user's config directory, and it would not help with App Management anyway. +""" +from __future__ import annotations + +import logging +import os +import subprocess +import sys +import tempfile + +logger = logging.getLogger(__name__) + +IS_WINDOWS = sys.platform.startswith("win") +IS_MACOS = sys.platform == "darwin" + + +class ElevationError(RuntimeError): + """A privileged command failed, or the user dismissed the auth prompt.""" + + +# AppleScript's own error number for "user cancelled" -- surfaced by osascript +# as ``execution error: User canceled. (-128)``. +_USER_CANCELLED = "-128" + + +def run_elevated(script: str, *, label: str = "operation", timeout: int = 1800) -> str: + """Run ``script`` (``/bin/sh`` source) as root, returning its stdout. + + macOS shows one authorization dialog per call, so callers should batch a + whole operation into a single script rather than elevating step by step -- + a revert that prompted twice would be a bug, not a detail. + + The script is written to a private temp file with mode 0600 and executed by + path. Passing it inline would mean embedding it in an AppleScript string + literal, where a quote or backslash in a BlueStacks path could change what + the elevated shell runs; a file sidesteps the nested quoting entirely. + + Raises :class:`ElevationError` if the user cancels the prompt or the script + exits non-zero. + """ + if not IS_MACOS: + raise ElevationError("run_elevated() is macOS-only") + + # mkdtemp (not just mkstemp) so the *directory* is ours and 0700: a + # world-writable /tmp entry that root is about to execute would otherwise be + # a swap-the-file-under-us race. + tmpdir = tempfile.mkdtemp(prefix="bsrootgui-") + path = os.path.join(tmpdir, "elevated.sh") + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\nset -e\n") + fh.write(script) + os.chmod(path, 0o700) + + logger.info("Requesting administrator rights for: %s", label) + applescript = 'do shell script "/bin/sh %s" with administrator privileges' % path + try: + proc = subprocess.run( + ["/usr/bin/osascript", "-e", applescript], + capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise ElevationError( + "%s timed out waiting for the administrator prompt." % label) from exc + + if proc.returncode != 0: + err = (proc.stderr or "").strip() + if _USER_CANCELLED in err or "User canceled" in err: + raise ElevationError( + "Administrator access is required to %s, and the prompt was " + "dismissed." % label) + raise ElevationError("%s failed: %s" % (label, err or "unknown error")) + + logger.info("Elevated %s completed.", label) + return proc.stdout + finally: + # Never leave a root-executable script lying around, even on failure. + try: + if os.path.isfile(path): + os.unlink(path) + os.rmdir(tmpdir) + except OSError: + logger.debug("could not clean up %s", tmpdir, exc_info=True) + + +def shell_quote(path: str) -> str: + """Quote a path for safe inclusion in the ``/bin/sh`` source above.""" + return "'" + str(path).replace("'", "'\\''") + "'" diff --git a/registry_handler.py b/registry_handler.py index eb0681d..beff437 100644 --- a/registry_handler.py +++ b/registry_handler.py @@ -1,17 +1,43 @@ from __future__ import annotations -import winreg import logging import os +import sys from typing import Any import constants +import platform_support logger = logging.getLogger(__name__) +# winreg only exists on Windows. Importing it unconditionally made this module +# -- and therefore MainWindow, which imports it at module scope -- unimportable +# on macOS. Detection dispatches by platform below, so the import can be +# conditional without any caller needing to care. +if platform_support.IS_WINDOWS: + import winreg + Installation = dict[str, Any] + def get_all_bluestacks_installations() -> list[Installation]: + """Every detected BlueStacks install, whichever platform we are on. + + This stays the single detection entry point for the whole app: on macOS it + delegates to ``macos_locator``, which returns the same dict shape from the + filesystem instead of the registry. + """ + if platform_support.IS_MACOS: + import macos_locator + return macos_locator.get_all_bluestacks_installations() + if not platform_support.IS_WINDOWS: + logger.warning("Unsupported platform %s; no BlueStacks detection available.", + sys.platform) + return [] + return _get_windows_installations() + + +def _get_windows_installations() -> list[Installation]: installations: list[Installation] = [] reg_sources = { constants.APP_SOURCE_NXT: constants.REGISTRY_BASE_PATH, diff --git a/requirements.txt b/requirements.txt index 657fde7..0354abb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ PyQt5 >= 5.15.11 -pywin32 >= 312 -psutil >= 7.2.2 \ No newline at end of file +# Windows-only: the single-instance mutex in main.py and nothing else. Marked +# so `pip install -r requirements.txt` works on macOS, where BlueStacks Air is +# supported (see macos_root) and pywin32 has no wheel. +pywin32 >= 312 ; sys_platform == "win32" +psutil >= 7.2.2 diff --git a/root_persistence.py b/root_persistence.py index cebcb22..d69c5ef 100644 --- a/root_persistence.py +++ b/root_persistence.py @@ -19,18 +19,41 @@ The lock is just the standard Windows FILE_ATTRIBUTE_READONLY bit, so it is trivially reversible (unlock, or untick the box) and leaves no residue. + +macOS: why there is no lock here +-------------------------------- +The read-only trick does not port, for two independent reasons. + +1. It would not work. The Windows lock holds because ``MoveFileEx`` refuses to + replace a read-only file. BlueStacks Air saves its config the POSIX way -- + write a temp file, then ``rename()`` over the original -- and ``rename()`` + only needs the *directory* to be writable. Air was observed reverting + ``bst.feature.rooting`` to ``"0"`` on launch with the conf at mode 444, so a + chmod-based lock buys nothing. (``chflags uchg`` would block the rename, but + see below.) +2. It would not matter. Air ships no guest ``su`` for those keys to unlock, so + nothing about rooting depends on their value -- ``macos_root`` injects ``su`` + into the system image instead, and root survives regardless of what the + player writes back into ``bluestacks.conf``. + +So on macOS every function here is inert: locking reports "not locked" and the +``unlocked()`` context manager passes straight through, which is exactly right +for a config file nothing needs pinned. """ from __future__ import annotations import contextlib -import ctypes import logging import os import struct -from ctypes import wintypes from typing import Iterator import integrity_patch +import platform_support + +if platform_support.IS_WINDOWS: + import ctypes + from ctypes import wintypes logger = logging.getLogger(__name__) @@ -38,11 +61,12 @@ FILE_ATTRIBUTE_READONLY = 0x01 INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF -_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) -_kernel32.GetFileAttributesW.argtypes = [wintypes.LPCWSTR] -_kernel32.GetFileAttributesW.restype = wintypes.DWORD -_kernel32.SetFileAttributesW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD] -_kernel32.SetFileAttributesW.restype = wintypes.BOOL +if platform_support.IS_WINDOWS: + _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + _kernel32.GetFileAttributesW.argtypes = [wintypes.LPCWSTR] + _kernel32.GetFileAttributesW.restype = wintypes.DWORD + _kernel32.SetFileAttributesW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD] + _kernel32.SetFileAttributesW.restype = wintypes.BOOL def _get_attributes(path: str) -> int: @@ -59,6 +83,8 @@ def _set_attributes(path: str, attrs: int) -> None: def is_locked(config_path: str) -> bool: """Return True if ``config_path`` currently has the read-only attribute.""" + if not platform_support.IS_WINDOWS: + return False if not os.path.isfile(config_path): return False return bool(_get_attributes(config_path) & FILE_ATTRIBUTE_READONLY) @@ -69,6 +95,8 @@ def lock(config_path: str) -> bool: Returns True if the attribute was changed, False if it was already locked. """ + if not platform_support.IS_WINDOWS: + return False if not os.path.isfile(config_path): raise FileNotFoundError(config_path) attrs = _get_attributes(config_path) @@ -85,6 +113,8 @@ def unlock(config_path: str) -> bool: Returns True if the attribute was changed, False if it was already writable. """ + if not platform_support.IS_WINDOWS: + return False if not os.path.isfile(config_path): raise FileNotFoundError(config_path) attrs = _get_attributes(config_path) diff --git a/tests/test_instance_launch.py b/tests/test_instance_launch.py index f50ae46..55fe257 100644 --- a/tests/test_instance_launch.py +++ b/tests/test_instance_launch.py @@ -1,9 +1,29 @@ +"""Launch/restart coverage for both players. + +``launch_instance`` branches on the platform -- ``HD-Player.exe --instance`` on +Windows, the extension-less ``BlueStacks`` binary inside the .app on macOS -- +so each test pins the branch it means to exercise instead of inheriting the +host's. That way the Windows path is still covered when the suite runs on a Mac +(and vice versa), which is the whole point of having both. +""" import pytest import instance_handler as ih -def test_launch_instance_starts_hd_player_with_instance(tmp_path, monkeypatch): +@pytest.fixture +def windows(monkeypatch): + monkeypatch.setattr(ih.platform_support, "IS_MACOS", False) + monkeypatch.setattr(ih.platform_support, "IS_WINDOWS", True) + + +@pytest.fixture +def macos(monkeypatch): + monkeypatch.setattr(ih.platform_support, "IS_MACOS", True) + monkeypatch.setattr(ih.platform_support, "IS_WINDOWS", False) + + +def test_launch_instance_starts_hd_player_with_instance(tmp_path, monkeypatch, windows): exe = tmp_path / "HD-Player.exe" exe.write_bytes(b"x") calls = [] @@ -14,12 +34,12 @@ def test_launch_instance_starts_hd_player_with_instance(tmp_path, monkeypatch): assert calls and calls[0][:3] == [str(exe), "--instance", "Tiramisu64"] -def test_launch_instance_missing_player_raises(tmp_path): +def test_launch_instance_missing_player_raises(tmp_path, windows): with pytest.raises(RuntimeError, match="HD-Player"): ih.launch_instance(str(tmp_path), "Whatever") -def test_restart_instance_kills_then_relaunches(tmp_path, monkeypatch): +def test_restart_instance_kills_then_relaunches(tmp_path, monkeypatch, windows): exe = tmp_path / "HD-Player.exe" exe.write_bytes(b"x") order = [] @@ -30,3 +50,39 @@ def test_restart_instance_kills_then_relaunches(tmp_path, monkeypatch): ih.restart_instance(str(tmp_path), "Tiramisu64", wait_ms=10) assert order == ["kill", "wait", "launch"] # kill, settle, then relaunch + + +# --- BlueStacks Air ------------------------------------------------------ + +def test_launch_instance_macos_runs_bundle_binary(tmp_path, monkeypatch, macos): + exe = tmp_path / "BlueStacks" + exe.write_bytes(b"x") + calls = [] + monkeypatch.setattr(ih.subprocess, "Popen", + lambda args, **k: calls.append((args, k))) + + ih.launch_instance(str(tmp_path), "Tiramisu64") + + args, kwargs = calls[0] + assert args == [str(exe), "--instance", "Tiramisu64"] + # Detached, so quitting this app never takes the emulator down with it. + assert kwargs.get("start_new_session") is True + + +def test_launch_instance_macos_missing_binary_raises(tmp_path, macos): + # Names the macOS binary, not HD-Player.exe, so the error is actionable. + with pytest.raises(RuntimeError, match="BlueStacks not found"): + ih.launch_instance(str(tmp_path), "Whatever") + + +def test_macos_process_names_are_used_for_termination(macos): + names = ih.bluestacks_process_names() + assert "BlueStacks" in names + # Air's player has no .exe processes at all; picking the Windows list would + # silently terminate nothing and let a write race against a running VM. + assert not any(n.endswith(".exe") for n in names) + + +def test_windows_process_names_are_used_for_termination(windows): + names = ih.bluestacks_process_names() + assert "HD-Player.exe" in names diff --git a/tests/test_instances_page.py b/tests/test_instances_page.py index 82989b2..34a01fd 100644 --- a/tests/test_instances_page.py +++ b/tests/test_instances_page.py @@ -268,3 +268,73 @@ def test_root_buttons_are_short_enough_not_to_truncate(qtbot): "rezygisk_button", "lsposed_button"): label = getattr(page, name).text() assert len(label) <= 20, (name, label) + + +# --- BlueStacks Air ------------------------------------------------------ +# Air has no R/W state and no Magisk path, so those columns and buttons are +# dropped rather than shown empty -- "Off" and "-" read as "a thing you have +# switched off", which would be a lie about what the app can do here. + +def _air_page(qtbot, rooted=False): + page = InstancesPage() + qtbot.addWidget(page) + page.show() + page.set_air_mode(True) + page.set_instances({"Tiramisu64 (AIR)": { + "root_enabled": rooted, + "rw_mode": constants.MODE_NOT_APPLICABLE, + "air_mode": True, + "display_name": "BlueStacks Air", + "original_name": "Tiramisu64", + }}, preserve_selection=False) + return page + + +def _headers(page): + return [page.instance_layout.itemAt(i).widget().text() + for i in range(page.instance_layout.count()) + if isinstance(page.instance_layout.itemAt(i).widget(), QLabel) + and page.instance_layout.itemAt(i).widget().objectName() == "InstanceHeader"] + + +def test_air_grid_drops_rw_and_manager_columns(qtbot): + assert _headers(_air_page(qtbot)) == ["Instance", "Root"] + + +def test_windows_grid_keeps_all_columns(qtbot): + page = InstancesPage() + qtbot.addWidget(page) + page.show() + page.set_instances({"Nougat64 (NXT)": { + "root_enabled": False, "rw_mode": constants.MODE_READONLY, + "display_name": "x", "original_name": "Nougat64", + }}, preserve_selection=False) + assert _headers(page) == ["Instance", "Root", "R/W", "Manager app"] + + +def test_air_hides_rw_and_magisk_buttons(qtbot): + 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.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 + + +def test_air_root_button_says_it_covers_every_instance(qtbot): + page = _air_page(qtbot) + page.checkboxes["Tiramisu64 (AIR)"].setChecked(True) + assert "all instances" in page.root_toggle_button.text() + page_on = _air_page(qtbot, rooted=True) + page_on.checkboxes["Tiramisu64 (AIR)"].setChecked(True) + assert page_on.root_toggle_button.text().startswith("Remove") + + +def test_air_hint_does_not_advertise_manager_root(qtbot): + """Manager Root has no button on Air; naming it would send users hunting.""" + page = _air_page(qtbot) + page.checkboxes["Tiramisu64 (AIR)"].setChecked(True) + assert "Manager Root" not in page.hint_label.text() + assert "su" in page.hint_label.text() diff --git a/tests/test_macos_hosts.py b/tests/test_macos_hosts.py new file mode 100644 index 0000000..57eb659 --- /dev/null +++ b/tests/test_macos_hosts.py @@ -0,0 +1,176 @@ +"""In-guest hosts blocking on Air, and how it shares an image with rooting. + +Both features edit the same ``Root.qcow2``, so the interesting cases are not +the individual edits (those mirror ``telemetry_block``, which has its own +tests) but the interaction: which one takes the pristine backup, and which one +is allowed to restore it. Getting that wrong either strands a modified image +or silently reverts the *other* feature. +""" +from __future__ import annotations + +import os + +import pytest + +import macos_hosts +import macos_root +import platform_support +import telemetry_block + + +@pytest.fixture +def install(tmp_path, monkeypatch): + """An Air install with a stub image, and the image edit stubbed out. + + ``open_image`` is replaced by a fake session backed by a dict, so these + tests exercise the decision logic without a 1.7 GB qcow2 round-trip. + """ + app = tmp_path / "BlueStacks.app" + (app / "Contents" / "img").mkdir(parents=True) + image = app / "Contents" / "img" / "Root.qcow2" + image.write_bytes(b"pristine-image") + data = tmp_path / "data" + data.mkdir() + + files = {macos_hosts.HOSTS_IMAGE_PATH: "127.0.0.1 localhost\n"} + + class FakeSession: + workdir = str(tmp_path) + + def read_file(self, path): + return files.get(path) + + def write_file(self, path, content, mode=0o100644): + files[path] = content + + def run(self, commands): + for line in commands.splitlines(): + if line.startswith("rm "): + files.pop(line[3:].strip(), None) + return "" + + import contextlib + + @contextlib.contextmanager + def fake_open_image(app_path, progress=None, *, results=None): + yield FakeSession() + # Any edit rewrites the image, so its bytes change -- which is what + # invalidates the fingerprints the state files are checked against. + image.write_bytes(image.read_bytes() + b"+edit") + + monkeypatch.setattr(macos_root, "open_image", fake_open_image) + monkeypatch.setattr(platform_support, "IS_MACOS", True) + return str(app), str(data), image, files + + +def test_apply_adds_the_block_and_keeps_existing_entries(install): + app, data, _, files = install + + macos_hosts.apply(app, data_dir=data) + + hosts = files[macos_hosts.HOSTS_IMAGE_PATH] + assert "127.0.0.1 localhost" in hosts, "must not clobber the real file" + assert "0.0.0.0 doubleclick.net" in hosts + assert macos_hosts.status(app, data)["hosts"] == len(macos_hosts.blocked_hosts()) + + +def test_reapplying_does_not_stack_duplicate_blocks(install): + app, data, _, files = install + + macos_hosts.apply(app, data_dir=data) + macos_hosts.apply(app, data_dir=data) + + hosts = files[macos_hosts.HOSTS_IMAGE_PATH] + assert hosts.count("0.0.0.0 doubleclick.net") == 1 + assert telemetry_block.has_block(hosts) + + +def test_apply_creates_a_hosts_file_when_the_guest_has_none(install): + app, data, _, files = install + del files[macos_hosts.HOSTS_IMAGE_PATH] + + macos_hosts.apply(app, data_dir=data) + + hosts = files[macos_hosts.HOSTS_IMAGE_PATH] + assert "localhost" in hosts, "a hosts file with only the block is malformed" + assert "0.0.0.0 doubleclick.net" in hosts + + +def test_first_edit_takes_the_pristine_backup(install): + app, data, image, _ = install + original = image.read_bytes() + + macos_hosts.apply(app, data_dir=data) + + backup = macos_root.backup_path(data) + assert os.path.isfile(backup) + assert open(backup, "rb").read() == original + + +def test_removing_the_only_edit_restores_the_untouched_image(install): + """Byte-for-byte, which is what repairs the bundle's signature seal.""" + app, data, image, _ = install + original = image.read_bytes() + + macos_hosts.apply(app, data_dir=data) + assert image.read_bytes() != original # the edit really changed it + macos_hosts.remove(app, data_dir=data) + + assert image.read_bytes() == original + assert not os.path.isfile(macos_root.backup_path(data)) + assert macos_hosts.status(app, data) is None + + +def test_blocking_trackers_does_not_forget_that_root_is_applied(install): + """Regression: the block edit must not invalidate root's recorded state. + + Both are recorded against a fingerprint of the same image, so applying one + rewrites the image the other was stamped against. Caught live -- blocking + trackers on a rooted install reported it as un-rooted. + """ + app, data, image, _ = install + macos_root.write_modstate(app, data, {"root": True}) + + macos_hosts.apply(app, data_dir=data) + + assert macos_root.image_root_state(app, data) is True + assert macos_hosts.status(app, data) is not None + + +def test_removing_the_block_while_rooted_does_not_restore_over_root(install): + """Restoring the pristine image here would silently un-root the user.""" + app, data, image, files = install + macos_root.write_modstate(app, data, {"root": True}) + macos_hosts.apply(app, data_dir=data) + backup_before = open(macos_root.backup_path(data), "rb").read() + + macos_hosts.remove(app, data_dir=data) + + assert telemetry_block.has_block(files[macos_hosts.HOSTS_IMAGE_PATH]) is False + # Root survives, and so does its backup -- otherwise there would be nothing + # left to un-root to. + assert macos_root.image_root_state(app, data) is True + assert os.path.isfile(macos_root.backup_path(data)) + assert open(macos_root.backup_path(data), "rb").read() == backup_before + + +def test_a_replaced_image_clears_the_recorded_block(install): + """A BlueStacks update, or an un-root, drops the block with the image.""" + app, data, image, _ = install + macos_hosts.apply(app, data_dir=data) + assert macos_hosts.status(app, data) is not None + + image.write_bytes(b"a-new-build-from-an-update") + + assert macos_hosts.status(app, data) is None + + +def test_backup_is_not_overwritten_by_a_second_edit(install): + """The backup must stay the *pristine* image, not the once-edited one.""" + app, data, image, _ = install + original = image.read_bytes() + macos_hosts.apply(app, data_dir=data) + + macos_root.ensure_backup(str(image), data, image_is_pristine=False) + + assert open(macos_root.backup_path(data), "rb").read() == original diff --git a/tests/test_macos_locator.py b/tests/test_macos_locator.py new file mode 100644 index 0000000..0074b6e --- /dev/null +++ b/tests/test_macos_locator.py @@ -0,0 +1,179 @@ +"""Detection of a BlueStacks Air install, against a faked bundle on disk. + +Everything here is filesystem shape, so it runs on any host: the tests build a +miniature BlueStacks.app + shared data directory in tmp_path and point the +module's two hard-coded locations at it. +""" +from __future__ import annotations + +import os +import plistlib +import struct + +import pytest + +import constants +import macos_locator + +CPU_ARM64 = 0x0100000C +CPU_X86_64 = 0x01000007 + + +def _write_macho(path, cputype): + """A minimal thin 64-bit Mach-O header with the given cputype.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xcf\xfa\xed\xfe" + struct.pack("I", len(cputypes)) + for cpu in cputypes: + blob += struct.pack(">I", cpu) + b"\0" * 16 + path.write_bytes(blob) + + +@pytest.fixture +def air(tmp_path, monkeypatch): + """A minimal but complete-looking Air installation.""" + app = tmp_path / "BlueStacks.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + # Detection refuses anything that isn't the Apple Silicon build, so the + # fixture has to carry a real (if stub) arm64 player binary. + _write_macho(app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME, CPU_ARM64) + (app / "Contents" / "img").mkdir(parents=True) + (app / "Contents" / "img" / "Root.qcow2").write_bytes(b"qcow") + with open(app / "Contents" / "Info.plist", "wb") as fh: + plistlib.dump({"CFBundleShortVersionString": "5.21.782"}, fh) + + data = tmp_path / "data" + (data / "Engine" / "Tiramisu64").mkdir(parents=True) + (data / "Engine" / "UserData").mkdir(parents=True) + (data / "bluestacks.conf").write_text( + 'bst.instance.Tiramisu64.enable_root_access="0"\n', encoding="utf-8") + + monkeypatch.setattr(macos_locator, "DEFAULT_APP_PATH", str(app)) + monkeypatch.setattr(macos_locator, "DATA_DIR", str(data)) + monkeypatch.setattr(macos_locator, "SETTING_PLIST", str(data / "setting.plist")) + return app, data + + +def test_finds_install_and_reports_registry_handler_shape(air): + app, data = air + (inst,) = macos_locator.get_all_bluestacks_installations() + + assert inst["source"] == constants.APP_SOURCE_AIR + assert inst["version"] == (5, 21, 782) + assert inst["config_path"] == str(data / "bluestacks.conf") + assert inst["app_path"] == str(app) + # Instances live under Engine/, not the data dir itself. + assert inst["data_path"] == str(data / "Engine") + # Air has no 5.22 integrity check and no .exe to patch, so the Dashboard's + # engine-patch button must stay hidden. + assert inst["patch_mode"] is False + assert inst["air_mode"] is True + + +def test_reports_nothing_when_not_installed(tmp_path, monkeypatch): + monkeypatch.setattr(macos_locator, "DEFAULT_APP_PATH", str(tmp_path / "nope.app")) + monkeypatch.setattr(macos_locator, "SETTING_PLIST", str(tmp_path / "nope.plist")) + assert macos_locator.get_all_bluestacks_installations() == [] + + +def test_bundle_without_a_conf_is_not_reported(air): + """Installed but never launched: no conf, no instances, nothing to show.""" + _, data = air + (data / "bluestacks.conf").unlink() + assert macos_locator.get_all_bluestacks_installations() == [] + + +def test_userdata_is_not_listed_as_an_instance(air): + _, data = air + assert macos_locator.list_instance_dirs(str(data)) == ["Tiramisu64"] + + +def test_setting_plist_overrides_the_default_location(air, tmp_path, monkeypatch): + """A relocated bundle is found through setting.plist.""" + app, data = air + moved = tmp_path / "Elsewhere" / "BlueStacks.app" + moved.parent.mkdir() + app.rename(moved) + with open(data / "setting.plist", "wb") as fh: + plistlib.dump({macos_locator.SETTING_PATH_KEY: + str(moved / "Contents" / "MacOS")}, fh) + + (inst,) = macos_locator.get_all_bluestacks_installations() + assert inst["app_path"] == str(moved) + + +def test_bundled_tool_finds_an_executable(air): + app, _ = air + adb = app / "Contents" / "MacOS" / "hd-adb" + adb.write_bytes(b"#!/bin/sh\n") + adb.chmod(0o755) + assert macos_locator.bundled_tool(str(app), "hd-adb") == str(adb) + + +@pytest.mark.skipif( + os.name == "nt", + reason="Windows os.access(X_OK) is equivalent to R_OK, so every existing " + "file reads as executable and this distinction cannot be made") +def test_bundled_tool_rejects_a_non_executable(air): + """Present but not executable is a broken install, not a usable adb.""" + app, _ = air + adb = app / "Contents" / "MacOS" / "hd-adb" + adb.write_bytes(b"#!/bin/sh\n") + adb.chmod(0o644) + assert macos_locator.bundled_tool(str(app), "hd-adb") is None + + +def test_root_image_path_points_into_the_bundle(air): + app, _ = air + assert macos_locator.root_image_path(str(app)) == \ + str(app / "Contents" / "img" / "Root.qcow2") + + +# --- Apple Silicon only -------------------------------------------------- +# The Intel macOS BlueStacks is a different product inside (VirtualBox, VHDX +# disks, x86 guest). Detecting it as Air would not merely fail -- macos_root +# would write an *aarch64* su into an x86 guest, leaving a modified system +# image and an su that cannot exec. So detection fails closed. + +def test_reports_arm64_player(air): + app, _ = air + _write_macho(app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME, CPU_ARM64) + assert macos_locator.player_architectures(str(app)) == {"arm64"} + + +def test_reports_x86_player(air): + app, _ = air + _write_macho(app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME, CPU_X86_64) + assert macos_locator.player_architectures(str(app)) == {"x86_64"} + + +def test_reads_universal_binary(air): + app, _ = air + _write_fat(app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME, [CPU_X86_64, CPU_ARM64]) + assert macos_locator.player_architectures(str(app)) == {"x86_64", "arm64"} + + +def test_intel_install_is_not_reported_as_air(air): + app, _ = air + _write_macho(app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME, CPU_X86_64) + assert macos_locator.get_all_bluestacks_installations() == [] + + +def test_universal_build_with_arm64_is_supported(air): + """A future universal build still runs the arm64 guest; accept it.""" + app, _ = air + _write_fat(app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME, [CPU_X86_64, CPU_ARM64]) + assert len(macos_locator.get_all_bluestacks_installations()) == 1 + + +def test_unreadable_player_fails_closed(air): + """Never edit a system image belonging to a build we could not identify.""" + app, _ = air + (app / "Contents" / "MacOS" / macos_locator.PLAYER_NAME).unlink() + assert macos_locator.player_architectures(str(app)) == set() + assert macos_locator.get_all_bluestacks_installations() == [] diff --git a/tests/test_macos_root.py b/tests/test_macos_root.py new file mode 100644 index 0000000..87bc649 --- /dev/null +++ b/tests/test_macos_root.py @@ -0,0 +1,256 @@ +"""Coverage for the Air rooting engine's decisions, without touching a real image. + +The 1.7 GB convert/edit/repack round-trip is validated live against a real +BlueStacks Air install; what is worth locking in here is the logic that decides +*what* to do and *when to refuse*: where the ext4 actually starts, whether the +recorded root state is still trustworthy, and that the two failure modes a user +will actually hit (e2fsprogs missing, macOS App Management blocking the write) +produce an actionable message rather than a raw errno. +""" +from __future__ import annotations + +import json +import os +import struct + +import pytest + +import macos_root +import platform_support + + +# --- partition geometry -------------------------------------------------- + +def _mbr_image(tmp_path, *, part_type=0x83, start_lba=2048, signature=True): + mbr = bytearray(512) + if signature: + mbr[510:512] = b"\x55\xaa" + entry = bytearray(16) + entry[4] = part_type + entry[8:12] = struct.pack(" bytes: + """The code+data portion, i.e. everything after the two headers.""" + return macos_su.build_su()[64 + 56:] + + +def _words() -> list[int]: + text = _text()[:4 * len(macos_su._INSNS)] + return [struct.unpack_from("> 29) & 0b11 + immhi = (word >> 5) & 0x7FFFF + disp = (immhi << 2) | immlo + assert disp == macos_su._ADR_DISPLACEMENT + + # Follow it and confirm the bytes really are the path. + target = (64 + 56) + adr_index * 4 + disp + assert blob[target:target + len(macos_su.SHELL_PATH)] == macos_su.SHELL_PATH + assert macos_su.SHELL_PATH.endswith(b"\0"), "execve needs a NUL-terminated path" + + +def test_privileges_are_dropped_before_exec_not_after(): + """setresgid/setresuid must both precede execve. + + Reversed, the exec would happen first and the shell would keep the caller's + uid -- a root-looking su that is not root. + """ + words = _words() + svc = 0xD4000001 + # Syscall number is the immediate of the `mov x8, #N` before each svc. + calls = [(words[i - 1] >> 5) & 0xFFFF + for i, w in enumerate(words) if w == svc] + assert calls[:3] == [149, 147, 221], calls # setresgid, setresuid, execve + + +def test_output_is_deterministic_and_fingerprinted(): + first, second = macos_su.build_su(), macos_su.build_su() + assert first == second + import hashlib + assert macos_su.su_sha256() == hashlib.sha256(first).hexdigest() + + +def test_binary_is_small_enough_to_audit(): + # Not a golden hash (that would churn on any deliberate change) but a guard + # that this never quietly becomes a vendored multi-megabyte payload. + assert len(macos_su.build_su()) < 1024 diff --git a/tests/test_magisk_system.py b/tests/test_magisk_system.py index 3eb3032..3354c41 100644 --- a/tests/test_magisk_system.py +++ b/tests/test_magisk_system.py @@ -12,6 +12,17 @@ import pytest import magisk_system as ms +import platform_support + +# `_cygpath` maps a Windows path onto the Cygwin debugfs that ships with the +# Windows build (``C:\x`` -> ``/cygdrive/c/x``). ``os.path.splitdrive`` has no +# drive to split off Windows, so only the tests that assert the *converted* +# string are Windows-bound; everything else in this module is platform-neutral +# and runs on macOS too. Magisk itself is Windows-only -- the Air build hides +# it (see ``views.instances_page.set_air_mode``). +windows_paths_only = pytest.mark.skipif( + not platform_support.IS_WINDOWS, + reason="asserts Cygwin conversion of Windows drive paths") def test_write_commands_cd_precedes_bare_quoted_writes(): @@ -107,6 +118,7 @@ def test_clean_dir_commands_recurses_subdir_before_parent_rmdir(monkeypatch): ] +@windows_paths_only def test_databin_extra_commands_writes_scripts_and_stub(): # Kyubi extras: the module-install gate script + the manager stub (no chromeos). extras = { @@ -129,6 +141,7 @@ def test_databin_extra_commands_writes_scripts_and_stub(): assert not any("chromeos" in c for c in cmds) +@windows_paths_only def test_service_d_grant_commands_creates_dir_when_absent(): cmds = ms._service_d_grant_commands(r"C:\w\00-bsrgui-adbgrant.sh", dir_exists=False) sd = ms._SERVICE_D @@ -181,6 +194,7 @@ def test_verify_staged_checks_extras_mode_and_owner(monkeypatch): assert bad == ["stub.apk"] +@windows_paths_only def test_system_write_commands_footprint_and_perms(): srcs = {n: r"C:\a\%s" % n for n in ("config", "magisk32", "magisk64", "magiskinit", "magiskpolicy", diff --git a/tests/test_registry_handler.py b/tests/test_registry_handler.py index c7f789b..d457e4b 100644 --- a/tests/test_registry_handler.py +++ b/tests/test_registry_handler.py @@ -1,21 +1,26 @@ """Coverage for ``registry_handler.get_all_bluestacks_installations`` -- reads three Windows Registry paths (Normal/CN/MSI editions) and had zero tests. -``winreg`` itself is real here (the whole suite already requires Windows, see -conftest.py), so rather than faking module *existence* we monkeypatch +``winreg`` itself is real here rather than a fake module, so we monkeypatch ``winreg.OpenKey``/``winreg.QueryValueEx`` to serve fixture data instead of the real registry, and keep the real ``HKEY_LOCAL_MACHINE``/``KEY_READ``/``REG_SZ`` constants the code compares/passes through. Each test pins down one specific, previously-unverified branch: a whole source missing vs. permission-denied vs. one individual value missing, the "need both UserDefinedDir and DataDir" gating rule, the REG_SZ type guard, and that sources aren't cross-contaminated. + +The rest of the suite runs on macOS too (BlueStacks Air is supported there -- +see ``macos_root``), but there is no registry to emulate, so this module skips +itself off Windows instead of importing a module that does not exist. """ from __future__ import annotations -import winreg +import pytest + +winreg = pytest.importorskip("winreg", reason="registry probe is Windows-only") -import constants -import registry_handler +import constants # noqa: E402 (must follow the skip guard) +import registry_handler # noqa: E402 # conftest.py's repo-root autouse fixture stubs # registry_handler.get_all_bluestacks_installations to `lambda: []` for every diff --git a/tests/test_theme.py b/tests/test_theme.py index 4e3174f..c046600 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -37,3 +37,46 @@ def test_load_saved_theme_defaults_to_light(): def test_apply_theme_persists_choice(qapp): theme.apply_theme(qapp, theme.DARK) assert theme.load_saved_theme() == theme.DARK + + +# --- macOS native-style corrections -------------------------------------- +# QMacStyle paints a QGroupBox's frame as a filled lightGray panel, and draws +# an empty checkbox at #f4f4f4 against the light theme's #f3f3f3 page. Both +# read as bugs on macOS and neither happens on Windows, so the corrections are +# appended only there -- Windows keeps the base themes byte-for-byte. + +@pytest.mark.parametrize("name", [theme.LIGHT, theme.DARK]) +def test_macos_appends_groupbox_and_checkbox_rules(monkeypatch, name): + monkeypatch.setattr(theme, "IS_MACOS", True) + qss = theme.stylesheet_for(name) + assert "QGroupBox {" in qss + assert "background-color: transparent" in qss + assert "QGroupBox::title" in qss + assert "QCheckBox::indicator:unchecked" in qss + + +@pytest.mark.parametrize("name", [theme.LIGHT, theme.DARK]) +def test_windows_stylesheet_is_untouched(monkeypatch, name): + """The base themes are this project's Windows appearance; leave them be.""" + monkeypatch.setattr(theme, "IS_MACOS", False) + assert theme.stylesheet_for(name) == theme._THEMES[name] + + +def test_checkbox_rule_targets_unchecked_only(monkeypatch): + """Styling every state hands the box to the QSS painter and loses the tick. + + Verified by rendering: an all-states rule draws the checked box as a flat + white rectangle with no checkmark, so the rule must stay :unchecked-scoped. + """ + monkeypatch.setattr(theme, "IS_MACOS", True) + qss = theme.stylesheet_for(theme.LIGHT) + assert "QCheckBox::indicator {" not in qss + assert "QCheckBox::indicator:checked" not in qss + + +def test_macos_groupbox_leaves_room_for_its_title(monkeypatch): + """Without a top margin the frame is drawn over the title text.""" + monkeypatch.setattr(theme, "IS_MACOS", True) + qss = theme.stylesheet_for(theme.LIGHT) + assert "margin-top" in qss + assert "subcontrol-origin: margin" in qss diff --git a/views/instances_page.py b/views/instances_page.py index b300753..18ae834 100644 --- a/views/instances_page.py +++ b/views/instances_page.py @@ -182,6 +182,8 @@ def __init__(self, parent=None): self._instance_data: dict[str, dict] = {} self._magisk: dict[str, dict | None] = {} self._busy = False + # Set from the detected installation; see set_air_mode(). + self._air_mode = False self._update() # --- state in -------------------------------------------------------- @@ -227,7 +229,13 @@ def _build_rows(self, selected: set) -> None: self.checkboxes = {} # Column headers, so "Root:" and "R/W:" aren't repeated on every row. - for col, title in ((0, "Instance"), (1, "Root"), (2, "R/W"), (3, "Manager app")): + # R/W and Manager app are Windows-only concepts; on Air the columns are + # dropped entirely rather than filled with "Off"/"-", which would read + # as "a thing you have switched off" instead of "not a thing here". + air = getattr(self, "_air_mode", False) + columns = ((0, "Instance"), (1, "Root")) if air else ( + (0, "Instance"), (1, "Root"), (2, "R/W"), (3, "Manager app")) + for col, title in columns: header = QLabel(title) header.setObjectName("InstanceHeader") self.instance_layout.addWidget(header, 0, col) @@ -264,8 +272,9 @@ def _build_rows(self, selected: set) -> None: self.instance_layout.addWidget(checkbox, row, 0) self.instance_layout.addWidget(root_label, row, 1) - self.instance_layout.addWidget(rw_label, row, 2) - self.instance_layout.addWidget(magisk_label, row, 3) + if not air: + self.instance_layout.addWidget(rw_label, row, 2) + self.instance_layout.addWidget(magisk_label, row, 3) self.checkboxes[unique_id] = checkbox @staticmethod @@ -310,9 +319,19 @@ def selected_status(self) -> dict | None: # --- derived UI ------------------------------------------------------ + # Air has one root method, not a choice between two, so the hints that + # weigh Native Root against Manager Root would be advertising a button + # that is not on screen. + _HINT_AIR_OFF = ("Adds su to the Android system image all instances share. " + "Close BlueStacks first; undo from the same button.") + _HINT_AIR_ON = ("Rooted: su is at /system/xbin/su in every instance. A " + "BlueStacks update replaces the image and removes it.") + def _hint_text(self, uid, app_root, installed, manager) -> str: if uid is None: return self._PICK_ONE + if getattr(self, "_air_mode", False): + return self._HINT_AIR_ON if app_root else self._HINT_AIR_OFF if app_root and installed: return self._HINT_CONFLICT if app_root: @@ -321,6 +340,25 @@ def _hint_text(self, uid, app_root, installed, manager) -> str: return self._HINT_CHOOSE return self._HINT_MODULES + def set_air_mode(self, air: bool) -> None: + """Reduce the page to the actions BlueStacks Air actually supports. + + Air has no R/W state to toggle (no .bstk files, one shared read-only + system image) and no Magisk path (its offline installer drives Windows + VHDs through bundled e2fsprogs ``.exe``s). Those buttons are hidden + rather than disabled: a greyed-out button reads as "not right now", + which would be a lie -- they will never apply here. + """ + self._air_mode = air + # The grid's columns depend on this flag, so rows built before it + # arrived have to be rebuilt. Detection normally sets it before the + # first load, so this is only for a later rescan that finds a + # different install; skip the no-op rebuild when there is nothing + # on screen yet. + if self._instance_data: + self._refresh_rows() + self._update() + def _update(self, *_args) -> None: busy = self._busy any_ticked = bool(self.selected_ids()) @@ -330,6 +368,7 @@ def _update(self, *_args) -> None: app_root = bool(data and data.get("root_enabled")) installed = bool(st) manager = installed and "manager" in (st.get("components") or []) + air = getattr(self, "_air_mode", False) self.hint_label.setText(self._hint_text(uid, app_root, installed, manager)) @@ -341,12 +380,28 @@ def _update(self, *_args) -> None: # The Native Root button is a toggle, so its label says what the click # will do rather than leaving the user to infer it from the grid. - self.root_toggle_button.setText( - "Disable Native Root" if app_root else "Native Root") + if air: + # "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( + "Remove Root (all instances)" if app_root else "Root (all instances)") + self.root_toggle_button.setToolTip( + "Installs su into the Android system image BlueStacks Air " + "shares between all instances. Air ships no su of its own, so " + "this is what root means here.\n\n" + "While it is applied, BlueStacks.app's code signature no longer " + "validates (the image is a sealed resource) -- the app still " + "runs. Undoing from this same button restores the original " + "image byte for byte and repairs that.") + else: + self.root_toggle_button.setText( + "Disable Native Root" if app_root else "Native Root") + + self.rw_toggle_button.setVisible(not air) # 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 + one = uid is not None and not air show_install = one and not installed self.install_button.setVisible(show_install) self.uninstall_button.setVisible(one and installed) diff --git a/views/main_window.py b/views/main_window.py index cf0aed6..ed2c6c5 100644 --- a/views/main_window.py +++ b/views/main_window.py @@ -25,6 +25,12 @@ import adb_handler import magisk_system import admin +# Pure-Python and dependency-free, so these import fine on Windows too; only +# 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_locator +import macos_root from views.nav_rail import ( NavRail, DASHBOARD as NAV_DASHBOARD, INSTANCES as NAV_INSTANCES, @@ -235,6 +241,14 @@ def initialize_paths_and_instances(self) -> None: path_details.append(f" - {inst['source']} v{ver}: {inst['user_path']}") self.dashboard_page.set_paths_text("\n".join(path_details)) + # BlueStacks Air supports a different (smaller) set of actions than + # Windows BlueStacks; tell the pages before the first render so no + # 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 # refreshing first would render "0 / 0" until the next timer tick. @@ -328,12 +342,23 @@ def update_instance_data(self) -> None: source_id, config_path, data_path = inst["source"], inst["config_path"], inst["data_path"] install_path = inst.get("install_path") patch_mode = inst.get("patch_mode", False) + air_mode = inst.get("air_mode", False) root_info = config_handler.get_complete_root_statuses(config_path) instance_root_statuses = root_info['instance_statuses'] display_names = root_info.get('display_names', {}) + # Air roots the one system image every instance shares, so root is + # a property of the installation, not of an instance. Read it once + # 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) + disk_instances = set() - if os.path.isdir(data_path): + if air_mode: + # Engine/ also holds UserData, which is not an instance; the + # locator knows which entries to skip. + disk_instances = set(macos_locator.list_instance_dirs(inst["user_path"])) + elif os.path.isdir(data_path): try: disk_instances = { entry for entry in os.listdir(data_path) @@ -350,7 +375,12 @@ def update_instance_data(self) -> None: instance_dir_path = os.path.join(data_path, name) rw_mode = constants.MODE_UNKNOWN - if os.path.isdir(instance_dir_path): + if air_mode: + # No .bstk files and one shared read-only image: there is + # no R/W state to report, and MODE_UNKNOWN would hide the + # instance entirely (see the filter below). + rw_mode = constants.MODE_NOT_APPLICABLE + elif os.path.isdir(instance_dir_path): is_readonly = instance_handler.is_instance_readonly(instance_dir_path) if is_readonly is True: rw_mode = constants.MODE_READONLY @@ -358,14 +388,21 @@ def update_instance_data(self) -> None: rw_mode = constants.MODE_READWRITE individual_root_on = instance_root_statuses.get(name, False) - if patch_mode: + if air_mode: + # Air's enable_root_access key is inert -- the image ships + # no su for it to unlock -- so the only truthful answer is + # whether su is in the image. + effective_root_status = air_rooted + elif patch_mode: effective_root_status = su_patch_offline.instance_root_state(instance_dir_path) else: effective_root_status = individual_root_on # A Magisk system-mode install roots via /system -- it never sets # the bluestacks.conf root flag or the su-patch marker, so without # this the dashboard reads "0 rooted" for a Magisk-rooted instance. - if not effective_root_status and \ + # Magisk is Windows-only (its offline installer drives VHDs with + # bundled e2fsprogs .exe binaries), so skip the probe on Air. + if not air_mode and not effective_root_status and \ magisk_system.magisk_status(instance_dir_path) is not None: effective_root_status = True @@ -379,6 +416,9 @@ def update_instance_data(self) -> None: "individual_root_status": individual_root_on, "display_name": display_names.get(name, name), "patch_mode": patch_mode, + "air_mode": air_mode, + "app_path": inst.get("app_path"), + "user_path": inst.get("user_path"), } self.instance_data = { @@ -392,11 +432,32 @@ def update_instance_checkboxes(self, preserve_selection: bool = True) -> None: self.instances_page.set_instances(self.instance_data, preserve_selection) def _toggle_single_instance_root(self, unique_id, progress=None): - if self.instance_data[unique_id].get("patch_mode"): + instance = self.instance_data[unique_id] + if instance.get("air_mode"): + self._toggle_root_air(unique_id, progress) + elif instance.get("patch_mode"): self._toggle_root_patchmode(unique_id, progress) else: self._toggle_root_conf(unique_id, progress) + def _toggle_root_air(self, unique_id, progress=None): + """Root BlueStacks Air by adding/removing su in its system image. + + Unlike every other path here this is install-wide: Air has no + per-instance system image, so the toggle affects every instance. The + selected instance only decides which *direction* to go. + """ + instance = self.instance_data[unique_id] + turn_on = not instance["root_enabled"] + app_path = instance.get("app_path") + if not app_path: + raise RuntimeError("No BlueStacks Air app bundle recorded for %s." % unique_id) + results = macos_root.set_root( + app_path, turn_on, progress, + data_dir=instance.get("user_path") or macos_locator.DATA_DIR) + logger.info("Root %s (Air) for %s: %s", "ON" if turn_on else "OFF", + unique_id, " | ".join(results)) + def _toggle_root_patchmode(self, unique_id, progress=None): instance = self.instance_data[unique_id] turn_on = not instance["root_enabled"] @@ -492,6 +553,22 @@ def _perform_operation(self, operation_func, operation_name): % (", ".join(blocked), "" if len(blocked) > 1 else "s")) return + # Air roots the one system image every instance shares. Running the + # toggle once per ticked instance would flip it back and forth and + # land on "rooted" or "not rooted" depending on how many were + # ticked, so collapse to a single pass. + air_ids = [uid for uid in selected_ids + if self.instance_data[uid].get("air_mode")] + if air_ids: + if len(selected_ids) > 1: + QMessageBox.information( + self, "Root applies to every instance", + "BlueStacks Air shares one Android system image between " + "all its instances, so root is installed once and every " + "instance gets it.\n\nThis will run once, not %d times." + % len(selected_ids)) + selected_ids = air_ids[:1] + total = len(selected_ids) def job(progress): diff --git a/views/nav_rail.py b/views/nav_rail.py index e181719..38b2efc 100644 --- a/views/nav_rail.py +++ b/views/nav_rail.py @@ -42,6 +42,21 @@ def __init__(self, parent=None): layout.addStretch(1) self.select(DASHBOARD) + def set_destination_visible(self, key: str, visible: bool) -> None: + """Show or hide one destination. + + Used to drop Modules on BlueStacks Air, whose module install path is + Magisk-over-ADB and does not exist there. If the hidden destination is + the current one, fall back to Dashboard so the rail cannot end up with + nothing selected and a page the user can no longer navigate away from. + """ + btn = self._buttons.get(key) + if btn is None: + return + btn.setVisible(visible) + if not visible and self.current() == key: + self.select(DASHBOARD) + def select(self, key: str) -> None: for k, btn in self._buttons.items(): btn.setChecked(k == key) diff --git a/views/privacy_controller.py b/views/privacy_controller.py index 4296084..d9e52e7 100644 --- a/views/privacy_controller.py +++ b/views/privacy_controller.py @@ -21,6 +21,7 @@ import ad_settings import constants import instance_handler +import macos_hosts import telemetry_block from views.progress import StepReporter @@ -44,10 +45,21 @@ def _config_path(self): return path return None + @staticmethod + def _hosts_status(data): + """In-guest block state, from whichever backend owns this install. + + Both report the same shape, so the page renders them identically; only + the place the block lives differs (``Root.vhd`` vs ``Root.qcow2``). + """ + if data.get("air_mode"): + return macos_hosts.status(data.get("app_path"), data["user_path"]) + return telemetry_block.status(data["data_path"]) + def refresh_statuses(self) -> None: """Fill the Privacy tab: global ad-switch state + per-instance blocks.""" w = self._window - statuses = {uid: telemetry_block.status(data["data_path"]) + statuses = {uid: self._hosts_status(data) for uid, data in w.instance_data.items()} w.privacy_page.set_instances(statuses) @@ -188,17 +200,23 @@ def handle_block(self) -> None: "

This reaches apps running inside the emulator. It does " "not affect BlueStacks' own ads, which are served by the Windows " "player and never pass through the guest.

" - "

One master Root.vhd is shared by every instance of this " + "

One master system image is shared by every instance of this " "Android version, so this applies to all of them.

"): return data_path = instance["data_path"] + air = instance.get("air_mode") + app_path = instance.get("app_path") def job(progress): progress("Closing BlueStacks...", 0) instance_handler.terminate_bluestacks() QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) - results = telemetry_block.apply( - data_path, progress=StepReporter(progress, _STEPS_HOSTS)) + reporter = StepReporter(progress, _STEPS_HOSTS) + if air: + results = macos_hosts.apply(app_path, progress=reporter, + data_dir=instance["user_path"]) + else: + results = telemetry_block.apply(data_path, progress=reporter) return results[-1] if results else "Trackers blocked." w._run_async(job, "Blocking trackers in %s..." % uid) @@ -215,13 +233,19 @@ def handle_unblock(self) -> None: "shut down (all BlueStacks processes close first).

"): return data_path = instance["data_path"] + air = instance.get("air_mode") + app_path = instance.get("app_path") def job(progress): progress("Closing BlueStacks...", 0) instance_handler.terminate_bluestacks() QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) - results = telemetry_block.remove( - data_path, progress=StepReporter(progress, _STEPS_HOSTS)) + reporter = StepReporter(progress, _STEPS_HOSTS) + if air: + results = macos_hosts.remove(app_path, progress=reporter, + data_dir=instance["user_path"]) + else: + results = telemetry_block.remove(data_path, progress=reporter) return results[-1] if results else "Block removed." w._run_async(job, "Removing the block from %s..." % uid) diff --git a/views/theme.py b/views/theme.py index b9779ef..f9f34f0 100644 --- a/views/theme.py +++ b/views/theme.py @@ -1,6 +1,8 @@ """Light/dark QSS themes and persistence.""" from __future__ import annotations +import sys + from PyQt5.QtCore import QSettings _ORG = "RobThePCGuy" @@ -10,6 +12,55 @@ LIGHT = "light" DARK = "dark" +# --- macOS group boxes ---------------------------------------------------- +# QMacStyle paints a QGroupBox's *frame* as a filled panel in Qt's lightGray +# (#d3d3d3). The generic `QWidget { background-color }` rule below does not +# override it, because it is the frame being drawn and not the background -- +# setting `background-color: transparent` changes nothing, only removing the +# border does. The result is that the Instances and Privacy tabs show grey +# slabs behind their groups on macOS that are invisible on Windows. +# +# So restate the frame explicitly, in the same flat rounded style the buttons +# already use. The title needs its own rule: once QSS takes over the box model, +# the frame is drawn at the widget's top edge and the title is clipped by it +# unless margin-top leaves room and the title is positioned into that margin. +# +# Scoped to macOS deliberately. Windows already renders these correctly, it is +# this project's primary platform, and it cannot be verified from here -- so it +# keeps the native rendering untouched rather than trading a visible bug on one +# platform for an unverifiable change on the other. +_GROUPBOX_QSS = """ +QGroupBox { + background-color: transparent; + border: 1px solid %(border)s; + border-radius: 7px; + margin-top: 12px; + padding: 10px 8px 8px 8px; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 10px; + padding: 0 4px; +} +QCheckBox::indicator:unchecked { + background: %(box)s; + border: 1px solid %(box_border)s; + border-radius: 4px; +} +""" + +# The indicator rule is deliberately :unchecked only. An *empty* macOS checkbox +# is drawn as near-white (#f4f4f4) against this theme's #f3f3f3 page, so it is +# invisible -- you cannot see which instances are tickable until one turns +# blue. Giving it a border fixes that. Styling the indicator in *all* states +# would instead hand the whole box to the QSS box model, which paints a flat +# rectangle and loses the checkmark glyph entirely (verified: the checked box +# renders white and empty). Restricting the rule to :unchecked leaves the +# checked state on the native painter, so the tick survives. + +IS_MACOS = sys.platform == "darwin" + _LIGHT_QSS = """ QWidget { background-color: #f3f3f3; color: #1a1a1a; } QPushButton { background-color: #ffffff; border: 1px solid rgba(0,0,0,0.13); border-radius: 7px; padding: 6px 14px; } @@ -36,13 +87,28 @@ _THEMES = {LIGHT: _LIGHT_QSS, DARK: _DARK_QSS} +# Colours for the macOS-only rules, matching each theme's existing buttons. +_MACOS_COLOURS = { + LIGHT: {"border": "rgba(0,0,0,0.13)", "box": "#ffffff", + "box_border": "rgba(0,0,0,0.30)"}, + DARK: {"border": "rgba(255,255,255,0.11)", "box": "#2b2b2b", + "box_border": "rgba(255,255,255,0.35)"}, +} + def stylesheet_for(theme: str) -> str: - """QSS text for ``theme`` ("light" or "dark"). Raises ValueError otherwise.""" + """QSS text for ``theme`` ("light" or "dark"). Raises ValueError otherwise. + + On macOS this appends rules that correct two native-style artefacts; see + ``_GROUPBOX_QSS``. Windows gets the base themes unchanged. + """ try: - return _THEMES[theme] + qss = _THEMES[theme] except KeyError: raise ValueError("Unknown theme: %r" % theme) from None + if IS_MACOS: + qss += _GROUPBOX_QSS % _MACOS_COLOURS[theme] + return qss def apply_theme(app, theme: str) -> None: