Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions adb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,24 @@ def _resolve_serial(adb_exe: str, port: Optional[int], runner: Runner) -> str:
return devices[0]


def _ensure_su_policy(adb_exe: str, serial: str, runner: Runner) -> None:
"""Persist a permanent 'allow' Superuser policy for the shell uid (2000) so
magiskd stops prompting -- and, if the prompt isn't tapped fast enough,
auto-DENYING -- ``su`` during a flash. The very first ``su`` here may still
pop the grant once; after it's recorded, every later flash is automatic
(this is the "flip auto-grant before flashing" step, done for you).

Best-effort: any failure is swallowed so it never aborts the flash itself.
"""
sql = ("REPLACE INTO policies (uid,policy,until,logging,notification) "
"VALUES(2000,2,0,1,1)") # policy 2 = allow, until 0 = forever
Comment on lines +136 to +137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Magisk's Superuser database (policies table), the policy values are defined as:

  • 0 = Deny (POLICY_DENY)
  • 1 = Allow (POLICY_ALLOW)
  • 2 = Ask (POLICY_ASK)

By setting the policy value to 2, you are configuring it to "Ask" (prompt), which means subsequent su commands will still trigger a prompt. To pre-grant root access (auto-allow) for the shell UID (2000), the policy value should be set to 1.

Suggested change
sql = ("REPLACE INTO policies (uid,policy,until,logging,notification) "
"VALUES(2000,2,0,1,1)") # policy 2 = allow, until 0 = forever
sql = ("REPLACE INTO policies (uid,policy,until,logging,notification) "
"VALUES(2000,1,0,1,1)") # policy 1 = allow, until 0 = forever

try:
runner([adb_exe, "-s", serial, "shell", "su", "-c",
'magisk --sqlite "%s"' % sql])
except Exception: # noqa: BLE001 - a pre-grant failure must not stop the flash
logger.debug("ensure_su_policy failed (non-fatal)", exc_info=True)


def install_module(adb_exe: str, port: Optional[int], local_zip: str,
progress: Optional[Callable[[str], None]] = None,
runner: Runner = _run) -> str:
Expand All @@ -146,6 +164,11 @@ def _p(msg):
_p("Connecting to the instance...")
serial = _resolve_serial(adb_exe, port, runner)

# Pre-authorize shell root so the flash isn't auto-denied. The first flash
# of a fresh install may still prompt once (tap Grant); after that it sticks.
_p("Authorizing root (tap Grant in the instance if a prompt appears)...")
_ensure_su_policy(adb_exe, serial, runner)

tmp = "/data/local/tmp/" + name
_p("Pushing %s..." % name)
cp = runner([adb_exe, "-s", serial, "push", local_zip, tmp])
Expand Down
29 changes: 29 additions & 0 deletions instance_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import os
import glob
import logging
import subprocess
import time
import psutil


Expand All @@ -12,6 +14,33 @@
logger = logging.getLogger(__name__)


def launch_instance(install_dir: str, instance_name: str) -> None:
"""Start a specific BlueStacks instance (``HD-Player.exe --instance <name>``).

Raises with an actionable message if the player exe isn't found.
"""
exe = os.path.join(install_dir, "HD-Player.exe")
if not os.path.isfile(exe):
raise RuntimeError("HD-Player.exe not found in %s" % install_dir)
logger.info("Launching instance %s via %s", instance_name, exe)
subprocess.Popen([exe, "--instance", instance_name], close_fds=True)


def restart_instance(install_dir: str, instance_name: str,
wait_ms: int = 2500) -> None:
"""Kill all BlueStacks processes, then relaunch ``instance_name``.

This is the reliable "reboot" on BlueStacks: ``adb reboot`` does not cleanly
restart an instance, but a full process kill + relaunch does (and it clears
the state that can leave an instance unbootable right after flashing
modules). Terminating hits every BlueStacks process, so only the requested
instance comes back.
"""
terminate_bluestacks()
time.sleep(max(0, wait_ms) / 1000.0) # let processes + disk locks release
launch_instance(install_dir, instance_name)


