diff --git a/README.md b/README.md index 169efee..7ec32f1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ BlueStacks Root GUI Dashboard -**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. +**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. > [!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. @@ -36,13 +36,13 @@ ## Quick Start -You don't need to know which BlueStacks version you have, the app detects it and shows you the right buttons. Just run it as administrator and follow along. +You don't need to know which BlueStacks version you have; the app detects it and shows you the right buttons. Just run it as administrator and follow along. 1. **Install BlueStacks and open it once.** Let your instance finish booting, then close it. (The tool can only root an instance that already exists.) 2. **Download the tool.** Grab the latest `.exe` from **[Releases](https://github.com/RobThePCGuy/BlueStacks-Root-GUI/releases)**. 3. **Right-click the `.exe` → Run as administrator.** It opens on the **Dashboard** and finds your BlueStacks automatically. 4. **Patch the engine.** Click the red **"Patch BlueStacks Engine (required for root)"** button and confirm. Let it finish. - > Don't see that button? You're on an older build that doesn't need it, skip straight to step 5. + > Don't see that button? You're on an older build that doesn't need it; skip straight to step 5. 5. **Turn on root.** Click **Instances** in the left menu, tick the checkbox next to your instance, and click **Toggle Root**. Watch the progress bar at the bottom and wait for it to finish. 6. **Start BlueStacks.** It boots with no security popup, and your root apps (Root Checker, Kitsune Mask, Magisk) now see root. **Done.** @@ -58,7 +58,7 @@ The window has five tabs down the left side. You'll only ever need the first two | **Instances** | Your instances with live **Root** and **R/W** status. This is where you flip root on and off. | | **Magisk** | Full offline Magisk system-root install/uninstall per instance, plus the post-boot manager, ReZygisk, and LSPosed installs. More involved than basic rooting; optional. | | **Modules** | Push a Magisk module `.zip` into a running instance and flash it for you. Optional. | -| **Privacy** | Block ad/telemetry domains in an instance's guest hosts file, offline and reversible. Optional. | +| **Privacy** | Turn BlueStacks' own ads and telemetry off (its config switches, all instances), and optionally block tracker domains inside one instance's guest hosts file. Both reversible. Optional. | A **light/dark theme** toggle sits in the header, and a **progress bar** along the bottom shows what the tool is doing during any operation. @@ -69,7 +69,7 @@ A **light/dark theme** toggle sits in the header, and a **progress bar** along t 1. Download the latest `.exe` from **[Releases](https://github.com/RobThePCGuy/BlueStacks-Root-GUI/releases)**. 2. Right-click it and choose **"Run as administrator."** -You need **Windows 10 or later** and **administrator rights** (the tool reads the registry, patches files under `Program Files`, and closes BlueStacks). You do **not** need to uninstall or downgrade BlueStacks first, the tool patches whatever current version you have, in place. +You need **Windows 10 or later** and **administrator rights** (the tool reads the registry, patches files under `Program Files`, and closes BlueStacks). You do **not** need to uninstall or downgrade BlueStacks first: the tool patches whatever current version you have, in place. ### Option 2: Run from Source @@ -96,7 +96,7 @@ pyinstaller --onefile --windowed --icon="favicon.ico" --add-data "favicon.ico;." 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. +> 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. ## Usage Guide @@ -108,24 +108,24 @@ This is the path for current BlueStacks (5.22.150.1014 and newer). You get root 1. **Create the instance first**: if this is a brand-new install, open BlueStacks once so it builds and boots your instance, then close it. Root can't be added until the instance's disk exists. 2. **Patch the engine (once per install)**: on the **Dashboard**, click **"Patch BlueStacks Engine (required for root)"** → **Yes**. All BlueStacks processes are closed first, then the tool patches and backs up the engine files. Until you do this, the **Instances** page shows a *"Patch-mode root is locked"* banner with a **Fix it** shortcut back to the Dashboard. -3. **Toggle root (per instance)**: go to the **Instances** page, tick the instance, and click **"Toggle Root."** **Watch the progress bar at the bottom**: it walks through *"Part 1/2: enabling root access..."* then *"Part 2/2: patching guest su in Data.vhdx..."* before the button is usable again. Don't launch the instance while that's running, wait for it to finish. If it says `su` isn't there yet, a dialog will tell you to boot the instance once and toggle again. +3. **Toggle root (per instance)**: go to the **Instances** page, tick the instance, and click **"Toggle Root."** **Watch the progress bar at the bottom**: it walks through *"Part 1/2: enabling root access..."* then *"Part 2/2: patching guest su in Data.vhdx..."* before the button is usable again. Don't launch the instance while that's running; wait for it to finish. If it says `su` isn't there yet, a dialog will tell you to boot the instance once and toggle again. 4. **Restart the instance**: start it from BlueStacks. It should boot with **no** security/tamper popup, and root-checker apps (or Kitsune Mask / Magisk) will see root. > [!NOTE] > If a background BlueStacks auto-update later replaces the patched files, the Dashboard raises an **"auto-update reverted your engine patch"** alert with a **Re-patch now** button. See [Keep Root After Updates](#keep-root-after-updates) to stop it happening again. > [!TIP] -> This gets **apps** working root, enough for most root-requiring apps and root checkers. If you want **Magisk/Kitsune-managed root with modules** (Zygisk via ReZygisk, LSPosed, etc.), that's a separate, more involved setup with real emulator gotchas. It's documented in the companion guide: **[Root BlueStacks with Kitsune Mask → Magisk Modules & Hiding](https://github.com/RobThePCGuy/Root-Bluestacks-with-Kitsune-Mask#magisk-modules--hiding-advanced)**. Note: Play Integrity does not pass on an emulator, Google limits that to its own Google Play Games, so integrity-gated apps won't work here regardless of modules. +> This gets **apps** working root, enough for most root-requiring apps and root checkers. If you want **Magisk/Kitsune-managed root with modules** (Zygisk via ReZygisk, LSPosed, etc.), that's a separate, more involved setup with real emulator gotchas. It's documented in the companion guide: **[Root BlueStacks with Kitsune Mask → Magisk Modules & Hiding](https://github.com/RobThePCGuy/Root-Bluestacks-with-Kitsune-Mask#magisk-modules--hiding-advanced)**. Note: Play Integrity does not pass on an emulator; Google limits that to its own Google Play Games, so integrity-gated apps won't work here regardless of modules. ### Magisk Modules, Kitsune Mask & Older Builds -Everything past basic root, installing **Kitsune Mask** into `/system`, choosing and flashing **Magisk modules** (ReZygisk, LSPosed, module load order), and rooting **older or MSI builds**: lives in the companion guide, so it stays in one maintained place instead of being half-covered in two: +Everything past basic root lives in the companion guide: installing **Kitsune Mask** into `/system`, choosing and flashing **Magisk modules** (ReZygisk, LSPosed, module load order), and rooting **older or MSI builds**. One maintained place beats two half-covered ones. > [!TIP] > **➡️ [Root BlueStacks with Kitsune Mask](https://github.com/RobThePCGuy/Root-Bluestacks-with-Kitsune-Mask)**: the full written walkthrough. > Stuck, or want to share a setup that works? Ask and help out in **[Discussions](https://github.com/RobThePCGuy/Root-Bluestacks-with-Kitsune-Mask/discussions)** there. -One tool-specific note: this app's **Modules** tab pushes and flashes a module `.zip` into a running, rooted instance for you, start the instance, open the **Modules** tab, pick it, **Browse...** to the `.zip`, and click **Push and flash module**, then reopen the instance. It exists because BlueStacks' own file picker hands Magisk an *"Invalid Uri"* it can't open. (If the ADB root shell isn't reachable, the tool drops the `.zip` in the instance's `Download` folder so you can flash it by hand.) +One tool-specific note: this app's **Modules** tab pushes and flashes a module `.zip` into a running, rooted instance for you. Start the instance, open the **Modules** tab, pick it, **Browse...** to the `.zip`, click **Push and flash module**, then reopen the instance. It exists because BlueStacks' own file picker hands Magisk an *"Invalid Uri"* it can't open. (If the ADB root shell isn't reachable, the tool drops the `.zip` in the instance's `Download` folder so you can flash it by hand.) ### Keep Root After Updates @@ -138,7 +138,7 @@ 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, the `sc.exe` lines will report *"service does not exist,"* which is fine, but 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. +> 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. ## Troubleshooting @@ -150,7 +150,7 @@ schtasks /Change /TN "BlueStacksHelper_nxt" /DISABLE - Perform a clean reinstall using the official cleaner tool **"Permission denied" while patching `HD-MultiInstanceManager.exe`** -- This means the Multi-Instance Manager window was open, locking the file. The tool now closes it automatically before patching, make sure you're on the latest version, then re-run "Patch BlueStacks Engine." +- This means the Multi-Instance Manager window was open, locking the file. The tool now closes it automatically before patching; make sure you're on the latest version, then re-run "Patch BlueStacks Engine." **"Toggle Root" says `su` isn't in `Data.vhdx` yet** - The guest `su` only materializes after the instance's first boot. Start the instance once, shut it down, and toggle root again. @@ -162,7 +162,7 @@ schtasks /Change /TN "BlueStacksHelper_nxt" /DISABLE - Ensure BlueStacks processes were fully terminated (kill leftovers in Task Manager if needed) **Installing a module fails with "Invalid Uri"** -- Don't use BlueStacks' own file picker, use the app's **Modules** tab instead (see [Magisk Modules, Kitsune Mask & Older Builds](#magisk-modules-kitsune-mask--older-builds)). Deeper Kitsune/module help lives in the [companion guide's Discussions](https://github.com/RobThePCGuy/Root-Bluestacks-with-Kitsune-Mask/discussions). +- Don't use BlueStacks' own file picker; use the app's **Modules** tab instead (see [Magisk Modules, Kitsune Mask & Older Builds](#magisk-modules-kitsune-mask--older-builds)). Deeper Kitsune/module help lives in the [companion guide's Discussions](https://github.com/RobThePCGuy/Root-Bluestacks-with-Kitsune-Mask/discussions). **Toggle operation errors** - Check the progress bar/status text at the bottom of the window for the error message @@ -176,7 +176,7 @@ schtasks /Change /TN "BlueStacksHelper_nxt" /DISABLE | BlueStacks Version | Root Working? | Method | |-------------------|---------------|--------| -| 5.20.x – 5.21.x | Yes | Classic `enable_root_access` rooting | +| 5.20.x to 5.21.x | Yes | Classic `enable_root_access` rooting | | 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 | @@ -205,7 +205,7 @@ schtasks /Change /TN "BlueStacksHelper_nxt" /DISABLE
How to Downgrade to 5.21 (legacy) -You should not need this anymore, it's kept for reference only. +You should not need this anymore; it's kept for reference only. 1. **Backup your data** - Export important app data/saves @@ -227,7 +227,7 @@ You should not need this anymore, it's kept for reference only. ## How It Works -*(For the curious, you don't need any of this to use the tool.)* +*(For the curious. None of this is needed to use the tool.)* BlueStacks changed how it locks down root across versions, so the tool uses two approaches and chooses automatically based on the detected version. @@ -238,58 +238,60 @@ BlueStacks changed how it locks down root across versions, so the tool uses two 1. **Engine patch** - flips `_isDiskVerificationRequired()` in `HD-Player.exe` to return 0, which disables the integrity shutdown **and** turns on Developer Mode. It also NOPs the routine in `HD-MultiInstanceManager.exe` that resets `enable_root_access` to 0. 2. **Guest-`su` patch** - opens the instance's `Data.vhdx` directly (no running instance, no ADB), finds every guest `su`, and flips its `isDeveloperMode()` gate to always-grant so root works for **every app**. -Both patches are located by byte signature, not hard-coded offsets, so they survive minor version rebuilds, and both are fully reversible. +Both patches are located by byte signature rather than hard-coded offsets, so they survive minor version rebuilds, and both are fully reversible. > [!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). ## Features -- **Nav-Rail Layout** - A left navigation rail splits the app into five pages: **Dashboard** (install paths, engine-patch state, rooted-instance count), **Instances** (per-instance root/R-W toggles), **Magisk** (full offline Magisk system-root install/uninstall, manager, ReZygisk, LSPosed), **Modules** (push and flash a Magisk module), and **Privacy** (block ad/telemetry domains in the guest hosts file). A light/dark theme toggle sits in the header -- **Auto-Detection** - Discovers BlueStacks installation paths via the Windows Registry (Normal, China, and MSI editions) and picks the right rooting method per version automatically -- **Instance Listing** - Lists every instance by its display name with live Root and R/W status (root shows a green highlight when on), including newer instances that use a single `Data.vhdx` layout (created or cloned): not just the classic `fastboot.vdi`/`Root.vhd` ones -- **Engine-Patch Status** - The Dashboard's engine button reads its own state at a glance: *"Patch BlueStacks Engine (required for root),"* *"Engine patched (click to Undo),"* or *"Engine partially patched (click to finish)."* It's per-install and applies to every instance -- **Patch-Gating Banner** - On patch-mode builds, the Instances page shows a banner while the engine is unpatched (*"Patch-mode root is locked…"*) with a **Fix it** button that jumps straight to the Dashboard, so you can't try to root an instance before the engine is ready -- **Update-Revert Alert** - If a background auto-update silently replaces the patched files, the Dashboard raises an alert with a one-click **Re-patch now** button -- **Root Toggle** - Enables root the right way for your build: the `enable_root_access` / `bst.feature.rooting` flags on classic builds, plus an offline guest-`su` patch on 5.22.150.1014+. Prompts you to boot a fresh instance once if its `su` isn't generated yet -- **Engine Patch (5.22+)** - Patches `HD-Player.exe` to disable the *"doesn't meet security"* integrity shutdown, and `HD-MultiInstanceManager.exe` so root isn't reset back off when you edit instances -- **Read/Write Toggle** - Switches disk files (`fastboot.vdi`, `Root.vhd`) between `Normal` and `Readonly` -- **Push and Flash Module** - The Modules page pushes a module `.zip` into a running instance and flashes it directly over BlueStacks' bundled ADB (`magisk --install-module`), so you skip BlueStacks' file dialog entirely (it hands Magisk an *"Invalid Uri"* it can't open). Just close and reopen the instance afterwards to activate it -- **Reversible** - Every binary patch backs up to a `.prepatch.bak`; every guest-`su` patch records the original bytes. "Undo Engine Patch" and toggling root off restore the originals -- **Process Handling** - Closes all BlueStacks processes (player, services, and the Multi-Instance Manager) before applying changes -- **Responsive UI** - Long operations run on background threads (`QThread`) so the window never freezes, and a docked progress bar reports real step-by-step percentages +- **Nav-Rail Layout**: A left navigation rail splits the app into five pages: **Dashboard** (install paths, engine-patch state, rooted-instance count), **Instances** (per-instance root/R-W toggles), **Magisk** (full offline Magisk system-root install/uninstall, manager, ReZygisk, LSPosed), **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 +- **Ad and Telemetry Removal**: Turns off BlueStacks' own advertising, promo, and stats-upload switches in `bluestacks.conf`. This is the part that actually stops the ads: they are served by `HD-Player.exe` on Windows, so nothing changed inside Android can reach them. Measured on 5.22.250.1015, the player's ad and tracker endpoints went from 40 to 0. The switches are found by pattern rather than a fixed list, so an update that renames or adds one is still covered; every original value is recorded for an exact restore, and an optional read-only pin stops BlueStacks turning the stats beacons back on. Reversible in one click +- **Auto-Detection**: Discovers BlueStacks installation paths via the Windows Registry (Normal, China, and MSI editions) and picks the right rooting method per version automatically +- **Instance Listing**: Lists every instance by its display name with live Root and R/W status (root shows a green highlight when on), including newer instances that use a single `Data.vhdx` layout (created or cloned), alongside the classic `fastboot.vdi`/`Root.vhd` ones +- **Engine-Patch Status**: The Dashboard's engine button reads its own state at a glance: *"Patch BlueStacks Engine (required for root),"* *"Engine patched (click to Undo),"* or *"Engine partially patched (click to finish)."* It's per-install and applies to every instance +- **Patch-Gating Banner**: On patch-mode builds, the Instances page shows a banner while the engine is unpatched (*"Patch-mode root is locked…"*) with a **Fix it** button that jumps straight to the Dashboard, so you can't try to root an instance before the engine is ready +- **Update-Revert Alert**: If a background auto-update silently replaces the patched files, the Dashboard raises an alert with a one-click **Re-patch now** button +- **Root Toggle**: Enables root the right way for your build: the `enable_root_access` / `bst.feature.rooting` flags on classic builds, plus an offline guest-`su` patch on 5.22.150.1014+. Prompts you to boot a fresh instance once if its `su` isn't generated yet +- **Engine Patch (5.22+)**: Patches `HD-Player.exe` to disable the *"doesn't meet security"* integrity shutdown, and `HD-MultiInstanceManager.exe` so root isn't reset back off when you edit instances +- **Read/Write Toggle**: Switches disk files (`fastboot.vdi`, `Root.vhd`) between `Normal` and `Readonly` +- **Push and Flash Module**: The Modules page pushes a module `.zip` into a running instance and flashes it directly over BlueStacks' bundled ADB (`magisk --install-module`), so you skip BlueStacks' file dialog entirely (it hands Magisk an *"Invalid Uri"* it can't open). Just close and reopen the instance afterwards to activate it +- **Reversible**: Every binary patch backs up to a `.prepatch.bak`; every guest-`su` patch records the original bytes. "Undo Engine Patch" and toggling root off restore the originals +- **Process Handling**: Closes all BlueStacks processes (player, services, and the Multi-Instance Manager) before applying changes +- **Responsive UI**: Long operations run on background threads (`QThread`) so the window never freezes, and a docked progress bar reports real step-by-step percentages ## Development ### Project Structure -- `main.py` - Application entry point and controller: wires the UI to the handlers, owns the background-thread orchestration -- `views/` - PyQt5 UI package (nav-rail layout) - - `main_window.py` - Main window: nav rail, page stack, worker threads, docked progress bar - - `nav_rail.py` - Left navigation rail (Dashboard / Instances / Magisk / Modules / Privacy) - - `dashboard_page.py` - Install paths, engine-patch button, update-revert alert, rooted-count stat - - `instances_page.py` - Instance grid, Toggle Root/R-W, patch-gating banner - - `magisk_page.py` - Full offline Magisk system-root install/uninstall per instance, plus the manager, ReZygisk, and LSPosed installs - - `modules_page.py` - Pick a running instance, pick a module `.zip`, push and flash - - `privacy_page.py` - Block ad/telemetry domains in an instance's guest hosts file, offline and reversible - - `progress.py` - Docked status/progress indicator with step percentages - - `theme.py` - Light/dark QSS themes and persistence - - `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 -- `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) -- `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) -- `ext4_symlink.py` - Classic/MSI app root: adds `/system/xbin/su` in `Root.vhd` via bundled `debugfs` (`tools/e2fsprogs/`) -- `magisk_system.py` - Offline Magisk-to-system install: stages the DATABIN into `Data.vhdx` and the `/system` footprint into `Root.vhd`, all via bundled `debugfs` -- `magisk_payload.py` - Downloads and hash-verifies the latest Kyubi (Magisk) release APK, and extracts the native tools/assets `magisk_system.py` needs -- `rezygisk_payload.py` - Downloads and hash-verifies the pinned ReZygisk module (standalone Zygisk for the emulator) -- `lsposed_payload.py` - Downloads and hash-verifies the pinned LSPosed (Zygisk) module -- `telemetry_block.py` - Null-routes ad/telemetry domains in an instance's guest hosts file, offline via `Root.vhd` -- `magisk_assets/` - Version-pinned system-install assets (`config`, `bootanim.rc`, `bootanim.rc.gz`) bundled for the Magisk system-mode install +- `main.py`: Application entry point and controller; wires the UI to the handlers and owns the background-thread orchestration +- `views/`: PyQt5 UI package (nav-rail layout) + - `main_window.py`: Main window; nav rail, page stack, worker threads, docked progress bar + - `nav_rail.py`: Left navigation rail (Dashboard / Instances / Magisk / Modules / Privacy) + - `dashboard_page.py`: Install paths, engine-patch button, update-revert alert, rooted-count stat + - `instances_page.py`: Instance grid, Toggle Root/R-W, patch-gating banner + - `magisk_page.py`: Full offline Magisk system-root install/uninstall per instance, plus the manager, ReZygisk, and LSPosed installs + - `modules_page.py`: Pick a running instance, pick a module `.zip`, push and flash + - `privacy_page.py`: Turn BlueStacks' own ads/telemetry off (global config switches), plus the per-instance in-guest tracker block + - `progress.py`: Docked status/progress indicator with step percentages + - `theme.py`: Light/dark QSS themes and persistence + - `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 +- `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) +- `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) +- `ext4_symlink.py`: Classic/MSI app root; adds `/system/xbin/su` in `Root.vhd` via bundled `debugfs` (`tools/e2fsprogs/`) +- `magisk_system.py`: Offline Magisk-to-system install; stages the DATABIN into `Data.vhdx` and the `/system` footprint into `Root.vhd`, all via bundled `debugfs` +- `magisk_payload.py`: Downloads and hash-verifies the latest Kyubi (Magisk) release APK, and extracts the native tools/assets `magisk_system.py` needs +- `rezygisk_payload.py`: Downloads and hash-verifies the pinned ReZygisk module (standalone Zygisk for the emulator) +- `lsposed_payload.py`: Downloads and hash-verifies the pinned LSPosed (Zygisk) module +- `ad_settings.py`: Turns BlueStacks' own ad/promo/stats switches off in the global `bluestacks.conf`. Discovers them by pattern so a version update can't silently outdate the list, records originals for an exact restore, and can pin the file read-only +- `telemetry_block.py`: Null-routes tracker domains in an instance's guest hosts file, offline via `Root.vhd`. Reaches apps inside the emulator only: BlueStacks' own ads are host-side, so `ad_settings.py` handles those +- `magisk_assets/`: Version-pinned system-install assets (`config`, `bootanim.rc`, `bootanim.rc.gz`) bundled for the Magisk system-mode install ### Dependencies @@ -309,7 +311,7 @@ pytest ## Contributing -Contributions are welcome! Please: +Contributions are welcome. Please: - Maintain existing code style and structure - Use the `logging` module for debugging output diff --git a/ad_settings.py b/ad_settings.py new file mode 100644 index 0000000..144f316 --- /dev/null +++ b/ad_settings.py @@ -0,0 +1,308 @@ +"""Turn BlueStacks' own ad / promo / telemetry switches off in ``bluestacks.conf``. + +Why this exists (and why the guest hosts block isn't enough) +----------------------------------------------------------- +BlueStacks' own advertising is served by **HD-Player.exe on Windows**, not by the +Android guest. A live capture settled it: with the guest **completely powered +off**, HD-Player still held open connections to googlesyndication, inmobi, +rubiconproject, adnxs and a dozen RTB exchanges. A guest ``/system/etc/hosts`` +file cannot reach any of that -- there is no guest involved. Measured on +5.22.250.1015, applying the guest block changed the player's ad endpoints not at +all (40 before, 40+ after). + +BlueStacks ships explicit switches for this in its own config. Turning them off +is dramatically more effective *and* less invasive than editing the guest system +image -- same capture rig, same instance: + +=========================== ================== ================== +measurement guest hosts block these switches +=========================== ================== ================== +ad/tracker endpoints 40 (no change) **0** +unique remote IPs 151 -> 133 151 -> **14** +=========================== ================== ================== + +What survived was purely Play/GMS/Firebase infrastructure, and the emulator +stayed healthy. ``bst.enable_programmatic_ads="0"`` is the load-bearing switch. + +Surviving version updates +------------------------- +Keys get renamed, added and removed between BlueStacks builds, and a hard-coded +list silently rots. So this module **discovers** the keys in whatever config is +actually present, by matching concept patterns (``programmatic_ads``, +``send_*_stats``, ``auto_upload``, ...) rather than fixed names. A future build +that adds ``bst.feature.send_new_ad_stats`` is handled with no code change. + +Three gates keep that safe -- a rename can never make us flip something harmful: + +1. the key must match a curated concept pattern (:data:`SWITCH_PATTERNS`), +2. it must NOT match an exclusion (:data:`NEVER_TOUCH`) -- rooting, ADB, the + ``*_preference`` keys that control whether BlueStacks' own settings toggle is + *visible*, and anything with inverted ``disable``/``skip`` semantics, +3. its current value must be exactly ``"0"`` or ``"1"``. That alone protects + every id/string/number key, e.g. ``android_google_ad_id``. + +We only ever write ``"0"``, so gate 3 plus the ``disable``/``skip`` exclusion +means the semantics are always "turn this feature off". + +Reversibility and drift +----------------------- +``apply`` records each key's original value in a sidecar next to the config, so +``remove`` restores exactly what was there -- including keys that were already +``"0"``. + +BlueStacks **selectively rewrites** keys when it starts, and the split observed +on 5.22.250.1015 is informative: the ``bst.enable_*`` keys and +``feature.show_gp_ads`` **stick**, while most ``bst.feature.*`` keys are put back +to ``"1"`` from the service's own defaults (``nowbux``, the ``nowgg_*`` pair, and +four ``send_*_stats`` beacons all came back). The ads stayed gone anyway, +because the load-bearing ``enable_programmatic_ads`` is one of the survivors -- +but the *telemetry beacons* did get re-enabled, which is exactly what +:func:`lock` is for. :func:`status` reports the drift honestly so the UI can +offer a re-apply, and the read-only pin is offered rather than forced because a +locked config also stops BlueStacks editing its own settings. + +The config is BlueStacks' single **global** file -- these switches apply to every +instance, not one. Write them with BlueStacks shut down; it rewrites the file on +exit. +""" +from __future__ import annotations + +import datetime +import json +import logging +import os +import re + +import config_handler +import root_persistence + +logger = logging.getLogger(__name__) + +_STATE_NAME = ".bsrgui_ad_settings.json" + +#: Concept patterns for switches worth turning off. Matched case-insensitively +#: against the whole key. Deliberately about *concepts* ("programmatic ads", +#: "stats upload") rather than exact names, so a renamed or newly added key in a +#: later BlueStacks build is still recognised. +SWITCH_PATTERNS = ( + r"programmatic_ads", # the player's ad unit (the load-bearing one) + r"show_gp_ads", # ads on the game-player surface + r"android_ads_stats", + r"split_ad_enabled", + r"enable_ads", + r"show_ads", + r"boot_banner", # promo banner on instance start + r"nowbux", # nowbux rewards promo + r"bluestacksx", # BlueStacksX promo surface + r"nowgg_login_popup", + r"nowgg_cloud_upload", + r"auto_upload", # recording / "moments" cloud uploads + r"send_\w*stats", # every stats-beacon key + r"send_offer", +) + +#: Hard exclusions, checked before :data:`SWITCH_PATTERNS`. These protect keys +#: that merely *look* related, or where writing ``"0"`` would be backwards. +NEVER_TOUCH = ( + r"_preference$", # controls whether BlueStacks' own ad toggle is VISIBLE in + # Settings -- turning it off hides the user's control + r"root", # rooting keys belong to root_persistence, never here + r"adb", # "adb" contains "ad"; bst.enable_adb_access is not an ad key + r"disable", # inverted semantics -- writing "0" would turn a feature ON + r"skip", # e.g. skipNowggLogin: "1" is the desirable value +) + +#: The value written to every managed key. +OFF = "0" + +_KEY_RE = re.compile(r"^\s*([\w.]+)\s*=\s*\"?([^\"\r\n]*)\"?\s*$") +_SWITCH_RE = re.compile("|".join(SWITCH_PATTERNS), re.IGNORECASE) +_NEVER_RE = re.compile("|".join(NEVER_TOUCH), re.IGNORECASE) + + +def is_managed(key: str, value: str) -> bool: + """Whether this config key is one we turn off. + + All three gates, in order: not excluded, matches a concept pattern, and is a + boolean-valued switch. ``value`` matters -- it is what keeps id/string keys + such as ``android_google_ad_id`` out of scope no matter what they are named. + """ + if _NEVER_RE.search(key): + return False + if not _SWITCH_RE.search(key): + return False + return value.strip() in ("0", "1") + + +def _parse(text: str) -> dict[str, str]: + """All ``key -> value`` pairs in a bluestacks.conf.""" + found: dict[str, str] = {} + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + m = _KEY_RE.match(line) + if m: + found[m.group(1)] = m.group(2) + return found + + +def _read(config_path: str) -> str: + with open(config_path, encoding="utf-8") as f: + return f.read() + + +def discover(config_path: str) -> dict[str, str]: + """The managed switches present in this config, mapped to current values. + + Discovery runs against the real file every time, so a BlueStacks update that + adds or renames switches is picked up without a code change. + """ + return {k: v for k, v in _parse(_read(config_path)).items() if is_managed(k, v)} + + +def _state_path(config_path: str) -> str: + return os.path.join(os.path.dirname(config_path), _STATE_NAME) + + +def status(config_path: str) -> dict | None: + """Current state, or ``None`` if these switches have not been turned off. + + On top of the stored sidecar this re-reads the live config and reports: + + ``off`` + managed keys currently sitting at ``"0"``. + ``reverted`` + keys we set that BlueStacks has since put back -- expected for a couple + of them on every start, and the reason the UI offers a re-apply. + ``unmanaged`` + switches present now that we have no original recorded for, i.e. keys a + BlueStacks update introduced since. Re-applying adopts them. + """ + try: + with open(_state_path(config_path), encoding="utf-8") as f: + state = json.load(f) + except (OSError, ValueError): + return None + + try: + live = discover(config_path) + except OSError: + return state + + originals = state.get("originals", {}) + state = dict(state) + state["off"] = sorted(k for k, v in live.items() if v.strip() == OFF) + state["reverted"] = sorted(k for k, v in live.items() + if k in originals and v.strip() != OFF) + state["unmanaged"] = sorted(k for k in live if k not in originals) + state["locked"] = root_persistence.is_locked(config_path) + return state + + +def _write_state(config_path: str, originals: dict[str, str] | None) -> None: + path = _state_path(config_path) + if originals is None: + try: + os.unlink(path) + except OSError: + pass + return + payload = { + "ads_disabled": True, + "keys": len(originals), + "originals": originals, + "applied_at": datetime.datetime.now().replace(microsecond=0).isoformat(), + } + with open(path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + + +def apply(config_path: str, progress=None) -> list[str]: + """Turn every discovered ad/telemetry switch off. + + Idempotent, and safe to re-run after BlueStacks reverts a key. Originals are + recorded on the first apply and preserved across re-applies, so + :func:`remove` always restores the true pre-BSRGUI values; keys introduced by + a later BlueStacks build are adopted (with their current value recorded) on + the next apply. BlueStacks should be shut down -- it rewrites this file on + exit. + """ + def _p(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + if not os.path.isfile(config_path): + raise FileNotFoundError(config_path) + + switches = discover(config_path) + if not switches: + return ["No ad/telemetry switches found in bluestacks.conf " + "(BlueStacks may have renamed them in this build)."] + + prior = (status(config_path) or {}).get("originals", {}) + originals = dict(prior) + for key, value in switches.items(): + originals.setdefault(key, value) + + _p("Turning off %d BlueStacks ad/telemetry switches..." % len(switches)) + changed = 0 + for key in sorted(switches): + if config_handler.modify_config_file(config_path, key, OFF): + changed += 1 + + _write_state(config_path, originals) + _p("Verifying...") + still_on = sorted(k for k, v in discover(config_path).items() if v.strip() != OFF) + if still_on: + # Not a failure: the write is verified below by re-reading, so this means + # something outside this process is holding values on. + logger.warning("Switches still on after write: %s", still_on) + return ["Turned off %d of %d BlueStacks ad/telemetry switches (%d still on: %s)." + % (len(switches) - len(still_on), len(switches), len(still_on), + ", ".join(still_on))] + return ["Turned off %d BlueStacks ad/telemetry switches (%d newly changed)." + % (len(switches), changed)] + + +def remove(config_path: str, progress=None) -> list[str]: + """Restore every switch to the value it had before :func:`apply`.""" + def _p(msg: str) -> None: + logger.info(msg) + if progress: + progress(msg) + + state = status(config_path) + if not state: + return ["BlueStacks ad/telemetry switches were not changed by this tool."] + + originals = state.get("originals", {}) + if not originals: + _write_state(config_path, None) + return ["Nothing recorded to restore."] + + _p("Restoring %d BlueStacks ad/telemetry switches..." % len(originals)) + restored = 0 + for key in sorted(originals): + if config_handler.modify_config_file(config_path, key, originals[key]): + restored += 1 + + _write_state(config_path, None) + return ["Restored %d BlueStacks ad/telemetry switches (%d changed back)." + % (len(originals), restored)] + + +def lock(config_path: str) -> bool: + """Pin the config read-only so BlueStacks cannot revert the switches. + + Optional. The load-bearing ``enable_programmatic_ads`` key survives without + it, and a locked config stops BlueStacks editing its own settings, so this is + offered rather than applied automatically. Shares the one read-only bit with + the root-persistence lock -- see :mod:`root_persistence`. + """ + return root_persistence.lock(config_path) + + +def unlock(config_path: str) -> bool: + """Release the read-only pin taken by :func:`lock`.""" + return root_persistence.unlock(config_path) diff --git a/telemetry_block.py b/telemetry_block.py index 4f88105..4becec0 100644 --- a/telemetry_block.py +++ b/telemetry_block.py @@ -1,16 +1,28 @@ """Offline ad/telemetry blocking for a BlueStacks instance's guest hosts file. -Why this exists ---------------- -BlueStacks and the apps inside it phone home to ad networks and analytics -endpoints -- and the in-app "disable ads" toggle only covers a fraction of it -(a live capture still shows the player reaching an ad-exchange like rtbhouse -with that toggle off). The surgical, emulator-only fix is the classic Android -ad-block approach: null-route the ad/tracker domains in the guest's +Why this exists, and what it does NOT do +---------------------------------------- +Apps running inside the emulator phone home to ad networks and analytics +endpoints. The surgical, emulator-only fix is the classic Android ad-block +approach: null-route the ad/tracker domains in the guest's ``/system/etc/hosts``. This affects **only the emulator's guest**, never the user's Windows machine (unlike a system-wide hosts edit), and it's fully reversible. +**It cannot block BlueStacks' own ads.** Those are served by ``HD-Player.exe`` +on Windows, not by the guest -- proven by a live capture in which the player kept +open connections to googlesyndication, inmobi, rubiconproject and adnxs while the +Android guest was **completely powered off**. Applying this block changed the +player's ad endpoints not at all (40 before, 40 after, on 5.22.250.1015). For +those use :mod:`ad_settings`, which turns off BlueStacks' own config switches and +measured 40 -> 0 on the same rig. This module is for in-guest app traffic; the +two are complementary, not alternatives. + +A hosts file also has **no wildcard support**: an entry for ``doubleclick.net`` +does nothing for ``cm.g.doubleclick.net`` or ``pagead2.googlesyndication.com``. +Real ad traffic is overwhelmingly subdomains, so the observed ones are enumerated +explicitly in ``HOST_BLOCKLIST``. + How it works ------------ ``/system/etc/hosts`` lives in the guest system tree inside ``Root.vhd`` (mounted @@ -31,7 +43,10 @@ telemetry block. Requirements: Windows, Administrator (raw-disk access + diskpart), instance shut -down. +down, **and a patched engine**. This edits the guest system image, and BlueStacks +shuts down an instance whose system image was modified ("...illegally +tampered...") unless ``integrity_patch`` has been applied -- root is not required +to trip that check, any modification does it. """ from __future__ import annotations @@ -52,12 +67,15 @@ _STATE_NAME = ".telemetry_block.json" # host-side: applied? + provenance # Null-routed domains. Conservative on purpose -- clear third-party ad networks, -# mobile-attribution SDKs, and the ad exchange caught in a live capture. NOT +# mobile-attribution SDKs, and the exchanges caught in a live capture. NOT # Google Play / GMS infrastructure, NOT an app's own backend. Extend from a live # capture of the target build (see module docstring). +# +# These are apex domains; each also gets a "www." alias. A hosts file has NO +# wildcard support, so an apex entry does not cover subdomains -- and real +# ad traffic is almost entirely subdomains. The observed ones therefore have to +# be listed explicitly in HOST_BLOCKLIST below. BLOCKLIST = ( - # ad exchange seen phoning home from the player itself (live capture) - "rtbhouse.net", # generic ad serving "doubleclick.net", "googlesyndication.com", @@ -80,6 +98,48 @@ "appsflyer.com", "adjust.com", "kochava.com", + # ad exchange (the earlier list had "rtbhouse.net", which does not resolve -- + # the real endpoint seen in a capture is esp.rtbhouse.com, below) + "rtbhouse.com", +) + +# Fully-qualified hostnames, listed individually because a hosts file cannot +# wildcard a domain. Every one of these was observed live on 5.22.250.1015 while +# an apex-only blocklist was already applied -- i.e. these are exactly the +# endpoints that the apex entries above silently fail to cover. +HOST_BLOCKLIST = ( + # google ad serving (subdomains of already-listed apexes) + "ad.doubleclick.net", + "cm.g.doubleclick.net", + "static.doubleclick.net", + "googleads.g.doubleclick.net", + "securepubads.g.doubleclick.net", + "pagead2.googlesyndication.com", + "tpc.googlesyndication.com", + "ep1.adtrafficquality.google", + "ep2.adtrafficquality.google", + # inmobi + "w.inmobi.com", + "api.w.inmobi.com", + "sync.inmobi.com", + # exchanges / RTB / cookie-sync seen in the live capture + "esp.rtbhouse.com", + "ib.adnxs.com", + "secure.adnxs.com", + "fastlane.rubiconproject.com", + "pixel-us-east.rubiconproject.com", + "token.rubiconproject.com", + "rtb.openx.net", + "us-u.openx.net", + "eu-u.openx.net", + "google-bidout-d.openx.net", + "oa.openxcdn.net", + "hbopenbid.pubmatic.com", + "ssum-sec.casalemedia.com", + "js-sec.indexww.com", + "direct.adsrvr.org", + "btlr.sharethrough.com", + "ads.betweendigital.com", ) @@ -88,13 +148,26 @@ def _instance_paths(instance_dir: str) -> tuple[str, str]: os.path.join(instance_dir, _STATE_NAME)) +def blocked_hosts() -> tuple[str, ...]: + """Every hostname the block null-routes, de-duplicated and ordered. + + Apex domains contribute their ``www.`` alias; ``HOST_BLOCKLIST`` entries are + used verbatim, since they are the subdomains an apex entry cannot cover. + """ + seen: dict[str, None] = {} + for d in BLOCKLIST: + seen.setdefault(d, None) + seen.setdefault("www.%s" % d, None) + for h in HOST_BLOCKLIST: + seen.setdefault(h, None) + return tuple(seen) + + def _block_text() -> str: - """The marked block of ``0.0.0.0 `` lines (both the bare domain and - its ``www.`` alias).""" + """The marked block of ``0.0.0.0 `` lines.""" lines = [_BLOCK_BEGIN] - for d in BLOCKLIST: - lines.append("0.0.0.0 %s" % d) - lines.append("0.0.0.0 www.%s" % d) + for host in blocked_hosts(): + lines.append("0.0.0.0 %s" % host) lines.append(_BLOCK_END) return "\n".join(lines) + "\n" @@ -176,7 +249,7 @@ def _write_state(instance_dir: str, applied: bool) -> None: pass return data = {"telemetry_block": True, - "domains": len(BLOCKLIST), + "domains": len(blocked_hosts()), "applied_at": datetime.datetime.now().isoformat(timespec="seconds")} try: with open(state, "w", encoding="utf-8") as f: @@ -223,7 +296,7 @@ def _p(msg: str) -> None: except OSError as exc: logger.warning("could not save hosts backup: %s", exc) new = base + _block_text() - _p("Writing %d blocked domains into guest hosts..." % len(BLOCKLIST)) + _p("Writing %d blocked hostnames into guest hosts..." % len(blocked_hosts())) out = _write_hosts(dev, sysroot, new, env) written = _dump_hosts(dev, sysroot, env) if not has_block(written): @@ -232,7 +305,8 @@ def _p(msg: str) -> None: if not _es._fsck_ok(dev, env): raise RuntimeError("e2fsck reported errors after writing hosts") _write_state(instance_dir, True) - return ["Blocked %d ad/telemetry domains in the guest hosts file." % len(BLOCKLIST)] + return ["Blocked %d ad/tracker hostnames in the guest hosts file." + % len(blocked_hosts())] def remove(instance_dir: str, progress=None) -> list[str]: diff --git a/tests/test_ad_settings.py b/tests/test_ad_settings.py new file mode 100644 index 0000000..6940291 --- /dev/null +++ b/tests/test_ad_settings.py @@ -0,0 +1,219 @@ +"""Tests for ad_settings: which config keys get turned off, and the toggle. + +The fixture below is the real key set observed in bluestacks.conf on +5.22.250.1015 (BlueStacks 5, Android 13 instance), including the near-misses +that must NOT be touched -- bst.enable_adb_access (contains "ad"), +android_google_ad_id (an id, not a switch), the *_preference keys (they control +whether BlueStacks' own ad toggle is visible), and skipNowggLogin (inverted). +""" +import ad_settings as ads + +# Verbatim shape of the real config, trimmed to the relevant keys. +REAL_CONF = '''\ +bst.campaign_hash="5eb05192d5318a59eed61137da00d3e7" +bst.campaign_name="" +bst.enable_adb_access="1" +bst.enable_adb_remote_access="0" +bst.enable_android_ads_test_app="0" +bst.enable_auto_upload_recording="1" +bst.enable_boot_banner="1" +bst.enable_gamepad_detection="1" +bst.enable_programmatic_ads="1" +bst.feature.android_ads_stats="0" +bst.feature.auto_upload_nowgg_moments="1" +bst.feature.auto_upload_nowgg_recording="1" +bst.feature.bluestacksX="1" +bst.feature.nowbux="1" +bst.feature.nowgg_cloud_upload_enabled="1" +bst.feature.nowgg_login_popup="1" +bst.feature.programmatic_ads="1" +bst.feature.rooting="1" +bst.feature.send_internal_notification_stats="1" +bst.feature.send_notification_stats="0" +bst.feature.send_nowbux_login_boot_stats="1" +bst.feature.send_offer_stats="0" +bst.feature.send_programmatic_ads_boot_stats="1" +bst.feature.send_programmatic_ads_click_stats="1" +bst.feature.send_programmatic_ads_fill_stats="0" +bst.feature.show_boot_banner_preference="1" +bst.feature.show_gp_ads="1" +bst.feature.show_programmatic_ads_preference="1" +bst.feature.skipNowggLogin="1" +bst.instance.Tiramisu64.ads_app_package="com.uncube.gamevantage" +bst.instance.Tiramisu64.android_google_ad_id="b11c1080-79be-42f6-99b4-0ae2bdf109ee" +bst.instance.Tiramisu64.enable_root_access="1" +bst.instance.Tiramisu64.split_ad_enabled="0" +bst.nowgg_username="" +''' + + +def _conf(tmp_path, text=REAL_CONF): + p = tmp_path / "bluestacks.conf" + p.write_text(text, encoding="utf-8") + return str(p) + + +# --- the three safety gates ------------------------------------------------- + +def test_adb_keys_are_never_managed(): + """"adb" contains "ad" -- the classic false positive.""" + assert ads.is_managed("bst.enable_adb_access", "1") is False + assert ads.is_managed("bst.enable_adb_remote_access", "0") is False + + +def test_rooting_keys_are_never_managed(): + assert ads.is_managed("bst.feature.rooting", "1") is False + assert ads.is_managed("bst.instance.Foo.enable_root_access", "1") is False + + +def test_preference_keys_are_never_managed(): + """These control whether the user's own ad toggle is visible in Settings.""" + assert ads.is_managed("bst.feature.show_programmatic_ads_preference", "1") is False + assert ads.is_managed("bst.feature.show_boot_banner_preference", "1") is False + + +def test_inverted_semantics_keys_are_never_managed(): + """We only ever write "0", so a disable_/skip_ key would be flipped backwards.""" + assert ads.is_managed("bst.feature.skipNowggLogin", "1") is False + assert ads.is_managed("bst.feature.disable_ads", "1") is False + + +def test_non_boolean_values_are_never_managed(): + """The value gate keeps ids and strings out no matter how they're named.""" + assert ads.is_managed("bst.instance.X.android_google_ad_id", "b11c1080-79be") is False + assert ads.is_managed("bst.instance.X.ads_app_package", "com.uncube.gamevantage") is False + assert ads.is_managed("bst.instance.X.ads_display_time", "") is False + + +def test_real_ad_switches_are_managed(): + for key in ("bst.enable_programmatic_ads", + "bst.feature.programmatic_ads", + "bst.feature.show_gp_ads", + "bst.enable_boot_banner", + "bst.feature.nowbux", + "bst.feature.bluestacksX", + "bst.feature.send_programmatic_ads_boot_stats", + "bst.feature.send_internal_notification_stats", + "bst.feature.auto_upload_nowgg_moments", + "bst.enable_auto_upload_recording"): + assert ads.is_managed(key, "1") is True, key + + +def test_a_future_renamed_stats_key_is_still_caught(): + """The point of pattern discovery: no code change for a new build's keys.""" + assert ads.is_managed("bst.feature.send_brand_new_ad_stats", "1") is True + assert ads.is_managed("bst.feature.programmatic_ads_v2", "1") is True + + +# --- discovery -------------------------------------------------------------- + +def test_discover_finds_switches_and_excludes_the_near_misses(tmp_path): + found = ads.discover(_conf(tmp_path)) + assert "bst.enable_programmatic_ads" in found + assert "bst.feature.send_programmatic_ads_boot_stats" in found + # near misses stay out + for key in ("bst.enable_adb_access", + "bst.feature.rooting", + "bst.instance.Tiramisu64.enable_root_access", + "bst.feature.skipNowggLogin", + "bst.feature.show_programmatic_ads_preference", + "bst.instance.Tiramisu64.android_google_ad_id", + "bst.instance.Tiramisu64.ads_app_package", + "bst.campaign_hash"): + assert key not in found, key + + +def test_discover_reports_current_values(tmp_path): + found = ads.discover(_conf(tmp_path)) + assert found["bst.enable_programmatic_ads"] == "1" + assert found["bst.feature.send_offer_stats"] == "0" # already off, still managed + + +# --- apply / remove --------------------------------------------------------- + +def test_apply_turns_everything_off_and_status_reports_it(tmp_path): + conf = _conf(tmp_path) + assert ads.status(conf) is None + ads.apply(conf) + after = ads.discover(conf) + assert after and all(v == "0" for v in after.values()) + st = ads.status(conf) + assert st["ads_disabled"] is True + assert st["reverted"] == [] + assert set(st["off"]) == set(after) + + +def test_apply_leaves_unrelated_keys_untouched(tmp_path): + conf = _conf(tmp_path) + ads.apply(conf) + text = open(conf, encoding="utf-8").read() + assert 'bst.enable_adb_access="1"' in text + assert 'bst.feature.rooting="1"' in text + assert 'bst.instance.Tiramisu64.enable_root_access="1"' in text + assert 'bst.feature.skipNowggLogin="1"' in text + assert 'bst.instance.Tiramisu64.android_google_ad_id="b11c1080-79be-42f6-99b4-0ae2bdf109ee"' in text + + +def test_remove_restores_original_values_exactly(tmp_path): + conf = _conf(tmp_path) + before = open(conf, encoding="utf-8").read() + ads.apply(conf) + ads.remove(conf) + assert open(conf, encoding="utf-8").read() == before + assert ads.status(conf) is None + + +def test_apply_is_idempotent(tmp_path): + conf = _conf(tmp_path) + ads.apply(conf) + once = open(conf, encoding="utf-8").read() + ads.apply(conf) + assert open(conf, encoding="utf-8").read() == once + + +def test_reapply_after_bluestacks_reverts_a_key_keeps_true_originals(tmp_path): + """BlueStacks puts some keys back to "1" on start; re-applying must not + record "1" as the original and must not lose the real pre-change value.""" + conf = _conf(tmp_path) + ads.apply(conf) + # BlueStacks reverts one of the keys it's known to revert + import config_handler + config_handler.modify_config_file(conf, "bst.feature.programmatic_ads", "1") + st = ads.status(conf) + assert st["reverted"] == ["bst.feature.programmatic_ads"] + + ads.apply(conf) + assert ads.status(conf)["reverted"] == [] + # and a full removal still restores the genuine original, not the reverted value + ads.remove(conf) + text = open(conf, encoding="utf-8").read() + assert 'bst.feature.send_offer_stats="0"' in text # was already 0 originally + assert 'bst.feature.programmatic_ads="1"' in text # was 1 originally + + +def test_status_flags_switches_a_bluestacks_update_added(tmp_path): + conf = _conf(tmp_path) + ads.apply(conf) + import config_handler + config_handler.modify_config_file(conf, "bst.feature.send_shiny_new_stats", "1") + st = ads.status(conf) + assert st["unmanaged"] == ["bst.feature.send_shiny_new_stats"] + # re-applying adopts it + ads.apply(conf) + st = ads.status(conf) + assert st["unmanaged"] == [] + assert "bst.feature.send_shiny_new_stats" in st["off"] + + +def test_apply_on_a_config_with_no_switches_is_not_an_error(tmp_path): + conf = _conf(tmp_path, 'bst.enable_adb_access="1"\nbst.feature.rooting="1"\n') + msg = ads.apply(conf)[0] + assert "No ad/telemetry switches found" in msg + assert ads.status(conf) is None + + +def test_remove_without_apply_is_a_no_op(tmp_path): + conf = _conf(tmp_path) + before = open(conf, encoding="utf-8").read() + ads.remove(conf) + assert open(conf, encoding="utf-8").read() == before diff --git a/tests/test_privacy_page.py b/tests/test_privacy_page.py index d68b524..a02fd95 100644 --- a/tests/test_privacy_page.py +++ b/tests/test_privacy_page.py @@ -52,6 +52,84 @@ def test_busy_disables_visible_button(qtbot): assert page.unblock_button.isEnabled() is True +# --- global BlueStacks ad/telemetry switches -------------------------------- + +_ADS_OFF = {"ads_disabled": True, "keys": 23, "off": ["a"], "reverted": [], + "unmanaged": [], "locked": False} + + +def test_ads_on_offers_turn_off_only(qtbot): + page = _page(qtbot, {}) + page.set_ad_status(None, 23) + assert page.ads_off_button.isVisibleTo(page) is True + assert page.ads_restore_button.isVisibleTo(page) is False + assert "are ON" in page.ads_status_label.text() + assert "23" in page.ads_status_label.text() + + +def test_ads_off_offers_restore_only(qtbot): + page = _page(qtbot, {}) + page.set_ad_status(_ADS_OFF, 23) + assert page.ads_off_button.isVisibleTo(page) is False + assert page.ads_restore_button.isVisibleTo(page) is True + assert "are OFF" in page.ads_status_label.text() + + +def test_reverted_switches_offer_turn_off_again(qtbot): + """BlueStacks puts some keys back on every start; the user needs a re-apply.""" + page = _page(qtbot, {}) + page.set_ad_status(dict(_ADS_OFF, reverted=["bst.feature.nowbux"]), 23) + assert page.ads_off_button.isVisibleTo(page) is True + assert page.ads_restore_button.isVisibleTo(page) is True + assert "turned 1 back on" in page.ads_status_label.text() + + +def test_switches_added_by_a_bluestacks_update_offer_reapply(qtbot): + page = _page(qtbot, {}) + page.set_ad_status(dict(_ADS_OFF, unmanaged=["bst.feature.send_new_stats"]), 24) + assert page.ads_off_button.isVisibleTo(page) is True + assert "added 1 new switch" in page.ads_status_label.text() + + +def test_no_switches_in_this_build_hides_the_control(qtbot): + page = _page(qtbot, {}) + page.set_ad_status(None, 0) + assert page.ads_off_button.isVisibleTo(page) is False + assert "no switches found" in page.ads_status_label.text() + + +def test_ads_buttons_emit_signals(qtbot): + page = _page(qtbot, {}) + page.set_ad_status(None, 23) + with qtbot.waitSignal(page.ads_off_requested, timeout=1000): + qtbot.mouseClick(page.ads_off_button, Qt.LeftButton) + page.set_ad_status(_ADS_OFF, 23) + with qtbot.waitSignal(page.ads_restore_requested, timeout=1000): + qtbot.mouseClick(page.ads_restore_button, Qt.LeftButton) + + +def test_lock_checkbox_reflects_state_without_emitting(qtbot): + """set_ad_status() syncs the box to reality; only a user click should fire.""" + page = _page(qtbot, {}) + fired = [] + page.ads_lock_toggled.connect(fired.append) + page.set_ad_status(dict(_ADS_OFF, locked=True), 23) + assert page.ads_lock_check.isChecked() is True + assert fired == [] + page.ads_lock_check.setChecked(False) + assert fired == [False] + + +def test_busy_disables_ads_buttons(qtbot): + page = _page(qtbot, {}) + page.set_ad_status(None, 23) + assert page.ads_off_button.isEnabled() is True + page.set_busy(True) + assert page.ads_off_button.isEnabled() is False + page.set_busy(False) + assert page.ads_off_button.isEnabled() is True + + def test_buttons_emit_signals(qtbot): page = _page(qtbot, {"A (Normal)": None}) page._radios["A (Normal)"].setChecked(True) diff --git a/tests/test_telemetry_block.py b/tests/test_telemetry_block.py index d18bd77..066ed62 100644 --- a/tests/test_telemetry_block.py +++ b/tests/test_telemetry_block.py @@ -9,10 +9,36 @@ def test_blocklist_is_nonempty_lowercase_domains(): assert tb.BLOCKLIST - for d in tb.BLOCKLIST: + for d in tb.BLOCKLIST + tb.HOST_BLOCKLIST: assert d == d.lower() and " " not in d and "/" not in d - # the ad exchange caught in the live capture is in the list - assert "rtbhouse.net" in tb.BLOCKLIST + + +def test_dead_rtbhouse_net_entry_is_gone(): + """It never resolved; the live endpoint is esp.rtbhouse.com. + + Compared by equality rather than ``in``: an entry has to be its own exact + hostname, so a substring sitting inside some longer domain cannot satisfy + this. (It also keeps CodeQL's incomplete-URL-sanitization rule quiet, which + cannot tell tuple membership from a substring check on a URL.) + """ + assert not any(d == "rtbhouse.net" for d in tb.BLOCKLIST) + assert any(d == "rtbhouse.com" for d in tb.BLOCKLIST) + assert any(h == "esp.rtbhouse.com" for h in tb.HOST_BLOCKLIST) + + +def test_subdomains_are_listed_explicitly_because_hosts_cannot_wildcard(): + """An apex entry does not cover subdomains, and ad traffic is subdomains.""" + hosts = tb.blocked_hosts() + for fqdn in ("cm.g.doubleclick.net", + "pagead2.googlesyndication.com", + "api.w.inmobi.com", + "esp.rtbhouse.com"): + assert fqdn in hosts, fqdn + + +def test_blocked_hosts_has_no_duplicates(): + hosts = tb.blocked_hosts() + assert len(hosts) == len(set(hosts)) def test_block_text_markers_and_null_routes(): @@ -21,6 +47,8 @@ def test_block_text_markers_and_null_routes(): for d in tb.BLOCKLIST: assert "0.0.0.0 %s" % d in text assert "0.0.0.0 www.%s" % d in text # www alias too + for h in tb.HOST_BLOCKLIST: + assert "0.0.0.0 %s" % h in text assert tb.has_block(text) is True @@ -32,7 +60,7 @@ def test_strip_block_removes_only_the_marked_section(): assert tb.has_block(stripped) is False # the user's real hosts entries survive assert "127.0.0.1\tlocalhost" in stripped - assert "rtbhouse.net" not in stripped + assert "doubleclick.net" not in stripped def test_apply_is_idempotent_no_double_block(): @@ -53,6 +81,6 @@ def test_state_sidecar_roundtrip(tmp_path): tb._write_state(str(tmp_path), True) st = tb.status(str(tmp_path)) assert st["telemetry_block"] is True - assert st["domains"] == len(tb.BLOCKLIST) + assert st["domains"] == len(tb.blocked_hosts()) tb._write_state(str(tmp_path), False) assert tb.status(str(tmp_path)) is None diff --git a/views/main_window.py b/views/main_window.py index 661a57d..a0c34b3 100644 --- a/views/main_window.py +++ b/views/main_window.py @@ -196,6 +196,9 @@ def init_ui(self) -> None: self.magisk_page.install_lsposed_requested.connect(self.magisk_controller.handle_install_lsposed) self.privacy_page.block_requested.connect(self.privacy_controller.handle_block) self.privacy_page.unblock_requested.connect(self.privacy_controller.handle_unblock) + self.privacy_page.ads_off_requested.connect(self.privacy_controller.handle_ads_off) + self.privacy_page.ads_restore_requested.connect(self.privacy_controller.handle_ads_restore) + self.privacy_page.ads_lock_toggled.connect(self.privacy_controller.handle_ads_lock) self.setMinimumWidth(700) self.setMinimumHeight(480) @@ -633,7 +636,8 @@ def _action_buttons(self): self.magisk_page.install_button, self.magisk_page.uninstall_button, self.magisk_page.manager_button, self.magisk_page.remove_manager_button, self.magisk_page.rezygisk_button, self.magisk_page.lsposed_button, - self.privacy_page.block_button, self.privacy_page.unblock_button] + self.privacy_page.block_button, self.privacy_page.unblock_button, + self.privacy_page.ads_off_button, self.privacy_page.ads_restore_button] def _set_busy(self, busy): for b in self._action_buttons(): diff --git a/views/privacy_controller.py b/views/privacy_controller.py index 8ad4914..65dac48 100644 --- a/views/privacy_controller.py +++ b/views/privacy_controller.py @@ -1,16 +1,24 @@ -"""Privacy tab controller: owns the handler logic for blocking/unblocking -ad and telemetry domains in an instance's guest hosts file. +"""Privacy tab controller: BlueStacks' own ad/telemetry switches (global) and +the in-guest tracker block (per instance). Extraction of responsibility out of ``MainWindow``, same pattern as ``MagiskController`` -- constructed with the owning ``MainWindow`` and reaches back into it for the confirm dialog, the async job runner, and the Privacy page widget. + +The two controls are deliberately separate. The ad switches are what actually +stop BlueStacks' advertising (it is served by the Windows player, so no guest +edit can reach it) and they live in the one global config. The hosts block only +reaches apps running inside the emulator, and because it modifies the guest +system image it needs the engine patch -- without it BlueStacks detects the +tampering and shuts the instance down mid-session. """ from __future__ import annotations from PyQt5.QtCore import QThread from PyQt5.QtWidgets import QMessageBox +import ad_settings import constants import instance_handler import telemetry_block @@ -20,52 +28,164 @@ class PrivacyController: def __init__(self, window): self._window = window + # --- shared ------------------------------------------------------------- + + def _config_path(self): + """BlueStacks' single global config. Any instance points at the same one.""" + for data in self._window.instance_data.values(): + path = data.get("config_path") + if path: + return path + return None + def refresh_statuses(self) -> None: - """Fill the Privacy tab with each instance's current telemetry-block state.""" + """Fill the Privacy tab: global ad-switch state + per-instance blocks.""" w = self._window statuses = {uid: telemetry_block.status(data["data_path"]) for uid, data in w.instance_data.items()} w.privacy_page.set_instances(statuses) - def handle_block(self) -> None: + config_path = self._config_path() + if not config_path: + w.privacy_page.set_ad_status(None, 0) + return + try: + total = len(ad_settings.discover(config_path)) + w.privacy_page.set_ad_status(ad_settings.status(config_path), total) + except OSError: + w.privacy_page.set_ad_status(None, 0) + + # --- global: BlueStacks' own ad/telemetry switches ----------------------- + + def handle_ads_off(self) -> None: + w = self._window + config_path = self._config_path() + if not config_path: + QMessageBox.information(w, "No BlueStacks config found", + "BlueStacks and its instances need to be " + "detected first.") + return + if not w._confirm( + "Turn off ads & telemetry", + "Turn off BlueStacks' own ad and telemetry switches?", + "

