575 lines
20 KiB
Python
575 lines
20 KiB
Python
|
import contextlib
|
||
|
import importlib.metadata
|
||
|
import inspect
|
||
|
import logging
|
||
|
import os
|
||
|
import platform
|
||
|
import re
|
||
|
import shutil
|
||
|
import subprocess
|
||
|
import sys
|
||
|
import threading
|
||
|
from pathlib import Path
|
||
|
from typing import List, NoReturn, Tuple, Union
|
||
|
|
||
|
import jpype
|
||
|
from jpype import imports, _jpype
|
||
|
from importlib.machinery import ModuleSpec
|
||
|
|
||
|
from . import __version__
|
||
|
from .javac import java_compile
|
||
|
from .script import PyGhidraScript
|
||
|
from .version import ApplicationInfo, ExtensionDetails, MINIMUM_GHIDRA_VERSION
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
@contextlib.contextmanager
|
||
|
def _silence_java_output(stdout=True, stderr=True):
|
||
|
from java.io import OutputStream, PrintStream
|
||
|
from java.lang import System
|
||
|
out = System.out
|
||
|
err = System.err
|
||
|
null = PrintStream(OutputStream.nullOutputStream())
|
||
|
|
||
|
# The user's Java SecurityManager might not allow this
|
||
|
with contextlib.suppress(jpype.JException):
|
||
|
if stdout:
|
||
|
System.setOut(null)
|
||
|
if stderr:
|
||
|
System.setErr(null)
|
||
|
|
||
|
try:
|
||
|
yield
|
||
|
finally:
|
||
|
with contextlib.suppress(jpype.JException):
|
||
|
System.setOut(out)
|
||
|
System.setErr(err)
|
||
|
|
||
|
|
||
|
def _load_entry_points(group: str, *args):
|
||
|
"""
|
||
|
Loads any entry point callbacks registered by external python packages.
|
||
|
"""
|
||
|
entry_points = importlib.metadata.entry_points()
|
||
|
if hasattr(entry_points, 'select'):
|
||
|
entries = entry_points.select(group=group)
|
||
|
else:
|
||
|
entries = entry_points.get(group, None)
|
||
|
if entries is None:
|
||
|
return
|
||
|
|
||
|
for entry in entries:
|
||
|
name = entry.name
|
||
|
callback = entry.load()
|
||
|
try:
|
||
|
# Give launcher to callback so they can edit vmargs, install plugins, etc.
|
||
|
logger.debug(f"Calling {group} entry point: {name}")
|
||
|
callback(*args)
|
||
|
except Exception as e:
|
||
|
logger.error(f"Failed to run {group} entry point {name} with error: {e}")
|
||
|
|
||
|
|
||
|
class _PyhidraImportLoader:
|
||
|
""" (internal) Finder hook for importlib to handle Python mod conflicts. """
|
||
|
|
||
|
def find_spec(self, name, path, target=None):
|
||
|
|
||
|
# If jvm is not started then there is nothing to find.
|
||
|
if not _jpype.isStarted():
|
||
|
return None
|
||
|
|
||
|
if name.endswith('_') and _jpype.isPackage(name[:-1]):
|
||
|
return ModuleSpec(name, self)
|
||
|
|
||
|
def create_module(self, spec):
|
||
|
return _jpype._JPackage(spec.name[:-1])
|
||
|
|
||
|
def exec_module(self, fullname):
|
||
|
pass
|
||
|
|
||
|
|
||
|
class PyhidraLauncher:
|
||
|
"""
|
||
|
Base pyhidra launcher
|
||
|
"""
|
||
|
|
||
|
def __init__(self, verbose=False, *, install_dir: Path = None):
|
||
|
"""
|
||
|
Initializes a new `PyhidraLauncher`.
|
||
|
|
||
|
:param verbose: True to enable verbose output when starting Ghidra.
|
||
|
:param install_dir: Ghidra installation directory.
|
||
|
(Defaults to the GHIDRA_INSTALL_DIR environment variable)
|
||
|
:raises ValueError: If the Ghidra installation directory is invalid.
|
||
|
"""
|
||
|
self._layout = None
|
||
|
self._java_home = None
|
||
|
|
||
|
install_dir = install_dir or os.getenv("GHIDRA_INSTALL_DIR")
|
||
|
self._install_dir = self._validate_install_dir(install_dir)
|
||
|
self._plugins: List[Tuple[Path, ExtensionDetails]] = []
|
||
|
self.verbose = verbose
|
||
|
|
||
|
ghidra_dir = self._install_dir / "Ghidra"
|
||
|
self.class_path = [str(ghidra_dir / "Framework" / "Utility" / "lib" / "Utility.jar")]
|
||
|
self.class_files = []
|
||
|
self.vm_args = self._jvm_args(self._install_dir)
|
||
|
self.args = []
|
||
|
self.app_info = ApplicationInfo.from_file(ghidra_dir / "application.properties")
|
||
|
|
||
|
@classmethod
|
||
|
def _jvm_args(cls, install_dir: Path) -> List[str]:
|
||
|
suffix = "_" + platform.system().upper()
|
||
|
option_pattern: re.Pattern = re.compile(fr"VMARGS(?:{suffix})?=(.+)")
|
||
|
properties = []
|
||
|
|
||
|
launch_properties = install_dir / "support" / "launch.properties"
|
||
|
|
||
|
with open(launch_properties, "r", encoding='utf-8') as fd:
|
||
|
# this file is small so just read it at once
|
||
|
for line in fd.readlines():
|
||
|
match = option_pattern.match(line)
|
||
|
if match:
|
||
|
arg = match.group(1)
|
||
|
name, sep, value = arg.partition('=')
|
||
|
# unquote any values because quotes are automatically added during JVM startup
|
||
|
if value.startswith('"') and value.endswith('"'):
|
||
|
value = value.removeprefix('"').removesuffix('"')
|
||
|
properties.append(name + sep + value)
|
||
|
return properties
|
||
|
|
||
|
@property
|
||
|
def extension_path(self) -> Path:
|
||
|
if not self._layout:
|
||
|
raise RuntimeError("extension_path cannot be obtained until launcher starts.")
|
||
|
return Path(self._layout.getUserSettingsDir().getPath()) / "Extensions"
|
||
|
|
||
|
@property
|
||
|
def java_home(self) -> Path:
|
||
|
if not self._java_home:
|
||
|
launch_support = self.install_dir / "support" / "LaunchSupport.jar"
|
||
|
if not launch_support.exists():
|
||
|
raise ValueError(f"{launch_support} does not exist")
|
||
|
cmd = f'java -cp "{launch_support}" LaunchSupport "{self.install_dir}" -jdk_home -save'
|
||
|
home = subprocess.check_output(cmd, encoding="utf-8", shell=True)
|
||
|
self._java_home = Path(home.rstrip())
|
||
|
return self._java_home
|
||
|
|
||
|
@java_home.setter
|
||
|
def java_home(self, path: Path):
|
||
|
self._java_home = Path(path)
|
||
|
|
||
|
@property
|
||
|
def install_dir(self) -> Path:
|
||
|
return self._install_dir
|
||
|
|
||
|
@classmethod
|
||
|
def _validate_install_dir(cls, install_dir: Union[Path, str]) -> Path:
|
||
|
"""
|
||
|
Validates and sets the Ghidra installation directory.
|
||
|
"""
|
||
|
if not install_dir:
|
||
|
msg = (
|
||
|
"Please set the GHIDRA_INSTALL_DIR environment variable "
|
||
|
"or `install_dir` during the Launcher construction to the "
|
||
|
"directory where Ghidra is installed."
|
||
|
)
|
||
|
cls._report_fatal_error("GHIDRA_INSTALL_DIR is not set", msg, ValueError(msg))
|
||
|
|
||
|
# both the directory and the application.properties file must exist
|
||
|
install_dir = Path(install_dir)
|
||
|
if not install_dir.exists():
|
||
|
msg = f"{install_dir} does not exist"
|
||
|
cls._report_fatal_error("Invalid Ghidra Installation Directory", msg, ValueError(msg))
|
||
|
|
||
|
path = install_dir / "Ghidra" / "application.properties"
|
||
|
if not path.exists():
|
||
|
msg = "The Ghidra installation does not contain the required " + \
|
||
|
"application.properties file"
|
||
|
cls._report_fatal_error("Corrupt Ghidra Installation", msg, ValueError(msg))
|
||
|
|
||
|
return install_dir
|
||
|
|
||
|
def add_classpaths(self, *args):
|
||
|
"""
|
||
|
Add additional entries to the classpath when starting the JVM
|
||
|
"""
|
||
|
self.class_path += args
|
||
|
|
||
|
def add_vmargs(self, *args):
|
||
|
"""
|
||
|
Add additional vmargs for launching the JVM
|
||
|
"""
|
||
|
self.vm_args += args
|
||
|
|
||
|
def add_class_files(self, *args):
|
||
|
"""
|
||
|
Add additional entries to be added the classpath after Ghidra has been fully loaded.
|
||
|
This ensures that all of Ghidra is available so classes depending on it can be properly loaded.
|
||
|
"""
|
||
|
self.class_files += args
|
||
|
|
||
|
@classmethod
|
||
|
def _report_fatal_error(cls, title: str, msg: str, cause: Exception) -> NoReturn:
|
||
|
logger.error("%s: %s", title, msg)
|
||
|
raise cause
|
||
|
|
||
|
def check_ghidra_version(self):
|
||
|
"""
|
||
|
Checks if the currently installed Ghidra version is supported.
|
||
|
The launcher will report the problem and terminate if it is not supported.
|
||
|
"""
|
||
|
if self.app_info.version < MINIMUM_GHIDRA_VERSION:
|
||
|
msg = f"Ghidra version {self.app_info.version} is not supported" + os.linesep + \
|
||
|
f"The minimum required version is {MINIMUM_GHIDRA_VERSION}"
|
||
|
self._report_fatal_error("Unsupported Version", msg, ValueError(msg))
|
||
|
|
||
|
def start(self, **jpype_kwargs):
|
||
|
"""
|
||
|
Starts Jpype connection to Ghidra (if not already started).
|
||
|
"""
|
||
|
if jpype.isJVMStarted():
|
||
|
return
|
||
|
|
||
|
self.check_ghidra_version()
|
||
|
|
||
|
try:
|
||
|
# Before starting up, give launcher to installed entry points so they can do their thing.
|
||
|
_load_entry_points("pyhidra.setup", self)
|
||
|
|
||
|
pyhidra_details = ExtensionDetails(
|
||
|
name="pyhidra",
|
||
|
description="Native Python Plugin",
|
||
|
author="Department of Defense Cyber Crime Center (DC3)",
|
||
|
plugin_version=__version__,
|
||
|
version=self.app_info.version
|
||
|
)
|
||
|
|
||
|
# Merge classpath
|
||
|
jpype_kwargs['classpath'] = self.class_path + jpype_kwargs.get('classpath', [])
|
||
|
|
||
|
# force convert strings (required by pyhidra)
|
||
|
jpype_kwargs['convertStrings'] = True
|
||
|
|
||
|
# set the JAVA_HOME environment variable to the correct one so jpype uses it
|
||
|
os.environ['JAVA_HOME'] = str(self.java_home)
|
||
|
|
||
|
jpype.startJVM(
|
||
|
None, # indicates to use JAVA_HOME as the jvm path
|
||
|
*self.vm_args,
|
||
|
**jpype_kwargs
|
||
|
)
|
||
|
|
||
|
# Install hook into python importlib
|
||
|
sys.meta_path.append(_PyhidraImportLoader())
|
||
|
|
||
|
imports.registerDomain("ghidra")
|
||
|
|
||
|
# import and create a temporary GhidraApplicationLayout this can be
|
||
|
# used without initializing Ghidra to obtain the correct Extension path
|
||
|
from ghidra import GhidraApplicationLayout
|
||
|
self._layout = GhidraApplicationLayout()
|
||
|
|
||
|
# uninstall any outdated plugins before initializing Ghidra to ensure they are loaded correctly
|
||
|
self._uninstall_old_plugin(pyhidra_details)
|
||
|
|
||
|
for _, details in self._plugins:
|
||
|
try:
|
||
|
self._uninstall_old_plugin(details)
|
||
|
except:
|
||
|
logger.warning("failed to uninstall plugin %s", details.name)
|
||
|
|
||
|
|
||
|
from ghidra import GhidraLauncher
|
||
|
self._layout = GhidraLauncher.initializeGhidraEnvironment()
|
||
|
|
||
|
# install the Pyhidra plugin.
|
||
|
from pyhidra.java import plugin
|
||
|
needs_reload = self._install_plugin(Path(plugin.__file__).parent, pyhidra_details)
|
||
|
|
||
|
if needs_reload:
|
||
|
# "restart" Ghidra
|
||
|
self._layout = GhidraLauncher.initializeGhidraEnvironment()
|
||
|
needs_reload = False
|
||
|
|
||
|
from java.lang import System
|
||
|
|
||
|
# manually check the classpath for the pyhidra plugin to
|
||
|
# help diagnose confusing errors (GH #31)
|
||
|
# this will help in the future too if Extensions are ever moved outside
|
||
|
# of the Ghidra user settings directory
|
||
|
jar_path = self._get_plugin_jar_path("pyhidra")
|
||
|
|
||
|
except Exception as e:
|
||
|
self._report_fatal_error("An error occured launching Ghidra", str(e), e)
|
||
|
|
||
|
# NOTE: be very careful not to cause an exception here because there will be
|
||
|
# no indication of a problem in GUI mode unless started with pyhidra -g -v
|
||
|
|
||
|
CLASSPATH_PROPERTY = "java.class.path"
|
||
|
classpath = System.getProperty(CLASSPATH_PROPERTY)
|
||
|
if classpath is None:
|
||
|
# this is impossible but a helpful message is
|
||
|
# better than "'NoneType' object hsas no attribute 'split'"
|
||
|
msg = f"Required Java property {CLASSPATH_PROPERTY} not found"
|
||
|
self._report_fatal_error(f"No Classpath", msg, RuntimeError(msg))
|
||
|
|
||
|
# ensure it is a Python string (not Java string) and then split the classpath
|
||
|
classpath = str(classpath).split(os.pathsep)
|
||
|
if str(jar_path) not in classpath:
|
||
|
title = "Classpath Setup Error"
|
||
|
msg = "Pyhidra plugin is not in the system classpath"
|
||
|
# Ghidra uses this property depending on JVM configuration
|
||
|
ext_classpath = System.getProperty("java.class.path.ext")
|
||
|
if ext_classpath is None:
|
||
|
logger.debug("plugin path: %s\nclasspath: %s", jar_path, '\n'.join(classpath))
|
||
|
self._report_fatal_error(title, msg, RuntimeError(msg))
|
||
|
classpath = str(ext_classpath).split(os.pathsep)
|
||
|
if str(jar_path) not in classpath:
|
||
|
logger.debug("plugin path: %s\nclasspath: %s", jar_path, '\n'.join(classpath))
|
||
|
self._report_fatal_error(title, msg, RuntimeError(msg))
|
||
|
|
||
|
try:
|
||
|
# import it at the end so interfaces in our java code may be implemented
|
||
|
from pyhidra.java.plugin.plugin import PyPhidraPlugin
|
||
|
PyPhidraPlugin.register()
|
||
|
|
||
|
# Add extra class paths
|
||
|
# Do this before installing plugins incase dependencies are needed
|
||
|
if self.class_files:
|
||
|
from java.lang import ClassLoader
|
||
|
gcl = ClassLoader.getSystemClassLoader()
|
||
|
for path in self.class_files:
|
||
|
gcl.addPath(path)
|
||
|
|
||
|
# Install extra plugins.
|
||
|
for source_path, details in self._plugins:
|
||
|
try:
|
||
|
needs_reload = self._install_plugin(source_path, details) or needs_reload
|
||
|
except Exception as e:
|
||
|
# we should always warn if a plugin failed to compile
|
||
|
logger.warn(e, exc_info=e)
|
||
|
|
||
|
if needs_reload:
|
||
|
# "restart" Ghidra
|
||
|
self._layout = GhidraLauncher.initializeGhidraEnvironment()
|
||
|
|
||
|
# import properties to register the property customizer
|
||
|
from . import properties as _
|
||
|
|
||
|
_load_entry_points("pyhidra.pre_launch")
|
||
|
|
||
|
self._launch()
|
||
|
except Exception as e:
|
||
|
self._report_fatal_error("An error occured launching Ghidra", str(e), e)
|
||
|
|
||
|
def get_install_path(self, plugin_name: str) -> Path:
|
||
|
"""
|
||
|
Obtains the path for installation of a given plugin.
|
||
|
"""
|
||
|
return self.extension_path / plugin_name
|
||
|
|
||
|
def _get_plugin_jar_path(self, plugin_name: str) -> Path:
|
||
|
return self.get_install_path(plugin_name) / "lib" / (plugin_name + ".jar")
|
||
|
|
||
|
def uninstall_plugin(self, plugin_name: str):
|
||
|
"""
|
||
|
Uninstalls given plugin.
|
||
|
"""
|
||
|
path = self.get_install_path(plugin_name)
|
||
|
if path.exists():
|
||
|
# delete the existing extension so it will be up-to-date
|
||
|
try:
|
||
|
shutil.rmtree(path)
|
||
|
except Exception as e:
|
||
|
self._report_fatal_error(
|
||
|
"Plugin Update Failed",
|
||
|
f"Could not delete existing plugin at\n{path}",
|
||
|
e
|
||
|
)
|
||
|
|
||
|
def _uninstall_old_plugin(self, details: ExtensionDetails):
|
||
|
"""
|
||
|
Automatically uninstalls an outdated plugin if it exists.
|
||
|
"""
|
||
|
plugin_name = details.name
|
||
|
path = self.get_install_path(plugin_name)
|
||
|
ext = path / "extension.properties"
|
||
|
|
||
|
# Uninstall old version.
|
||
|
if path.exists() and ext.exists():
|
||
|
orig_details = ExtensionDetails.from_file(ext)
|
||
|
if not orig_details.plugin_version or orig_details.plugin_version != details.plugin_version:
|
||
|
self.uninstall_plugin(plugin_name)
|
||
|
logger.info(f"Uninstalled older plugin: {plugin_name} {orig_details.plugin_version}")
|
||
|
|
||
|
def _install_plugin(self, source_path: Path, details: ExtensionDetails):
|
||
|
"""
|
||
|
Compiles and installs a Ghidra extension if not already installed.
|
||
|
"""
|
||
|
if details.version is None:
|
||
|
details.version = self.app_info.version
|
||
|
plugin_name = details.name
|
||
|
path = self.get_install_path(plugin_name)
|
||
|
ext = path / "extension.properties"
|
||
|
manifest = path / "Module.manifest"
|
||
|
root = source_path
|
||
|
jar_path = path / "lib" / (plugin_name + ".jar")
|
||
|
|
||
|
if not jar_path.exists():
|
||
|
path.mkdir(parents=True, exist_ok=True)
|
||
|
|
||
|
try:
|
||
|
java_compile(root.parent, jar_path)
|
||
|
except:
|
||
|
shutil.rmtree(path, ignore_errors=True)
|
||
|
raise
|
||
|
|
||
|
ext.write_text(str(details))
|
||
|
|
||
|
# required empty file
|
||
|
manifest.touch()
|
||
|
|
||
|
# Copy over ghidra_scripts if included.
|
||
|
ghidra_scripts = root / "ghidra_scripts"
|
||
|
if ghidra_scripts.exists():
|
||
|
shutil.copytree(ghidra_scripts, path / "ghidra_scripts")
|
||
|
|
||
|
logger.info(f"Installed plugin: {plugin_name} {details.plugin_version}")
|
||
|
return True
|
||
|
|
||
|
return False
|
||
|
|
||
|
def install_plugin(self, source_path: Path, details: ExtensionDetails):
|
||
|
"""
|
||
|
Compiles and installs a Ghidra extension when launcher is started.
|
||
|
"""
|
||
|
self._plugins.append((source_path, details))
|
||
|
|
||
|
def _launch(self):
|
||
|
pass
|
||
|
|
||
|
@staticmethod
|
||
|
def has_launched() -> bool:
|
||
|
"""
|
||
|
Checks if jpype has started and if Ghidra has been fully initialized.
|
||
|
"""
|
||
|
if not jpype.isJVMStarted():
|
||
|
return False
|
||
|
|
||
|
from ghidra.framework import Application
|
||
|
return Application.isInitialized()
|
||
|
|
||
|
|
||
|
class DeferredPyhidraLauncher(PyhidraLauncher):
|
||
|
"""
|
||
|
PyhidraLauncher which allows full Ghidra initialization to be deferred.
|
||
|
initialize_ghidra must be called before all Ghidra classes are fully available.
|
||
|
"""
|
||
|
|
||
|
def initialize_ghidra(self, headless=True):
|
||
|
"""
|
||
|
Finished Ghidra initialization
|
||
|
|
||
|
:param headless: whether or not to initialize Ghidra in headless mode.
|
||
|
(Defaults to True)
|
||
|
"""
|
||
|
from ghidra import GhidraRun
|
||
|
from ghidra.framework import Application, HeadlessGhidraApplicationConfiguration
|
||
|
with _silence_java_output(not self.verbose, not self.verbose):
|
||
|
if headless:
|
||
|
config = HeadlessGhidraApplicationConfiguration()
|
||
|
Application.initializeApplication(self._layout, config)
|
||
|
else:
|
||
|
GhidraRun().launch(self._layout, self.args)
|
||
|
|
||
|
|
||
|
class HeadlessPyhidraLauncher(PyhidraLauncher):
|
||
|
"""
|
||
|
Headless pyhidra launcher
|
||
|
"""
|
||
|
|
||
|
def _launch(self):
|
||
|
from ghidra.framework import Application, HeadlessGhidraApplicationConfiguration
|
||
|
with _silence_java_output(not self.verbose, not self.verbose):
|
||
|
config = HeadlessGhidraApplicationConfiguration()
|
||
|
Application.initializeApplication(self._layout, config)
|
||
|
|
||
|
|
||
|
class _PyhidraStdOut:
|
||
|
|
||
|
def __init__(self, stream):
|
||
|
self._stream = stream
|
||
|
|
||
|
def _get_current_script(self) -> "PyGhidraScript":
|
||
|
for entry in inspect.stack():
|
||
|
f_globals = entry.frame.f_globals
|
||
|
if isinstance(f_globals, PyGhidraScript):
|
||
|
return f_globals
|
||
|
|
||
|
def flush(self):
|
||
|
script = self._get_current_script()
|
||
|
if script is not None:
|
||
|
writer = script._script.writer
|
||
|
if writer is not None:
|
||
|
writer.flush()
|
||
|
return
|
||
|
|
||
|
self._stream.flush()
|
||
|
|
||
|
def write(self, s: str) -> int:
|
||
|
script = self._get_current_script()
|
||
|
if script is not None:
|
||
|
writer = script._script.writer
|
||
|
if writer is not None:
|
||
|
writer.write(s)
|
||
|
return len(s)
|
||
|
|
||
|
return self._stream.write(s)
|
||
|
|
||
|
|
||
|
class GuiPyhidraLauncher(PyhidraLauncher):
|
||
|
"""
|
||
|
GUI pyhidra launcher
|
||
|
"""
|
||
|
|
||
|
@classmethod
|
||
|
def popup_error(cls, header: str, msg: str) -> NoReturn:
|
||
|
import tkinter.messagebox
|
||
|
tkinter.messagebox.showerror(header, msg)
|
||
|
sys.exit()
|
||
|
|
||
|
@classmethod
|
||
|
def _report_fatal_error(cls, title: str, msg: str, cause: Exception) -> NoReturn:
|
||
|
logger.exception(cause, exc_info=cause)
|
||
|
cls.popup_error(title, msg)
|
||
|
|
||
|
@staticmethod
|
||
|
def _get_thread(name: str):
|
||
|
from java.lang import Thread
|
||
|
for t in Thread.getAllStackTraces().keySet():
|
||
|
if t.getName() == name:
|
||
|
return t
|
||
|
return None
|
||
|
|
||
|
def _launch(self):
|
||
|
import ctypes
|
||
|
from ghidra import Ghidra
|
||
|
from java.lang import Runtime, Thread
|
||
|
|
||
|
if sys.platform == "win32":
|
||
|
appid = ctypes.c_wchar_p(self.app_info.name)
|
||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
|
||
|
|
||
|
stdout = _PyhidraStdOut(sys.stdout)
|
||
|
stderr = _PyhidraStdOut(sys.stderr)
|
||
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||
|
jpype.setupGuiEnvironment(lambda: Ghidra.main(["ghidra.GhidraRun", *self.args]))
|
||
|
is_exiting = threading.Event()
|
||
|
Runtime.getRuntime().addShutdownHook(Thread(is_exiting.set))
|
||
|
try:
|
||
|
is_exiting.wait()
|
||
|
finally:
|
||
|
jpype.shutdownGuiEnvironment()
|