From b8f5b29f6d0b29fd5b6206afd3ba040c1fd48283 Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Thu, 23 Jul 2026 18:55:35 -0500 Subject: [PATCH 1/3] Rebuild Privacy around BlueStacks' own ad switches The guest hosts block could never stop BlueStacks' advertising. Those ads are served by HD-Player.exe on Windows, not by the Android guest: a live capture on 5.22.250.1015 showed the player holding connections to googlesyndication, inmobi, rubiconproject and adnxs while the guest was completely powered off. Applying the block left the player's ad endpoints unchanged (40 before, 40 after). BlueStacks ships explicit switches for this in bluestacks.conf, and turning them off measured 40 endpoints to 0 on the same rig, with only Play, GMS, Firebase and fonts left. Add ad_settings.py to drive those switches, and make the Privacy tab a toggle for them. To survive version updates it discovers keys by concept pattern (programmatic_ads, send_*_stats, auto_upload) instead of a fixed list, so a renamed or newly added switch is still covered. It found 23 switches where a hand-written list had 19. Three gates keep that safe: the key must match a concept pattern, must not match an exclusion (root, adb, *_preference, and the inverted disable/skip keys), and must be a boolean 0/1 value, which is what keeps ids such as android_google_ad_id out of scope no matter how they are named. Originals are recorded per key, verified live as a byte-identical restore of the real config. BlueStacks selectively reverts keys on start: the bst.enable_* ones stick while most bst.feature.* are put back, including four stats beacons. status() reports that drift so the tab can offer a re-apply, and an optional read-only pin holds them (verified: 23/23 still off after a real start, instance boots normally). Also fix the hosts block itself, which stays as the in-guest tracker control: - Gate it on a patched engine. Editing the guest system image trips BlueStacks' tamper check and shuts the instance down mid-session; root is not required, any modification does it. The Magisk tab already gated on this, Privacy did not. - Enumerate subdomains explicitly. A hosts file has no wildcards, so an entry for doubleclick.net did nothing for cm.g.doubleclick.net or pagead2.googlesyndication.com, which is most real ad traffic. - Drop rtbhouse.net, which does not resolve, for rtbhouse.com and the esp.rtbhouse.com endpoint actually seen. - Stop the docstring claiming it blocks the player's ads. 247 tests green. --- README.md | 10 +- ad_settings.py | 308 ++++++++++++++++++++++++++++++++++ telemetry_block.py | 112 ++++++++++--- tests/test_ad_settings.py | 219 ++++++++++++++++++++++++ tests/test_privacy_page.py | 78 +++++++++ tests/test_telemetry_block.py | 32 +++- views/main_window.py | 6 +- views/privacy_controller.py | 156 +++++++++++++++-- views/privacy_page.py | 163 +++++++++++++++--- 9 files changed, 1016 insertions(+), 68 deletions(-) create mode 100644 ad_settings.py create mode 100644 tests/test_ad_settings.py diff --git a/README.md b/README.md index 169efee..0a5d6c1 100644 --- a/README.md +++ b/README.md @@ -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. @@ -245,7 +245,8 @@ Both patches are located by byte signature, not hard-coded offsets, so they surv ## 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 +- **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 what actually stops the ads: they are served by `HD-Player.exe` on Windows, so no change inside Android can reach them. Measured on 5.22.250.1015, the player's ad/tracker endpoints went from 40 to 0. The switches are found by pattern rather than a fixed list, so a BlueStacks 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 - **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 @@ -271,7 +272,7 @@ Both patches are located by byte signature, not hard-coded offsets, so they surv - `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 + - `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`) @@ -288,7 +289,8 @@ Both patches are located by byte signature, not hard-coded offsets, so they surv - `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` +- `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 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..158fddf 100644 --- a/tests/test_telemetry_block.py +++ b/tests/test_telemetry_block.py @@ -9,10 +9,30 @@ 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.""" + assert "rtbhouse.net" not in tb.BLOCKLIST + assert "rtbhouse.com" in tb.BLOCKLIST + assert "esp.rtbhouse.com" 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 +41,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 +54,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 +75,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) From 9f0c24ea0ee7407397ad35a75a99e6e14f27df4c Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Thu, 23 Jul 2026 19:17:41 -0500 Subject: [PATCH 2/3] README copy pass: punctuation and phrasing Definition-list bullets in Features and Project Structure used a hyphen where a colon belongs, so all 42 now read as labels. Fixes the comma splices left behind by the earlier wording pass, pairing clauses with semicolons and expanding with colons. Tightens a few run-ons, drops the exclamation in Contributing, and rewrites the Modules-tab and companion-guide paragraphs that had run together. Version range reads "5.20.x to 5.21.x" instead of using a dash. No content changes. --- README.md | 118 +++++++++++++++++++++++++++--------------------------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 0a5d6c1..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.** @@ -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,60 +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** (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 what actually stops the ads: they are served by `HD-Player.exe` on Windows, so no change inside Android can reach them. Measured on 5.22.250.1015, the player's ad/tracker endpoints went from 40 to 0. The switches are found by pattern rather than a fixed list, so a BlueStacks 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 -- **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` - 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 +- `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 @@ -311,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 From 6af1376bdb45ced6e45caeb9675ea14b6e4dc538 Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Thu, 23 Jul 2026 19:35:06 -0500 Subject: [PATCH 3/3] Assert blocklist entries by equality, not membership A blocklist entry has to be its own exact hostname, so comparing with == rather than `in` is the stricter check: a substring sitting inside some longer domain can no longer satisfy the test. This also clears two high-severity CodeQL incomplete-URL-sanitization alerts, which flag "domain" in x without being able to tell tuple membership from a substring check on a URL. --- tests/test_telemetry_block.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_telemetry_block.py b/tests/test_telemetry_block.py index 158fddf..066ed62 100644 --- a/tests/test_telemetry_block.py +++ b/tests/test_telemetry_block.py @@ -14,10 +14,16 @@ def test_blocklist_is_nonempty_lowercase_domains(): def test_dead_rtbhouse_net_entry_is_gone(): - """It never resolved; the live endpoint is esp.rtbhouse.com.""" - assert "rtbhouse.net" not in tb.BLOCKLIST - assert "rtbhouse.com" in tb.BLOCKLIST - assert "esp.rtbhouse.com" in tb.HOST_BLOCKLIST + """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():