def modify_instance_files(instance_path: str, new_mode: str) -> None:
"""
Modifies the 'Type' attribute in .bstk files within an instance
Expand Down
28 changes: 27 additions & 1 deletion tests/test_adb_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@

import pytest

from adb_handler import MANAGER_PACKAGE, install_manager, uninstall_manager
from adb_handler import (
MANAGER_PACKAGE, install_manager, install_module, uninstall_manager,
)


def _cp(stdout="", stderr="", rc=0):
Expand Down Expand Up @@ -86,6 +88,30 @@ def handle(cmd):
install_manager("adb", 5555, apk, runner=_runner(handle))


def test_install_module_preauthorizes_shell_su_before_flashing(tmp_path):
"""The flash first writes an allow policy for the shell uid so magiskd
doesn't auto-deny the su during the flash."""
zip_path = tmp_path / "mod.zip"
zip_path.write_bytes(b"PK\x03\x04mod")

def handle(cmd):
if cmd[1] == "connect":
return _cp("connected to 127.0.0.1:5555")
if "install-module" in " ".join(cmd):
return _cp("Success")
return _cp() # sqlite pre-grant, push, rm

runner = _runner(handle)
install_module("adb", 5555, str(zip_path), runner=runner)

joined = [" ".join(c) for c in runner.calls]
# a permanent allow policy for the shell uid (2000) was set before the flash
policy = next(i for i, c in enumerate(joined)
if "magisk --sqlite" in c and "policies" in c and "2000,2,0" in c)
Comment on lines +109 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update the test assertion to expect the correct policy value of 1 (POLICY_ALLOW) instead of 2 (POLICY_ASK).

Suggested change
policy = next(i for i, c in enumerate(joined)
if "magisk --sqlite" in c and "policies" in c and "2000,2,0" in c)
policy = next(i for i, c in enumerate(joined)
if "magisk --sqlite" in c and "policies" in c and "2000,1,0" in c)

flash = next(i for i, c in enumerate(joined) if "install-module" in c)
assert policy < flash


def test_uninstall_manager_success(tmp_path):
def handle(cmd):
if cmd[1] == "connect":
Expand Down
32 changes: 32 additions & 0 deletions tests/test_instance_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest

import instance_handler as ih


def test_launch_instance_starts_hd_player_with_instance(tmp_path, monkeypatch):
exe = tmp_path / "HD-Player.exe"
exe.write_bytes(b"x")
calls = []
monkeypatch.setattr(ih.subprocess, "Popen", lambda args, **k: calls.append(args))

ih.launch_instance(str(tmp_path), "Tiramisu64")

assert calls and calls[0][:3] == [str(exe), "--instance", "Tiramisu64"]


def test_launch_instance_missing_player_raises(tmp_path):
with pytest.raises(RuntimeError, match="HD-Player"):
ih.launch_instance(str(tmp_path), "Whatever")


def test_restart_instance_kills_then_relaunches(tmp_path, monkeypatch):
exe = tmp_path / "HD-Player.exe"
exe.write_bytes(b"x")
order = []
monkeypatch.setattr(ih, "terminate_bluestacks", lambda: order.append("kill"))
monkeypatch.setattr(ih.time, "sleep", lambda s: order.append("wait"))
monkeypatch.setattr(ih.subprocess, "Popen", lambda args, **k: order.append("launch"))

ih.restart_instance(str(tmp_path), "Tiramisu64", wait_ms=10)

assert order == ["kill", "wait", "launch"] # kill, settle, then relaunch
10 changes: 10 additions & 0 deletions tests/test_instances_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,13 @@ def test_set_instances_refresh_does_not_leak_widgets(qtbot):

page.set_instances(data)
assert page.instance_layout.count() == count_after_first


def test_launch_and_restart_buttons_emit_signals(qtbot):
page = InstancesPage()
qtbot.addWidget(page)
page.show()
with qtbot.waitSignal(page.launch_requested, timeout=1000):
qtbot.mouseClick(page.launch_button, Qt.LeftButton)
with qtbot.waitSignal(page.restart_requested, timeout=1000):
qtbot.mouseClick(page.restart_button, Qt.LeftButton)
46 changes: 46 additions & 0 deletions tests/test_main_window_magisk.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,49 @@ def test_install_lsposed_runs_when_adb_present(qtbot, monkeypatch):
window._handle_install_lsposed()

ran.assert_called_once()


def _inst_with(uid, **extra):
d = {"patch_mode": True, "root_enabled": False, "config_path": "c",
"original_name": uid.split(" ")[0], "data_path": r"C:\i", "install_path": r"C:\bs"}
d.update(extra)
return {uid: d}


def test_launch_instance_needs_exactly_one_selected(qtbot, monkeypatch):
window = MainWindow()
qtbot.addWidget(window)
window.instance_data = _inst_with("Tiramisu64 (Normal)")
window.instances_page.set_instances(window.instance_data)
# nothing selected -> informs, doesn't launch
info = MagicMock()
monkeypatch.setattr(QMessageBox, "information", info)
launched = MagicMock()
monkeypatch.setattr("instance_handler.launch_instance", launched)
window._handle_launch_instance()
info.assert_called_once()
launched.assert_not_called()


