From 650d35117ee2d4a16b04d365c19dfa3db05017cd Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Mon, 20 Jul 2026 15:26:02 -0500 Subject: [PATCH 1/2] ux: shorter module button labels, su auto-grant, reboot-order copy From live-test feedback: - Button labels overflowed the row ("stall ReZygisk (Zygisl"). Shorten to "Install Magisk / manager / ReZygisk / LSPosed"; the (Zygisk)/(Xposed)/ system-root detail moves to tooltips. - Module flashes were getting AUTO-DENIED when the su prompt wasn't tapped in time. install_module now pre-sets a permanent allow policy for the shell uid (magisk policies uid=2000 policy=2) before flashing -- the first flash may still prompt once, then every later flash is automatic. - Copy: flash modules ONE AT A TIME (ReZygisk -> close/reopen -> LSPosed -> close/reopen); flashing both before a restart can leave the instance unbootable. Modules enable themselves on flash -- dropped the stale "activate" implication of a manual enable step. 169 tests green. --- adb_handler.py | 23 +++++++++++++++++++++++ tests/test_adb_manager.py | 28 +++++++++++++++++++++++++++- views/magisk_page.py | 32 +++++++++++++++++--------------- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/adb_handler.py b/adb_handler.py index 39aea00..b860e92 100644 --- a/adb_handler.py +++ b/adb_handler.py @@ -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 + 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: @@ -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]) diff --git a/tests/test_adb_manager.py b/tests/test_adb_manager.py index 41b40f4..6ed11c6 100644 --- a/tests/test_adb_manager.py +++ b/tests/test_adb_manager.py @@ -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): @@ -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) + 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": diff --git a/views/magisk_page.py b/views/magisk_page.py index 3c0ebd1..7b32c42 100644 --- a/views/magisk_page.py +++ b/views/magisk_page.py @@ -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 " @@ -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): From 9e37a7a3e80aea73e6c0027f7cf02ca387b892b7 Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Mon, 20 Jul 2026 15:32:05 -0500 Subject: [PATCH 2/2] instances: Launch + Restart buttons (reliable reboot) Per feedback -- slim the Instances tab and add per-instance launch/reboot: - launch_instance(): HD-Player.exe --instance . - restart_instance(): terminate all BlueStacks processes, settle, relaunch the instance. This is the reliable reboot on BlueStacks (adb reboot doesn't restart cleanly) and clears the state that can leave an instance unbootable right after flashing modules. - Instances tab: Launch / Restart buttons (single-instance), wired through the busy-disable set; restart runs on the worker thread. 176 tests green. --- instance_handler.py | 29 ++++++++++++++++++++ tests/test_instance_launch.py | 32 ++++++++++++++++++++++ tests/test_instances_page.py | 10 +++++++ tests/test_main_window_magisk.py | 46 ++++++++++++++++++++++++++++++++ views/instances_page.py | 15 +++++++++-- views/main_window.py | 42 +++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 tests/test_instance_launch.py diff --git a/instance_handler.py b/instance_handler.py index 87662d7..49c0717 100644 --- a/instance_handler.py +++ b/instance_handler.py @@ -4,6 +4,8 @@ import os import glob import logging +import subprocess +import time import psutil @@ -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 ``). + + 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 diff --git a/tests/test_instance_launch.py b/tests/test_instance_launch.py new file mode 100644 index 0000000..f50ae46 --- /dev/null +++ b/tests/test_instance_launch.py @@ -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 diff --git a/tests/test_instances_page.py b/tests/test_instances_page.py index 878f2b7..cec482c 100644 --- a/tests/test_instances_page.py +++ b/tests/test_instances_page.py @@ -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) diff --git a/tests/test_main_window_magisk.py b/tests/test_main_window_magisk.py index eb90e72..1eaa90b 100644 --- a/tests/test_main_window_magisk.py +++ b/tests/test_main_window_magisk.py @@ -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() diff --git a/views/instances_page.py b/views/instances_page.py index 4b2f300..f639946 100644 --- a/views/instances_page.py +++ b/views/instances_page.py @@ -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): @@ -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] = {} diff --git a/views/main_window.py b/views/main_window.py index efd2447..ec31475 100644 --- a/views/main_window.py +++ b/views/main_window.py @@ -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) @@ -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. @@ -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,