Turns off every advertising, promo and stats-upload switch " + "found in bluestacks.conf. All BlueStacks processes " + "close first, because BlueStacks rewrites that file on exit.

" + "

This is BlueStacks' one shared config, so it applies to " + "every instance, not just one.

" + "

Fully reversible: each switch's original value is recorded, " + "and Restore BlueStacks defaults puts them all back.

"): + return + + def job(progress): + progress("Closing BlueStacks...", 0) + instance_handler.terminate_bluestacks() + QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) + results = ad_settings.apply(config_path, progress=lambda m: progress(m, -1)) + return results[-1] if results else "Ads and telemetry turned off." + + w._run_async(job, "Turning off BlueStacks ads & telemetry...") + + def handle_ads_restore(self) -> None: + w = self._window + config_path = self._config_path() + if not config_path: + return + if not w._confirm( + "Restore BlueStacks defaults", + "Put BlueStacks' ad and telemetry switches back?", + "

Restores every switch to the value it had before this tool " + "changed it. All BlueStacks processes close first.

"): + return + + def job(progress): + progress("Closing BlueStacks...", 0) + instance_handler.terminate_bluestacks() + QThread.msleep(constants.PROCESS_TERMINATION_WAIT_MS) + results = ad_settings.remove(config_path, progress=lambda m: progress(m, -1)) + return results[-1] if results else "BlueStacks ad settings restored." + + w._run_async(job, "Restoring BlueStacks ad settings...") + + def handle_ads_lock(self, checked: bool) -> None: + """Pin/unpin bluestacks.conf read-only so the switches can't be reverted.""" + w = self._window + config_path = self._config_path() + if not config_path: + return + try: + if checked: + ad_settings.lock(config_path) + else: + ad_settings.unlock(config_path) + except OSError as exc: + QMessageBox.warning(w, "Could not change the config lock", str(exc)) + finally: + self.refresh_statuses() + + # --- per instance: guest hosts block ------------------------------------- + + def _selected_instance(self): w = self._window uid = w.privacy_page.selected_instance_id() if not uid or uid not in w.instance_data: QMessageBox.information(w, "No instance selected", "Select an instance on the Privacy tab first.") + return None, None + return uid, w.instance_data[uid] + + def handle_block(self) -> None: + w = self._window + uid, instance = self._selected_instance() + if instance is None: + return + # Editing the guest hosts file modifies the system image. On an unpatched + # engine BlueStacks detects that and shuts the instance down mid-session + # ("illegally tampered") -- root isn't required to trip it, any change is. + if instance.get("patch_mode") and w._engine_state() != "patched": + QMessageBox.warning( + w, "Patch the engine first", + "Blocking trackers edits the guest system image, and BlueStacks " + "shuts down an instance whose system image was modified unless " + "the engine is patched. Patch it from the Dashboard, then try " + "again.\n\nTo stop BlueStacks' own ads you don't need this at " + "all. Use \"Turn off ads & telemetry\" above.") return if not w._confirm( - "Block ads & telemetry", - "Block ad/telemetry domains in %s?" % uid, + "Block in-guest trackers", + "Block tracker domains inside %s?" % uid, "

Null-routes ad, tracker, and analytics domains in the guest " "hosts file while the instance is shut down (all BlueStacks " - "processes close first). Emulator-only, and reversible.

"): + "processes close first). Emulator-only, and reversible.

