Skip to content
Open
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
45 changes: 37 additions & 8 deletions droidasc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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():
Expand Down
9 changes: 4 additions & 5 deletions tests/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 113 additions & 0 deletions tests/test_gui_startup.py
Original file line number Diff line number Diff line change
@@ -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())