def test_launch_instance_runs_for_single_selection(qtbot, monkeypatch):
window = MainWindow()
qtbot.addWidget(window)
window.instance_data = _inst_with("Tiramisu64 (Normal)")
window.instances_page.set_instances(window.instance_data)
window.instances_page.checkboxes["Tiramisu64 (Normal)"].setChecked(True)
launched = MagicMock()
monkeypatch.setattr("instance_handler.launch_instance", launched)
window._handle_launch_instance()
launched.assert_called_once()


def test_restart_instance_runs_async(qtbot, monkeypatch):
window = MainWindow()
qtbot.addWidget(window)
window.instance_data = _inst_with("Tiramisu64 (Normal)")
window.instances_page.set_instances(window.instance_data)
window.instances_page.checkboxes["Tiramisu64 (Normal)"].setChecked(True)
ran = MagicMock()
monkeypatch.setattr(window, "_run_async", ran)
window._handle_restart_instance()
ran.assert_called_once()
15 changes: 13 additions & 2 deletions views/instances_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
class InstancesPage(QWidget):
toggle_root_requested = pyqtSignal()
toggle_rw_requested = pyqtSignal()
launch_requested = pyqtSignal()
restart_requested = pyqtSignal()
go_to_dashboard_requested = pyqtSignal()

def __init__(self, parent=None):
Expand Down Expand Up @@ -59,8 +61,17 @@ def __init__(self, parent=None):
self.root_toggle_button.clicked.connect(self.toggle_root_requested.emit)
self.rw_toggle_button = QPushButton("Toggle R/W")
self.rw_toggle_button.clicked.connect(self.toggle_rw_requested.emit)
button_row.addWidget(self.root_toggle_button)
button_row.addWidget(self.rw_toggle_button)
self.launch_button = QPushButton("Launch")
self.launch_button.setToolTip("Start the selected instance (HD-Player).")
self.launch_button.clicked.connect(self.launch_requested.emit)
self.restart_button = QPushButton("Restart")
self.restart_button.setToolTip(
"Close all BlueStacks processes and relaunch the selected instance — "
"the reliable reboot (adb reboot doesn't restart BlueStacks cleanly).")
self.restart_button.clicked.connect(self.restart_requested.emit)
for _b in (self.root_toggle_button, self.rw_toggle_button,
self.launch_button, self.restart_button):
button_row.addWidget(_b)
layout.addLayout(button_row)