" + "

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 " + "Android version, so this applies to all of them.

"): return - data_path = w.instance_data[uid]["data_path"] + data_path = instance["data_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=lambda m: progress(m, -1)) - return results[-1] if results else "Telemetry blocked." + return results[-1] if results else "Trackers blocked." - w._run_async(job, "Blocking ads/telemetry in %s..." % uid) + w._run_async(job, "Blocking trackers in %s..." % uid) def handle_unblock(self) -> None: w = self._window - uid = w.privacy_page.selected_instance_id() - if not uid or uid not in w.instance_data: - QMessageBox.information(w, "No instance selected", - "Select an instance on the Privacy tab first.") + uid, instance = self._selected_instance() + if instance is None: return if not w._confirm( - "Remove telemetry block", + "Remove tracker block", "Restore the original guest hosts file for %s?" % uid, - "

Removes the ad/telemetry block, while the instance is shut " - "down (all BlueStacks processes close first).

"): + "

Removes the in-guest tracker block, while the instance is " + "shut down (all BlueStacks processes close first).

"): return - data_path = w.instance_data[uid]["data_path"] + data_path = instance["data_path"] def job(progress): progress("Closing BlueStacks...", 0) diff --git a/views/privacy_page.py b/views/privacy_page.py index 86e0203..24354dc 100644 --- a/views/privacy_page.py +++ b/views/privacy_page.py @@ -1,73 +1,180 @@ -"""Privacy page: block ad/telemetry domains in an instance's guest hosts file. - -Offline + reversible, per Android version (Root.vhd is shared across instances -of a version). Emulator-only -- never touches the user's Windows hosts. +"""Privacy page: two independent controls, deliberately kept apart because they +have very different reach. + +**BlueStacks ads & telemetry** (top, global) flips BlueStacks' own switches in +``bluestacks.conf``. This is the one that actually stops the ads: they are +served by ``HD-Player.exe`` on Windows, and a live capture measured the player's +ad/tracker endpoints going 40 -> 0 with these switches off. It applies to every +instance, because that config file is global. + +**In-guest tracker block** (bottom, per instance) null-routes tracker domains in +one Android version's guest hosts file. It reaches apps running *inside* the +emulator -- it cannot touch BlueStacks' own ads, which never go through the +guest at all. It also modifies the system image, so it needs the engine patch. """ from __future__ import annotations from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QRadioButton, QButtonGroup, - QPushButton, + QPushButton, QGroupBox, QCheckBox, ) class PrivacyPage(QWidget): + # global BlueStacks ad/telemetry switches + ads_off_requested = pyqtSignal() + ads_restore_requested = pyqtSignal() + ads_lock_toggled = pyqtSignal(bool) + # per-instance guest hosts block block_requested = pyqtSignal() unblock_requested = pyqtSignal() _EMPTY_TEXT = ("No instances detected yet. They appear here once BlueStacks " "and its instances are found.") - _PROMPT_TEXT = "Select an instance to see its telemetry-block status." + _PROMPT_TEXT = "Select an instance to see its tracker-block status." + _ADS_NOTE = ( + "Turns off BlueStacks' own advertising and stats-upload switches in its " + "config. This is what actually stops the ads, and it applies to every " + "instance. Close BlueStacks first, since it rewrites the file on exit." + ) _NOTE = ( - "Null-routes ad, tracker, and analytics domains in the guest hosts file, " - "offline. Emulator only (never your Windows machine), reversible, and it " - "never touches Google Play, GMS, or an app's own servers." + "Null-routes tracker domains in the guest hosts file, offline, for apps " + "running inside the emulator. Emulator only (never your Windows machine) " + "and reversible. It does not affect BlueStacks' own ads, which are served " + "by the Windows player: use the control above for those." ) def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) - layout.addWidget(QLabel("1. Choose an instance")) + # --- global: BlueStacks' own ad/telemetry switches ------------------- + ads_box = QGroupBox("BlueStacks ads && telemetry (all instances)") + ads_layout = QVBoxLayout(ads_box) + + self.ads_status_label = QLabel("Checking BlueStacks ad settings...") + self.ads_status_label.setWordWrap(True) + self.ads_status_label.setObjectName("PrivacyAdsStatus") + ads_layout.addWidget(self.ads_status_label) + + ads_row = QHBoxLayout() + self.ads_off_button = QPushButton("Turn off ads && telemetry") + self.ads_off_button.setToolTip( + "Turns off every ad, promo and stats-upload switch found in " + "bluestacks.conf. Closes BlueStacks first; fully reversible.") + self.ads_off_button.clicked.connect(self.ads_off_requested.emit) + self.ads_restore_button = QPushButton("Restore BlueStacks defaults") + self.ads_restore_button.setToolTip( + "Puts every switch back to the value it had before this tool " + "changed it.") + self.ads_restore_button.clicked.connect(self.ads_restore_requested.emit) + ads_row.addWidget(self.ads_off_button) + ads_row.addWidget(self.ads_restore_button) + ads_layout.addLayout(ads_row) + + self.ads_lock_check = QCheckBox( + "Pin the config so BlueStacks cannot turn them back on") + self.ads_lock_check.setToolTip( + "Marks bluestacks.conf read-only. BlueStacks puts some switches back " + "on every start, mostly the stats beacons. Pinning holds them, but " + "it also stops BlueStacks saving its own settings changes.") + self.ads_lock_check.toggled.connect(self._on_lock_toggled) + ads_layout.addWidget(self.ads_lock_check) + + ads_note = QLabel(self._ADS_NOTE) + ads_note.setWordWrap(True) + ads_note.setObjectName("PrivacyNote") + ads_layout.addWidget(ads_note) + layout.addWidget(ads_box) + + # --- per instance: guest hosts block --------------------------------- + guest_box = QGroupBox("In-guest tracker block (one Android version)") + guest_layout = QVBoxLayout(guest_box) + + guest_layout.addWidget(QLabel("1. Choose an instance")) self.instance_group = QButtonGroup(self) self.instance_group.setExclusive(True) self._instance_layout = QVBoxLayout() - layout.addLayout(self._instance_layout) + guest_layout.addLayout(self._instance_layout) self.no_instances_label = QLabel(self._EMPTY_TEXT) self.no_instances_label.setWordWrap(True) self.no_instances_label.hide() - layout.addWidget(self.no_instances_label) + guest_layout.addWidget(self.no_instances_label) self.status_label = QLabel(self._PROMPT_TEXT) self.status_label.setWordWrap(True) self.status_label.setObjectName("PrivacyStatus") - layout.addWidget(self.status_label) + guest_layout.addWidget(self.status_label) button_row = QHBoxLayout() - self.block_button = QPushButton("Block ads & telemetry") + self.block_button = QPushButton("Block in-guest trackers") self.block_button.setToolTip( - "Writes the block into the guest hosts file offline. Closes BlueStacks " - "first; reversible.") + "Writes the block into the guest hosts file offline. Closes " + "BlueStacks first; reversible. Needs the engine patch.") self.block_button.clicked.connect(self.block_requested.emit) self.unblock_button = QPushButton("Remove block") self.unblock_button.setToolTip("Restores the guest hosts file offline.") self.unblock_button.clicked.connect(self.unblock_requested.emit) button_row.addWidget(self.block_button) button_row.addWidget(self.unblock_button) - layout.addLayout(button_row) + guest_layout.addLayout(button_row) note = QLabel(self._NOTE) note.setWordWrap(True) note.setObjectName("PrivacyNote") - layout.addWidget(note) + guest_layout.addWidget(note) + layout.addWidget(guest_box) layout.addStretch(1) self._radios: dict[str, QRadioButton] = {} self._statuses: dict[str, dict | None] = {} + self._ads_status: dict | None = None + self._ads_total = 0 self._busy = False + self._emit_lock = True self._update() + # --- global ad settings -------------------------------------------------- + + def _on_lock_toggled(self, checked: bool) -> None: + # set_ad_status() drives the checkbox to match reality; only a real user + # click should reach the controller. + if self._emit_lock: + self.ads_lock_toggled.emit(checked) + + def set_ad_status(self, status: dict | None, total_switches: int = 0) -> None: + """``status`` is ad_settings.status() (None when not applied); + ``total_switches`` is how many switches the current config exposes.""" + self._ads_status = status + self._ads_total = total_switches + self._emit_lock = False + self.ads_lock_check.setChecked(bool(status and status.get("locked"))) + self._emit_lock = True + self._update() + + def _ads_status_text(self) -> str: + st = self._ads_status + if not st: + if not self._ads_total: + return ("BlueStacks ad settings: no switches found in this build. " + "Nothing to turn off here.") + return ("BlueStacks ads and telemetry are ON (%d switches available)." + % self._ads_total) + reverted = st.get("reverted") or [] + text = "BlueStacks ads and telemetry are OFF (%d switches)." % st.get("keys", 0) + if reverted: + text += (" BlueStacks has turned %d back on since, mostly stats " + "beacons. Turn them off again, or pin the config to hold them." + % len(reverted)) + unmanaged = st.get("unmanaged") or [] + if unmanaged: + text += (" This BlueStacks build added %d new switch(es); turning off " + "again will cover them." % len(unmanaged)) + return text + + # --- per-instance guest block ------------------------------------------- + def set_busy(self, busy: bool) -> None: self._busy = busy self._update() @@ -111,16 +218,30 @@ def _status_text(self, uid) -> str: st = self._statuses.get(uid) if not st: return "%s: no telemetry block applied." % uid - return "%s: blocking %s ad/telemetry domains." % (uid, st.get("domains", "?")) + return "%s: blocking %s tracker domains in-guest." % (uid, st.get("domains", "?")) def _update(self, *_args) -> None: + busy = self._busy + + applied = bool(self._ads_status) + has_switches = bool(self._ads_total) + # Offer "turn off" whenever anything is still on (including after + # BlueStacks reverts a key), and "restore" once we've recorded originals. + reverted = bool(applied and self._ads_status.get("reverted")) + unmanaged = bool(applied and self._ads_status.get("unmanaged")) + show_off = has_switches and (not applied or reverted or unmanaged) + self.ads_status_label.setText(self._ads_status_text()) + self.ads_off_button.setVisible(show_off) + self.ads_off_button.setEnabled(show_off and not busy) + self.ads_restore_button.setVisible(applied) + self.ads_restore_button.setEnabled(applied and not busy) + self.ads_lock_check.setEnabled(has_switches and not busy) + uid = self.selected_instance_id() blocked = bool(uid and self._statuses.get(uid)) self.status_label.setText(self._status_text(uid)) - # Block when an instance is chosen and it's not blocked; Remove once it is. show_block = bool(uid) and not blocked self.block_button.setVisible(show_block) self.unblock_button.setVisible(blocked) - busy = self._busy self.block_button.setEnabled(show_block and not busy) self.unblock_button.setEnabled(blocked and not busy)