diff --git a/README.md b/README.md
index 169efee..7ec32f1 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Turns off every advertising, promo and stats-upload switch "
+ "found in 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. Restores every switch to the value it had before this tool "
+ "changed it. All BlueStacks processes close first. 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.
-**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 bluestacks.conf. All BlueStacks processes "
+ "close first, because BlueStacks rewrites that file on exit.
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)