self.checkboxes: dict[str, QCheckBox] = {}
Expand Down
32 changes: 17 additions & 15 deletions views/magisk_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ class MagiskPage(QWidget):
_INTEGRITY_NOTE = (
"How this works: Magisk installs into the instance's system + data "
"images while it's shut down (no R/W toggle, no temp-root, no taps). "
"Start the instance, install the manager app, then add modules: "
"ReZygisk (Zygisk — required by Zygisk modules) and LSPosed (the Xposed "
"framework for app-hooking modules). Reboot to activate.\n\n"
"Start the instance, install the manager, then add modules ONE AT A "
"TIME: install ReZygisk (Zygisk), close and reopen the instance, then "
"LSPosed (Xposed), and close/reopen again. Flashing both before a "
"restart can leave the instance unbootable. Modules enable themselves "
"on flash — no extra step.\n\n"
"Note on Play Integrity: it does not pass on BlueStacks. Google limits "
"emulator integrity to its own Google Play Games, so apps that gate on "
"it (banking, some games) won't work here — with or without these "
Expand All @@ -59,31 +61,31 @@ def __init__(self, parent=None):
layout.addWidget(self.status_label)

button_row = QHBoxLayout()
self.install_button = QPushButton("Install Magisk (system root)")
self.install_button = QPushButton("Install Magisk")
self.install_button.setToolTip("Full offline Magisk system-root install (instance shut down).")
self.install_button.clicked.connect(self.install_requested.emit)
self.uninstall_button = QPushButton("Uninstall Magisk")
self.uninstall_button.clicked.connect(self.uninstall_requested.emit)
self.manager_button = QPushButton("Install manager app")
self.manager_button = QPushButton("Install manager")
self.manager_button.setToolTip(
"Installs the Magisk manager over ADB. Start the instance and enable "
"ADB (Settings → Advanced) first.")
"Installs the Magisk manager app over ADB. Start the instance and "
"enable ADB (Settings → Advanced) first.")
self.manager_button.clicked.connect(self.install_manager_requested.emit)
self.remove_manager_button = QPushButton("Remove manager")
self.remove_manager_button.setToolTip(
"Uninstalls the Magisk manager app over ADB. Leaves the system root "
"in place.")
self.remove_manager_button.clicked.connect(self.uninstall_manager_requested.emit)
self.rezygisk_button = QPushButton("Install ReZygisk (Zygisk)")
self.rezygisk_button = QPushButton("Install ReZygisk")
self.rezygisk_button.setToolTip(
"Downloads the pinned ReZygisk module and flashes it over ADB "
"(magisk --install-module). Grant the su request in the manager, then "
"reboot the instance to activate Zygisk.")
"ReZygisk = Zygisk, required by Zygisk modules. Flashes over ADB; "
"close and reopen the instance afterward. Install this before LSPosed.")
self.rezygisk_button.clicked.connect(self.install_rezygisk_requested.emit)
self.lsposed_button = QPushButton("Install LSPosed (Xposed)")
self.lsposed_button = QPushButton("Install LSPosed")
self.lsposed_button.setToolTip(
"Downloads the pinned LSPosed (Zygisk) module and flashes it over ADB. "
"Needs ReZygisk (Zygisk) installed first. Reboot to activate; manage "
"modules from the LSPosed app.")
"LSPosed = the Xposed framework (needs ReZygisk first). Flash it after "
"ReZygisk and a restart; close/reopen again after. Manage modules from "
"the LSPosed app.")
self.lsposed_button.clicked.connect(self.install_lsposed_requested.emit)
for _b in (self.install_button, self.uninstall_button, self.manager_button,
self.remove_manager_button, self.rezygisk_button, self.lsposed_button):
Expand Down
42 changes: 42 additions & 0 deletions views/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def init_ui(self) -> None:
self.dashboard_page.repatch_requested.connect(self.handle_apply_patches)
self.instances_page.toggle_root_requested.connect(self.handle_toggle_root)
self.instances_page.toggle_rw_requested.connect(self.handle_toggle_rw)
self.instances_page.launch_requested.connect(self._handle_launch_instance)
self.instances_page.restart_requested.connect(self._handle_restart_instance)
self.instances_page.go_to_dashboard_requested.connect(
lambda: self.nav_rail.select(NAV_DASHBOARD))
self.modules_page.browse_zip_requested.connect(self._handle_browse_zip)
Expand Down Expand Up @@ -499,6 +501,45 @@ def handle_toggle_root(self):
def handle_toggle_rw(self):
self._perform_operation(self._toggle_single_instance_rw, "R/W")

def _selected_single_instance(self):
ids = self.instances_page.selected_ids()
if len(ids) != 1:
QMessageBox.information(self, "Select one instance",
"Select exactly one instance to launch or restart.")
return None, None
return ids[0], self.instance_data.get(ids[0])

def _handle_launch_instance(self):
uid, instance = self._selected_single_instance()
if not instance:
return
install = instance.get("install_path")
if not install:
QMessageBox.warning(self, "Can't launch", "No BlueStacks install path for %s." % uid)
return
try:
instance_handler.launch_instance(install, instance["original_name"])
self.progress_bar.finish("Launching %s..." % uid)
except Exception as exc: # noqa: BLE001
QMessageBox.warning(self, "Launch failed", str(exc))

def _handle_restart_instance(self):
uid, instance = self._selected_single_instance()
if not instance:
return
install = instance.get("install_path")
if not install:
QMessageBox.warning(self, "Can't restart", "No BlueStacks install path for %s." % uid)
return
name = instance["original_name"]

def job(progress):
progress("Closing BlueStacks...", 0)
instance_handler.restart_instance(install, name)
return "Restarted %s. Give it a moment to boot." % uid

self._run_async(job, "Restarting %s..." % uid)

def _refresh_running_instances(self) -> None:
"""Populate the Modules tab's running-instance list.

Expand Down Expand Up @@ -800,6 +841,7 @@ def relay(msg):

def _action_buttons(self):
return [self.instances_page.root_toggle_button, self.instances_page.rw_toggle_button,
self.instances_page.launch_button, self.instances_page.restart_button,
self.dashboard_page.engine_button, self.modules_page.push_button,
self.magisk_page.install_button, self.magisk_page.uninstall_button,
self.magisk_page.manager_button, self.magisk_page.remove_manager_button,
Expand Down