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/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_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/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/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): 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,