/*******************************************************************************
* Copyright (c) 2015 Jeff Martin.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Lesser General Public
* License v3.0 which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/lgpl.html
*
*
Contributors:
* Jeff Martin - initial API and implementation
******************************************************************************/
package cuchaz.enigma.gui;
import java.awt.Desktop;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.swing.JEditorPane;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import org.jetbrains.annotations.Nullable;
import cuchaz.enigma.Enigma;
import cuchaz.enigma.EnigmaProject;
import cuchaz.enigma.analysis.ClassImplementationsTreeNode;
import cuchaz.enigma.analysis.ClassInheritanceTreeNode;
import cuchaz.enigma.analysis.ClassReferenceTreeNode;
import cuchaz.enigma.analysis.EntryReference;
import cuchaz.enigma.analysis.FieldReferenceTreeNode;
import cuchaz.enigma.analysis.IndexTreeBuilder;
import cuchaz.enigma.analysis.MethodImplementationsTreeNode;
import cuchaz.enigma.analysis.MethodInheritanceTreeNode;
import cuchaz.enigma.analysis.MethodReferenceTreeNode;
import cuchaz.enigma.analysis.StructureTreeNode;
import cuchaz.enigma.analysis.StructureTreeOptions;
import cuchaz.enigma.api.DataInvalidationEvent;
import cuchaz.enigma.api.DataInvalidationListener;
import cuchaz.enigma.api.service.ObfuscationTestService;
import cuchaz.enigma.api.service.ProjectService;
import cuchaz.enigma.api.view.GuiView;
import cuchaz.enigma.api.view.entry.EntryReferenceView;
import cuchaz.enigma.api.view.entry.EntryView;
import cuchaz.enigma.classhandle.ClassHandle;
import cuchaz.enigma.classhandle.ClassHandleProvider;
import cuchaz.enigma.gui.config.LookAndFeel;
import cuchaz.enigma.gui.config.NetConfig;
import cuchaz.enigma.gui.config.UiConfig;
import cuchaz.enigma.gui.dialog.ProgressDialog;
import cuchaz.enigma.gui.newabstraction.EntryValidation;
import cuchaz.enigma.gui.panels.EditorPanel;
import cuchaz.enigma.gui.stats.StatsGenerator;
import cuchaz.enigma.gui.stats.StatsMember;
import cuchaz.enigma.gui.util.History;
import cuchaz.enigma.network.ClientPacketHandler;
import cuchaz.enigma.network.EnigmaClient;
import cuchaz.enigma.network.EnigmaServer;
import cuchaz.enigma.network.IntegratedEnigmaServer;
import cuchaz.enigma.network.Message;
import cuchaz.enigma.network.ServerPacketHandler;
import cuchaz.enigma.network.packet.EntryChangeC2SPacket;
import cuchaz.enigma.network.packet.LoginC2SPacket;
import cuchaz.enigma.network.packet.Packet;
import cuchaz.enigma.source.DecompiledClassSource;
import cuchaz.enigma.source.DecompilerService;
import cuchaz.enigma.source.SourceIndex;
import cuchaz.enigma.source.Token;
import cuchaz.enigma.translation.TranslateResult;
import cuchaz.enigma.translation.Translator;
import cuchaz.enigma.translation.mapping.EntryChange;
import cuchaz.enigma.translation.mapping.EntryMapping;
import cuchaz.enigma.translation.mapping.EntryRemapper;
import cuchaz.enigma.translation.mapping.EntryUtil;
import cuchaz.enigma.translation.mapping.MappingDelta;
import cuchaz.enigma.translation.mapping.ResolutionStrategy;
import cuchaz.enigma.translation.mapping.serde.MappingFormat;
import cuchaz.enigma.translation.mapping.serde.MappingParseException;
import cuchaz.enigma.translation.mapping.serde.MappingSaveParameters;
import cuchaz.enigma.translation.mapping.tree.EntryTree;
import cuchaz.enigma.translation.mapping.tree.HashEntryTree;
import cuchaz.enigma.translation.representation.entry.ClassEntry;
import cuchaz.enigma.translation.representation.entry.Entry;
import cuchaz.enigma.translation.representation.entry.FieldEntry;
import cuchaz.enigma.translation.representation.entry.MethodEntry;
import cuchaz.enigma.utils.I18n;
import cuchaz.enigma.utils.Utils;
import cuchaz.enigma.utils.validation.PrintValidatable;
import cuchaz.enigma.utils.validation.ValidationContext;
public class GuiController implements ClientPacketHandler, GuiView, DataInvalidationListener {
private final Gui gui;
public final Enigma enigma;
public EnigmaProject project;
private IndexTreeBuilder indexTreeBuilder;
private Path loadedMappingPath;
private MappingFormat loadedMappingFormat = MappingFormat.ENIGMA_DIRECTORY;
private ClassHandleProvider chp;
private ClassHandle tokenHandle;
private EnigmaClient client;
private EnigmaServer server;
private History, Entry>>> referenceHistory;
public GuiController(Gui gui, Enigma enigma) {
this.gui = gui;
this.enigma = enigma;
}
@Override
public EnigmaProject getProject() {
return project;
}
@Override
public JFrame getFrame() {
return gui.getFrame();
}
@Override
public float getScale() {
return UiConfig.getActiveScaleFactor();
}
@Override
public boolean isDarkTheme() {
return LookAndFeel.isDarkLaf();
}
@Override
public JEditorPane createEditorPane() {
JEditorPane editor = new JEditorPane();
EditorPanel.customizeEditor(editor);
return editor;
}
public boolean isDirty() {
return project != null && project.getMapper().isDirty();
}
public CompletableFuture openJar(final List jarPaths, final List libraries) {
this.gui.onStartOpenJar();
return ProgressDialog.runOffThread(gui.getFrame(), progress -> {
project = enigma.openJars(jarPaths, libraries, progress, false);
project.addDataInvalidationListener(this);
indexTreeBuilder = new IndexTreeBuilder(project.getJarIndex());
chp = new ClassHandleProvider(project, UiConfig.getDecompiler().service);
SwingUtilities.invokeLater(() -> {
for (ProjectService projectService : enigma.getServices().get(ProjectService.TYPE)) {
projectService.onProjectOpen(project);
}
gui.onFinishOpenJar(getFileNames(jarPaths));
refreshClasses();
});
});
}
private static String getFileNames(List jarPaths) {
return jarPaths.stream()
.map(Path::getFileName)
.map(Object::toString)
.collect(Collectors.joining(", "));
}
public void closeJar() {
for (ProjectService projectService : enigma.getServices().get(ProjectService.TYPE)) {
projectService.onProjectClose(project);
}
this.chp.destroy();
this.chp = null;
this.project = null;
this.gui.onCloseJar();
}
public CompletableFuture openMappings(MappingFormat format, Path path) {
if (project == null) {
return CompletableFuture.completedFuture(null);
}
gui.setMappingsFile(path);
return ProgressDialog.runOffThread(gui.getFrame(), progress -> {
try {
MappingSaveParameters saveParameters = enigma.getProfile().getMappingSaveParameters();
project.setMappings(format.read(path, progress, saveParameters, project.getJarIndex()));
loadedMappingFormat = format;
loadedMappingPath = path;
refreshClasses();
project.invalidateData(DataInvalidationEvent.InvalidationType.JAVADOC);
} catch (MappingParseException e) {
JOptionPane.showMessageDialog(gui.getFrame(), e.getMessage());
}
});
}
@Override
public void openMappings(EntryTree mappings) {
if (project == null) {
return;
}
project.setMappings(mappings);
refreshClasses();
project.invalidateData(DataInvalidationEvent.InvalidationType.JAVADOC);
}
public MappingFormat getLoadedMappingFormat() {
return loadedMappingFormat;
}
public CompletableFuture saveMappings(Path path) {
return saveMappings(path, loadedMappingFormat);
}
/**
* Saves the mappings, with a dialog popping up, showing the progress.
*
* Notice the returned completable future has to be completed by
* {@link SwingUtilities#invokeLater(Runnable)}. Hence, do not try to
* join on the future in gui, but rather call {@code thenXxx} methods.
*
* @param path the path of the save
* @param format the format of the save
* @return the future of saving
*/
public CompletableFuture saveMappings(Path path, MappingFormat format) {
if (project == null) {
return CompletableFuture.completedFuture(null);
}
return ProgressDialog.runOffThread(this.gui.getFrame(), progress -> {
EntryRemapper mapper = project.getMapper();
MappingSaveParameters saveParameters = enigma.getProfile().getMappingSaveParameters();
MappingDelta delta = mapper.takeMappingDelta();
boolean saveAll = !path.equals(loadedMappingPath);
loadedMappingFormat = format;
loadedMappingPath = path;
if (saveAll) {
format.write(mapper.getObfToDeobf(), path, progress, saveParameters);
} else {
format.write(mapper.getObfToDeobf(), delta, path, progress, saveParameters);
}
});
}
public void closeMappings() {
if (project == null) {
return;
}
project.setMappings(null);
this.gui.setMappingsFile(null);
refreshClasses();
project.invalidateData(DataInvalidationEvent.InvalidationType.JAVADOC);
}
public void reloadAll() {
List jarPaths = this.project.getJarPaths();
List libraryPaths = this.project.getLibraryPaths();
MappingFormat loadedMappingFormat = this.loadedMappingFormat;
Path loadedMappingPath = this.loadedMappingPath;
this.closeJar();
CompletableFuture f = this.openJar(jarPaths, libraryPaths);
if (loadedMappingFormat != null && loadedMappingPath != null) {
f.whenComplete((v, t) -> this.openMappings(loadedMappingFormat, loadedMappingPath));
}
}
public void reloadMappings() {
MappingFormat loadedMappingFormat = this.loadedMappingFormat;
Path loadedMappingPath = this.loadedMappingPath;
if (loadedMappingFormat != null && loadedMappingPath != null) {
this.closeMappings();
this.openMappings(loadedMappingFormat, loadedMappingPath);
}
}
public CompletableFuture dropMappings() {
if (project == null) {
return CompletableFuture.completedFuture(null);
}
return ProgressDialog.runOffThread(this.gui.getFrame(), progress -> project.dropMappings(progress));
}
public CompletableFuture exportSource(final Path path) {
if (project == null) {
return CompletableFuture.completedFuture(null);
}
return ProgressDialog.runOffThread(this.gui.getFrame(), progress -> {
EnigmaProject.JarExport jar = project.exportRemappedJar(progress);
jar.decompileStream(project, progress, chp.getDecompilerService(), EnigmaProject.DecompileErrorStrategy.TRACE_AS_SOURCE).forEach(source -> {
try {
source.writeTo(source.resolvePath(path));
} catch (IOException e) {
e.printStackTrace();
}
});
});
}
public CompletableFuture exportJar(final Path path) {
if (project == null) {
return CompletableFuture.completedFuture(null);
}
return ProgressDialog.runOffThread(this.gui.getFrame(), progress -> {
EnigmaProject.JarExport jar = project.exportRemappedJar(progress);
jar.write(path, progress);
});
}
public void setTokenHandle(ClassHandle handle) {
if (tokenHandle != null) {
tokenHandle.close();
}
tokenHandle = handle;
}
public ClassHandle getTokenHandle() {
return tokenHandle;
}
public ReadableToken getReadableToken(Token token) {
if (tokenHandle == null) {
return null;
}
try {
return tokenHandle.getSource().get().map(DecompiledClassSource::getIndex).map(index -> new ReadableToken(index.getLineNumber(token.start), index.getColumnNumber(token.start), index.getColumnNumber(token.end))).unwrapOr(null);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
@Override
@Nullable
public EntryReferenceView getCursorReference() {
return gui.getCursorReference();
}
@Override
@Nullable
public EntryView getCursorDeclaration() {
return gui.getCursorDeclaration();
}
/**
* Navigates to the declaration with respect to navigation history.
*
* @param entry the entry whose declaration will be navigated to
*/
public void openDeclaration(Entry> entry) {
if (entry == null) {
throw new IllegalArgumentException("Entry cannot be null!");
}
openReference(EntryReference.declaration(entry, entry.getName()));
}
/**
* Navigates to the reference with respect to navigation history.
*
* @param reference the reference
*/
public void openReference(EntryReference, Entry>> reference) {
if (reference == null) {
throw new IllegalArgumentException("Reference cannot be null!");
}
if (this.referenceHistory == null) {
this.referenceHistory = new History<>(reference);
} else {
if (!reference.equals(this.referenceHistory.getCurrent())) {
this.referenceHistory.push(reference);
}
}
this.gui.showReference(reference);
}
public List getTokensForReference(DecompiledClassSource source, EntryReference, Entry>> reference) {
EntryRemapper mapper = this.project.getMapper();
SourceIndex index = source.getIndex();
return mapper.getObfResolver().resolveReference(reference, ResolutionStrategy.RESOLVE_CLOSEST).stream().flatMap(r -> index.getReferenceTokens(r).stream()).sorted().toList();
}
public void openPreviousReference() {
if (hasPreviousReference()) {
this.gui.showReference(referenceHistory.goBack());
}
}
public boolean hasPreviousReference() {
return referenceHistory != null && referenceHistory.canGoBack();
}
public void openNextReference() {
if (hasNextReference()) {
this.gui.showReference(referenceHistory.goForward());
}
}
public boolean hasNextReference() {
return referenceHistory != null && referenceHistory.canGoForward();
}
public void navigateTo(Entry> entry) {
if (!project.isRenamable(entry)) {
// entry is not in the jar. Ignore it
return;
}
openDeclaration(entry);
}
public void navigateTo(EntryReference, Entry>> reference) {
if (!project.isRenamable(reference.getLocationClassEntry())) {
return;
}
openReference(reference);
}
public void refreshClasses() {
if (project == null) {
return;
}
List obfClasses = new ArrayList<>();
List deobfClasses = new ArrayList<>();
this.addSeparatedClasses(obfClasses, deobfClasses);
this.gui.setObfClasses(obfClasses);
this.gui.setDeobfClasses(deobfClasses);
}
public void addSeparatedClasses(List obfClasses, List deobfClasses) {
EntryRemapper mapper = project.getMapper();
Collection classes = project.getJarIndex().getEntryIndex().getClasses();
Stream visibleClasses = classes.stream().filter(entry -> !entry.isInnerClass());
visibleClasses.forEach(entry -> {
if (gui.isSingleClassTree()) {
deobfClasses.add(entry);
return;
}
TranslateResult result = mapper.extendedDeobfuscate(entry);
ClassEntry deobfEntry = result.getValue();
List obfService = enigma.getServices().get(ObfuscationTestService.TYPE);
boolean obfuscated = result.isObfuscated() && deobfEntry.equals(entry);
if (obfuscated && !obfService.isEmpty()) {
if (obfService.stream().anyMatch(service -> service.testDeobfuscated(entry))) {
obfuscated = false;
}
}
if (obfuscated) {
obfClasses.add(entry);
} else {
deobfClasses.add(entry);
}
});
}
public StructureTreeNode getClassStructure(ClassEntry entry, StructureTreeOptions options) {
StructureTreeNode rootNode = new StructureTreeNode(this.project, entry, entry);
rootNode.load(this.project, options);
return rootNode;
}
public ClassInheritanceTreeNode getClassInheritance(ClassEntry entry) {
Translator translator = project.getMapper().getDeobfuscator();
ClassInheritanceTreeNode rootNode = indexTreeBuilder.buildClassInheritance(translator, entry);
return ClassInheritanceTreeNode.findNode(rootNode, entry);
}
public ClassImplementationsTreeNode getClassImplementations(ClassEntry entry) {
Translator translator = project.getMapper().getDeobfuscator();
return this.indexTreeBuilder.buildClassImplementations(translator, entry);
}
public MethodInheritanceTreeNode getMethodInheritance(MethodEntry entry) {
Translator translator = project.getMapper().getDeobfuscator();
MethodInheritanceTreeNode rootNode = indexTreeBuilder.buildMethodInheritance(translator, entry);
return MethodInheritanceTreeNode.findNode(rootNode, entry);
}
public MethodImplementationsTreeNode getMethodImplementations(MethodEntry entry) {
Translator translator = project.getMapper().getDeobfuscator();
List rootNodes = indexTreeBuilder.buildMethodImplementations(translator, entry);
if (rootNodes.isEmpty()) {
return null;
}
if (rootNodes.size() > 1) {
System.err.println("WARNING: Method " + entry + " implements multiple interfaces. Only showing first one.");
}
return MethodImplementationsTreeNode.findNode(rootNodes.get(0), entry);
}
public ClassReferenceTreeNode getClassReferences(ClassEntry entry) {
Translator deobfuscator = project.getMapper().getDeobfuscator();
ClassReferenceTreeNode rootNode = new ClassReferenceTreeNode(deobfuscator, entry);
rootNode.load(project.getJarIndex(), true);
return rootNode;
}
public FieldReferenceTreeNode getFieldReferences(FieldEntry entry) {
Translator translator = project.getMapper().getDeobfuscator();
FieldReferenceTreeNode rootNode = new FieldReferenceTreeNode(translator, entry);
rootNode.load(project.getJarIndex(), true);
return rootNode;
}
public MethodReferenceTreeNode getMethodReferences(MethodEntry entry, boolean recursive) {
Translator translator = project.getMapper().getDeobfuscator();
MethodReferenceTreeNode rootNode = new MethodReferenceTreeNode(translator, entry);
rootNode.load(project.getJarIndex(), true, recursive);
return rootNode;
}
@Override
public boolean applyChangeFromServer(EntryChange> change) {
ValidationContext vc = new ValidationContext();
vc.setActiveElement(PrintValidatable.INSTANCE);
this.applyChange0(vc, change);
gui.showStructure(gui.getActiveEditor());
return vc.canProceed();
}
public void validateChange(ValidationContext vc, EntryChange> change) {
if (change.getDeobfName().isSet()) {
EntryValidation.validateRename(vc, this.project, change.getTarget(), change.getDeobfName().getNewValue());
}
if (change.getJavadoc().isSet()) {
EntryValidation.validateJavadoc(vc, change.getJavadoc().getNewValue());
}
}
public void applyChange(ValidationContext vc, EntryChange> change) {
this.applyChange0(vc, change);
gui.showStructure(gui.getActiveEditor());
if (!vc.canProceed()) {
return;
}
this.sendPacket(new EntryChangeC2SPacket(change));
}
private void applyChange0(ValidationContext vc, EntryChange> change) {
validateChange(vc, change);
if (!vc.canProceed()) {
return;
}
Entry> target = change.getTarget();
EntryMapping prev = this.project.getMapper().getDeobfMapping(target);
EntryMapping mapping = EntryUtil.applyChange(vc, this.project, this.project.getMapper(), change);
boolean renamed = !change.getDeobfName().isUnchanged();
if (renamed && target instanceof ClassEntry && !((ClassEntry) target).isInnerClass()) {
this.gui.moveClassTree(target, prev.targetName() == null, mapping.targetName() == null);
}
if (!Objects.equals(prev.javadoc(), mapping.javadoc())) {
project.invalidateData(target.getTopLevelClass().getFullName(), DataInvalidationEvent.InvalidationType.JAVADOC);
// invalidateJavadoc implies invalidateMapped, so no need to check for that too
} else if (!Objects.equals(prev.targetName(), mapping.targetName())) {
project.invalidateData(DataInvalidationEvent.InvalidationType.MAPPINGS);
}
gui.showStructure(gui.getActiveEditor());
}
public void openStats(Set includedMembers, String topLevelPackage, boolean includeSynthetic) {
ProgressDialog.runOffThread(gui.getFrame(), progress -> {
String data = new StatsGenerator(project).generate(progress, includedMembers, topLevelPackage, includeSynthetic).getTreeJson();
try {
File statsFile = File.createTempFile("stats", ".html");
try (FileWriter w = new FileWriter(statsFile, StandardCharsets.UTF_8)) {
w.write(Utils.readResourceToString("/stats.html").replace("/*data*/", data));
}
Desktop.getDesktop().open(statsFile);
} catch (IOException e) {
throw new Error(e);
}
});
}
public void setDecompiler(DecompilerService service) {
if (chp != null) {
chp.setDecompilerService(service);
}
}
public ClassHandleProvider getClassHandleProvider() {
return chp;
}
public EnigmaClient getClient() {
return client;
}
public EnigmaServer getServer() {
return server;
}
public void createClient(String username, String ip, int port, char[] password) throws IOException {
client = new EnigmaClient(this, ip, port);
client.connect();
client.sendPacket(new LoginC2SPacket(project.getJarChecksum(), password, username));
gui.setConnectionState(ConnectionState.CONNECTED);
}
public void createServer(int port, char[] password) throws IOException {
server = new IntegratedEnigmaServer(project.getJarChecksum(), password, EntryRemapper.mapped(project.getJarIndex(), new HashEntryTree<>(project.getMapper().getObfToDeobf())), port);
server.start();
client = new EnigmaClient(this, "127.0.0.1", port);
client.connect();
client.sendPacket(new LoginC2SPacket(project.getJarChecksum(), password, NetConfig.getUsername()));
gui.setConnectionState(ConnectionState.HOSTING);
}
@Override
public synchronized void disconnectIfConnected(String reason) {
if (client == null && server == null) {
return;
}
if (client != null) {
client.disconnect();
}
if (server != null) {
server.stop();
}
client = null;
server = null;
SwingUtilities.invokeLater(() -> {
if (reason != null) {
JOptionPane.showMessageDialog(gui.getFrame(), I18n.translate(reason), I18n.translate("disconnect.disconnected"), JOptionPane.INFORMATION_MESSAGE);
}
gui.setConnectionState(ConnectionState.NOT_CONNECTED);
});
}
@Override
public void sendPacket(Packet packet) {
if (client != null) {
client.sendPacket(packet);
}
}
@Override
public void addMessage(Message message) {
gui.addMessage(message);
}
@Override
public void updateUserList(List users) {
gui.setUserList(users);
}
@Override
public void onDataInvalidated(DataInvalidationEvent event) {
Objects.requireNonNull(project, "Invalidating data when no project is open");
if (event.getClasses() == null) {
switch (event.getType()) {
case MAPPINGS -> chp.invalidateMapped();
case JAVADOC -> chp.invalidateJavadoc();
case DECOMPILE -> chp.invalidate();
}
} else {
switch (event.getType()) {
case MAPPINGS -> {
for (String clazz : event.getClasses()) {
chp.invalidateMapped(new ClassEntry(clazz));
}
}
case JAVADOC -> {
for (String clazz : event.getClasses()) {
chp.invalidateJavadoc(new ClassEntry(clazz));
}
}
case DECOMPILE -> {
for (String clazz : event.getClasses()) {
chp.invalidate(new ClassEntry(clazz));
}
}
}
}
}
}