diff --git a/README.md b/README.md index 8c89cbc..2951573 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,39 @@ line. With `-o`, output is written to the selected file instead of stdout. `Lcom/poc` is kept unchanged. You can also use `python main.py` as before — it delegates to the same entry point. + +## macOS GUI setup + +The GUI uses `tkinter`, including Python's native `_tkinter` module and Tcl/Tk. +These are interpreter dependencies, not pip packages; installing +`requirements.txt` does not add Tk to a Python build that lacks it. + +Check the **same interpreter** used to run ASC: + +```sh +python -m tkinter +``` + +This should open a small test window. If it reports `No module named '_tkinter'` +or `tkinter`, use a Python installation with Tk support. For example, with +[Homebrew Python 3.12](https://formulae.brew.sh/formula/python@3.12): + +```sh +brew install python@3.12 python-tk@3.12 +"$(brew --prefix python@3.12)/bin/python3.12" -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +.venv/bin/python -m tkinter +.venv/bin/python -m droidasc test.apk --gui +``` + +Alternatively, the [python.org macOS installer](https://www.python.org/download/mac/tcltk/) +includes Tcl/Tk; create a virtual environment using that installation. +An existing virtual environment made with pyenv continues to use its original +Python. Installing Homebrew's Tk package does **not** retrofit that pyenv Python; +rebuild it with Tcl/Tk support or create a new environment with a Tk-enabled +interpreter as above. + +Use `--gui` to launch the GUI. On macOS, the GUI runs in the foreground; +the terminal stays attached until the window closes, and startup failures are +printed with a nonzero exit status. Add `--debug` to include a traceback. +Missing Tk support is checked before any background launch on other platforms. diff --git a/droidasc/cli.py b/droidasc/cli.py index 775b234..4cf1348 100644 --- a/droidasc/cli.py +++ b/droidasc/cli.py @@ -170,7 +170,29 @@ def _run_gui(argv): parser.add_argument("--gui-foreground", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args(argv) - if not args.debug and not args.gui_foreground: + # Validate the APK before starting the GUI or a detached child process. + args.apk_path = os.path.abspath(args.apk_path) + if not os.path.isfile(args.apk_path): + parser.error(f"APK file not found: {args.apk_path}") + + # Check in the calling process so a missing native Tk module cannot become + # an apparently successful launch with no window or diagnostic. + try: + import tkinter + except ImportError as e: + if e.name not in ("tkinter", "_tkinter"): + raise + raise RuntimeError( + f"GUI requires tkinter and its native _tkinter module. " + f"Python interpreter: {sys.executable}. " + "Use a Python installation with Tcl/Tk support and recreate the virtual " + "environment with that interpreter. Installing requirements.txt alone " + "does not provide Tk. See README.md: macOS GUI setup." + ) from e + + # Keep macOS startup and its event loop in the foreground so initialization + # errors are visible and the exit status reflects the actual GUI process. + if sys.platform != "darwin" and not args.debug and not args.gui_foreground: cmd = [ sys.executable, "-m", "droidasc", @@ -190,7 +212,7 @@ def _run_gui(argv): cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=None, close_fds=True, creationflags=creationflags, start_new_session=start_new_session, @@ -201,12 +223,23 @@ def _run_gui(argv): launch_gui(args.apk_path, max_workers=args.threads, debug=args.debug) +def _exit_with_error(error, debug): + print(f"Error: {error}", file=sys.stderr) + if debug: + import traceback + traceback.print_exc() + sys.exit(1) + + def main(): if len(sys.argv) == 1: _build_main_parser().print_help() return if "--gui" in sys.argv[1:]: - _run_gui(sys.argv[1:]) + try: + _run_gui(sys.argv[1:]) + except Exception as e: + _exit_with_error(e, "--debug" in sys.argv[1:]) return parser = _build_main_parser() @@ -222,11 +255,7 @@ def main(): elif args.command == "findrefs": _handle_findrefs(args) except Exception as e: - print(f"Error: {e}", file=sys.stderr) - if args.debug: - import traceback - traceback.print_exc() - sys.exit(1) + _exit_with_error(e, args.debug) def _build_main_parser(): diff --git a/tests/TESTING.md b/tests/TESTING.md index 3d76c78..9b8fd40 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -12,11 +12,10 @@ The regression runner fails if Androguard is missing or any test is skipped in strict mode. It covers index zero, empty/fallback instruction maps, string layout, zeroed reconstructed DEX signatures/checksums, dummy imports, the MUTF-8 shim, CLI stored/Deflate multidex operation, GUI data-store reuse, and reference searches -without site packages. `listclass` coverage includes local class-table -enumeration, stable DEX definition order, normalized package-prefix filtering, -stored/Deflate multidex APKs, DEX 041 containers, malformed indices, operation -without site packages, and a large-tail Deflate fixture. It does not open GUI -windows. +without site packages. It does not open GUI windows. +GUI startup tests cover the `--gui` flag, macOS foreground execution, missing Tk +diagnostics, debug tracebacks, and relative APK paths in detached launches using +a stub GUI; native window rendering still requires a Tk-enabled desktop Python. The synthetic startup gate measures `getclass --debug` in nine fresh interpreters. Its median budgets are 100 ms internal time and 250 ms wall time on Linux CI. diff --git a/tests/test_gui_startup.py b/tests/test_gui_startup.py new file mode 100644 index 0000000..414277e --- /dev/null +++ b/tests/test_gui_startup.py @@ -0,0 +1,113 @@ +"""Exercise CLI GUI startup without requiring a desktop or a Tk installation.""" +import builtins +import contextlib +import io +import os +from pathlib import Path +import sys +import tempfile +import types +import unittest +from unittest.mock import Mock, patch + +from droidasc import cli as main + + +class GuiStartupTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.apk = Path(directory.name) / 'sample.apk' + self.apk.touch() + self.gui = types.ModuleType('droidasc.asc_client.gui.app') + self.gui.launch_gui = Mock() + self.modules = { + 'tkinter': types.ModuleType('tkinter'), + 'droidasc.asc_client.gui.app': self.gui, + } + + def test_macos_gui_launches_in_foreground(self): + with patch.dict(sys.modules, self.modules), \ + patch.object(sys, 'platform', 'darwin'), \ + patch.object(sys, 'argv', ['main.py', str(self.apk), '--gui', '--threads', '3']), \ + patch.object(main.subprocess, 'Popen') as popen: + main.main() + popen.assert_not_called() + self.gui.launch_gui.assert_called_once_with( + str(self.apk), max_workers=3, debug=False) + + def test_missing_tk_is_reported_before_spawning(self): + original_import = builtins.__import__ + for module in ('tkinter', '_tkinter'): + def missing_tk(name, *args, **kwargs): + if name == 'tkinter': + raise ModuleNotFoundError(f"No module named '{module}'", name=module) + return original_import(name, *args, **kwargs) + + with self.subTest(module=module), patch('builtins.__import__', side_effect=missing_tk), \ + patch.object(sys, 'platform', 'linux'), \ + patch.object(sys, 'argv', ['main.py', str(self.apk), '--gui']), \ + patch.object(main.subprocess, 'Popen') as popen, \ + contextlib.redirect_stderr(io.StringIO()) as stderr, \ + self.assertRaises(SystemExit) as exit_result: + main.main() + self.assertEqual(exit_result.exception.code, 1) + self.assertIn('GUI requires tkinter', stderr.getvalue()) + self.assertIn(sys.executable, stderr.getvalue()) + self.assertNotIn('Traceback', stderr.getvalue()) + popen.assert_not_called() + + def test_initialization_errors_are_visible_and_debug_keeps_traceback(self): + self.gui.launch_gui.side_effect = RuntimeError('Tk initialization failed') + for debug in (False, True): + argv = ['main.py', str(self.apk), '--gui'] + (['--debug'] if debug else []) + with self.subTest(debug=debug), patch.dict(sys.modules, self.modules), \ + patch.object(sys, 'platform', 'darwin'), patch.object(sys, 'argv', argv), \ + contextlib.redirect_stderr(io.StringIO()) as stderr, \ + self.assertRaises(SystemExit) as exit_result: + main.main() + self.assertEqual(exit_result.exception.code, 1) + self.assertIn('Tk initialization failed', stderr.getvalue()) + self.assertEqual('Traceback' in stderr.getvalue(), debug) + + def test_detached_launch_preserves_caller_path_and_stderr(self): + # Pass an absolute APK path to the detached module entry point. + with patch.dict(sys.modules, self.modules), patch.object(sys, 'platform', 'linux'), \ + patch.object(main.subprocess, 'Popen') as popen: + main._run_gui([os.path.relpath(self.apk), '--gui']) + command = popen.call_args.args[0] + self.assertEqual(command[:3], [sys.executable, '-m', 'droidasc']) + self.assertEqual(command[3], str(self.apk)) + self.assertIn('--gui-foreground', command) + self.assertIsNone(popen.call_args.kwargs['stderr']) + self.gui.launch_gui.assert_not_called() + + def test_explicit_foreground_and_debug_do_not_spawn(self): + for option in ('--gui-foreground', '--debug'): + with self.subTest(option=option), patch.dict(sys.modules, self.modules), \ + patch.object(sys, 'platform', 'linux'), \ + patch.object(main.subprocess, 'Popen') as popen: + main._run_gui([str(self.apk), '--gui', option]) + popen.assert_not_called() + self.gui.launch_gui.assert_called_once_with( + str(self.apk), max_workers=8, debug=option == '--debug') + self.gui.launch_gui.reset_mock() + + def test_missing_apk_is_reported_without_launch(self): + with patch.object(sys, 'argv', ['main.py', str(self.apk) + '.missing', '--gui']), \ + patch.object(main.subprocess, 'Popen') as popen, \ + contextlib.redirect_stderr(io.StringIO()) as stderr, \ + self.assertRaises(SystemExit) as exit_result: + main.main() + self.assertEqual(exit_result.exception.code, 2) + self.assertIn('APK file not found', stderr.getvalue()) + popen.assert_not_called() + + def test_gui_help_does_not_require_tk(self): + with patch.dict(sys.modules, {'tkinter': None}), \ + patch.object(sys, 'argv', ['main.py', '--gui', '--help']), \ + contextlib.redirect_stdout(io.StringIO()) as stdout, \ + self.assertRaises(SystemExit) as exit_result: + main.main() + self.assertEqual(exit_result.exception.code, 0) + self.assertIn('--gui', stdout.getvalue())